diff --git a/.buildkite/pipelines/bazel_cache.yml b/.buildkite/pipelines/bazel_cache.yml index daf56eb712a8..9aa961bcddbd 100644 --- a/.buildkite/pipelines/bazel_cache.yml +++ b/.buildkite/pipelines/bazel_cache.yml @@ -1,5 +1,7 @@ steps: - label: ':pipeline: Create pipeline with priority' + agents: + queue: kibana-default concurrency_group: bazel_macos concurrency: 1 concurrency_method: eager diff --git a/.buildkite/pipelines/es_snapshots/promote.yml b/.buildkite/pipelines/es_snapshots/promote.yml index 5a003321246a..f2f7b423c94c 100644 --- a/.buildkite/pipelines/es_snapshots/promote.yml +++ b/.buildkite/pipelines/es_snapshots/promote.yml @@ -10,3 +10,5 @@ steps: required: true - label: Promote Snapshot command: .buildkite/scripts/steps/es_snapshots/promote.sh + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/es_snapshots/verify.yml b/.buildkite/pipelines/es_snapshots/verify.yml index f98626ef25c0..18f3440b4acf 100755 --- a/.buildkite/pipelines/es_snapshots/verify.yml +++ b/.buildkite/pipelines/es_snapshots/verify.yml @@ -14,6 +14,8 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build timeout_in_minutes: 10 + agents: + queue: kibana-default - wait @@ -65,7 +67,7 @@ steps: - command: .buildkite/scripts/steps/test/jest_integration.sh label: 'Jest Integration Tests' - parallelism: 2 + parallelism: 3 agents: queue: n2-4 timeout_in_minutes: 120 @@ -85,6 +87,8 @@ steps: - command: .buildkite/scripts/steps/es_snapshots/trigger_promote.sh label: Trigger promotion timeout_in_minutes: 10 + agents: + queue: kibana-default depends_on: - default-cigroup - default-cigroup-docker @@ -98,3 +102,5 @@ steps: - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/flaky_tests/groups.json b/.buildkite/pipelines/flaky_tests/groups.json index b47ccf16a018..aa061af00bd6 100644 --- a/.buildkite/pipelines/flaky_tests/groups.json +++ b/.buildkite/pipelines/flaky_tests/groups.json @@ -13,6 +13,18 @@ "key": "oss/accessibility", "name": "OSS Accessibility" }, + { + "key": "xpack/cypress/security_solution", + "name": "Security Solution - Cypress" + }, + { + "key": "xpack/cypress/osquery_cypress", + "name": "Osquery - Cypress" + }, + { + "key": "xpack/cypress/fleet_cypress", + "name": "Fleet - Cypress" + }, { "key": "xpack/cigroup", "name": "Default CI Group", diff --git a/.buildkite/pipelines/flaky_tests/pipeline.js b/.buildkite/pipelines/flaky_tests/pipeline.js index cb5c37bf5834..b7f93412edb3 100644 --- a/.buildkite/pipelines/flaky_tests/pipeline.js +++ b/.buildkite/pipelines/flaky_tests/pipeline.js @@ -51,6 +51,9 @@ const pipeline = { { command: '.buildkite/pipelines/flaky_tests/runner.sh', label: 'Create pipeline', + agents: { + queue: 'kibana-default', + }, }, ], }; diff --git a/.buildkite/pipelines/flaky_tests/runner.js b/.buildkite/pipelines/flaky_tests/runner.js index cff4f9c0f29e..aa2e1f21c149 100644 --- a/.buildkite/pipelines/flaky_tests/runner.js +++ b/.buildkite/pipelines/flaky_tests/runner.js @@ -7,6 +7,9 @@ */ const { execSync } = require('child_process'); +const groups = /** @type {Array<{key: string, name: string, ciGroups: number }>} */ ( + require('./groups.json').groups +); const concurrency = 25; const defaultCount = concurrency * 2; @@ -113,7 +116,7 @@ steps.push({ label: 'Build Kibana Distribution and Plugins', agents: { queue: 'c2-8' }, key: 'build', - if: "build.env('BUILD_ID_FOR_ARTIFACTS') == null || build.env('BUILD_ID_FOR_ARTIFACTS') == ''", + if: "build.env('KIBANA_BUILD_ID') == null || build.env('KIBANA_BUILD_ID') == ''", }); for (const testSuite of testSuites) { @@ -183,6 +186,24 @@ for (const testSuite of testSuites) { concurrency_group: UUID, concurrency_method: 'eager', }); + case 'cypress': + const CYPRESS_SUITE = CI_GROUP; + const group = groups.find((group) => group.key.includes(CYPRESS_SUITE)); + if (!group) { + throw new Error( + `Group configuration was not found in groups.json for the following cypress suite: {${CYPRESS_SUITE}}.` + ); + } + steps.push({ + command: `.buildkite/scripts/steps/functional/${CYPRESS_SUITE}.sh`, + label: group.name, + agents: { queue: 'ci-group-6' }, + depends_on: 'build', + parallelism: RUN_COUNT, + concurrency: concurrency, + concurrency_group: UUID, + concurrency_method: 'eager', + }); break; } } diff --git a/.buildkite/pipelines/hourly.yml b/.buildkite/pipelines/hourly.yml index 6953c146050e..a236f9c37b31 100644 --- a/.buildkite/pipelines/hourly.yml +++ b/.buildkite/pipelines/hourly.yml @@ -2,6 +2,8 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build timeout_in_minutes: 10 + agents: + queue: kibana-default - wait @@ -127,10 +129,10 @@ steps: - command: .buildkite/scripts/steps/test/jest_integration.sh label: 'Jest Integration Tests' - parallelism: 2 + parallelism: 3 agents: queue: n2-4 - timeout_in_minutes: 90 + timeout_in_minutes: 120 key: jest-integration - command: .buildkite/scripts/steps/test/api_integration.sh @@ -174,3 +176,5 @@ steps: - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/on_merge.yml b/.buildkite/pipelines/on_merge.yml index e5f6dcc2d1d5..c6acb48b3e21 100644 --- a/.buildkite/pipelines/on_merge.yml +++ b/.buildkite/pipelines/on_merge.yml @@ -5,6 +5,8 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build timeout_in_minutes: 10 + agents: + queue: kibana-default - wait @@ -34,3 +36,5 @@ steps: - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/performance/daily.yml b/.buildkite/pipelines/performance/daily.yml index 208456f9c67a..658ab3a72f18 100644 --- a/.buildkite/pipelines/performance/daily.yml +++ b/.buildkite/pipelines/performance/daily.yml @@ -1,33 +1,27 @@ steps: - - block: ":gear: Performance Tests Configuration" - prompt: "Fill out the details for performance test" - fields: - - text: ":arrows_counterclockwise: Iterations" - key: "performance-test-iteration-count" - hint: "How many times you want to run tests? " - required: true - if: build.env('PERF_TEST_COUNT') == null - - - label: ":male-mechanic::skin-tone-2: Pre-Build" + - label: ':male-mechanic::skin-tone-2: Pre-Build' command: .buildkite/scripts/lifecycle/pre_build.sh + agents: + queue: kibana-default - wait - - label: ":factory_worker: Build Kibana Distribution and Plugins" + - label: ':factory_worker: Build Kibana Distribution and Plugins' command: .buildkite/scripts/steps/build_kibana.sh agents: queue: c2-16 key: build - - label: ":muscle: Performance Tests with Playwright config" + - label: ':muscle: Performance Tests with Playwright config' command: .buildkite/scripts/steps/functional/performance_playwright.sh agents: - queue: c2-16 + queue: kb-static-ubuntu depends_on: build - wait: ~ continue_on_failure: true - - label: ":male_superhero::skin-tone-2: Post-Build" + - label: ':male_superhero::skin-tone-2: Post-Build' command: .buildkite/scripts/lifecycle/post_build.sh - + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/pull_request.yml b/.buildkite/pipelines/pull_request.yml deleted file mode 100644 index 41c13bb403e1..000000000000 --- a/.buildkite/pipelines/pull_request.yml +++ /dev/null @@ -1,17 +0,0 @@ -env: - GITHUB_COMMIT_STATUS_ENABLED: 'true' - GITHUB_COMMIT_STATUS_CONTEXT: 'buildkite/kibana-pull-request' -steps: - - command: .buildkite/scripts/lifecycle/pre_build.sh - label: Pre-Build - - - wait - - - command: echo 'Hello World' - label: Test - - - wait: ~ - continue_on_failure: true - - - command: .buildkite/scripts/lifecycle/post_build.sh - label: Post-Build diff --git a/.buildkite/pipelines/pull_request/base.yml b/.buildkite/pipelines/pull_request/base.yml index d832717906bb..894f4c4368a3 100644 --- a/.buildkite/pipelines/pull_request/base.yml +++ b/.buildkite/pipelines/pull_request/base.yml @@ -2,6 +2,8 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build timeout_in_minutes: 10 + agents: + queue: kibana-default - wait @@ -127,10 +129,10 @@ steps: - command: .buildkite/scripts/steps/test/jest_integration.sh label: 'Jest Integration Tests' - parallelism: 2 + parallelism: 3 agents: queue: n2-4 - timeout_in_minutes: 90 + timeout_in_minutes: 120 key: jest-integration - command: .buildkite/scripts/steps/test/api_integration.sh diff --git a/.buildkite/pipelines/pull_request/post_build.yml b/.buildkite/pipelines/pull_request/post_build.yml index 4f252bf8abc1..63f716933458 100644 --- a/.buildkite/pipelines/pull_request/post_build.yml +++ b/.buildkite/pipelines/pull_request/post_build.yml @@ -4,3 +4,5 @@ steps: - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/purge_cloud_deployments.yml b/.buildkite/pipelines/purge_cloud_deployments.yml index 8287abf2ca5a..9567f67a047f 100644 --- a/.buildkite/pipelines/purge_cloud_deployments.yml +++ b/.buildkite/pipelines/purge_cloud_deployments.yml @@ -2,3 +2,5 @@ steps: - command: .buildkite/scripts/steps/cloud/purge.sh label: Purge old cloud deployments timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/update_demo_env.yml b/.buildkite/pipelines/update_demo_env.yml index e2dfdd782fd4..12c4f296f5df 100644 --- a/.buildkite/pipelines/update_demo_env.yml +++ b/.buildkite/pipelines/update_demo_env.yml @@ -2,6 +2,8 @@ steps: - command: .buildkite/scripts/steps/demo_env/es_and_init.sh label: Initialize Environment and Deploy ES timeout_in_minutes: 10 + agents: + queue: kibana-default - command: .buildkite/scripts/steps/demo_env/kibana.sh label: Build and Deploy Kibana diff --git a/.buildkite/scripts/steps/demo_env/kibana.sh b/.buildkite/scripts/steps/demo_env/kibana.sh index f38d43b5479e..77f2151f952c 100755 --- a/.buildkite/scripts/steps/demo_env/kibana.sh +++ b/.buildkite/scripts/steps/demo_env/kibana.sh @@ -9,7 +9,7 @@ source "$(dirname "${0}")/config.sh" export KIBANA_IMAGE="gcr.io/elastic-kibana-184716/demo/kibana:$DEPLOYMENT_NAME-$(git rev-parse HEAD)" echo '--- Build Kibana' -node scripts/build --debug --docker-images --example-plugins --skip-docker-ubi +node scripts/build --debug --docker-images --example-plugins --skip-docker-ubi --skip-docker-cloud --skip-docker-contexts echo '--- Build Docker image with example plugins' cd target/example_plugins diff --git a/.buildkite/scripts/steps/functional/performance_playwright.sh b/.buildkite/scripts/steps/functional/performance_playwright.sh index c38ef5e56dbe..596304d156cf 100644 --- a/.buildkite/scripts/steps/functional/performance_playwright.sh +++ b/.buildkite/scripts/steps/functional/performance_playwright.sh @@ -1,24 +1,55 @@ -#!/bin/bash +#!/usr/bin/env bash -set -uo pipefail +set -euo pipefail -if [ -z "${PERF_TEST_COUNT+x}" ]; then - TEST_COUNT="$(buildkite-agent meta-data get performance-test-iteration-count)" -else - TEST_COUNT=$PERF_TEST_COUNT -fi +source .buildkite/scripts/common/util.sh -tput setab 2; tput setaf 0; echo "Performance test will be run at ${BUILDKITE_BRANCH} ${TEST_COUNT} times" +.buildkite/scripts/bootstrap.sh +.buildkite/scripts/download_build_artifacts.sh -cat << EOF | buildkite-agent pipeline upload -steps: - - command: .buildkite/scripts/steps/functional/performance_sub_playwright.sh - parallelism: "$TEST_COUNT" - concurrency: 20 - concurrency_group: 'performance-test-group' - agents: - queue: c2-16 -EOF +echo --- Run Performance Tests with Playwright config +node scripts/es snapshot& +esPid=$! +export TEST_ES_URL=http://elastic:changeme@localhost:9200 +export TEST_ES_DISABLE_STARTUP=true + +sleep 120 + +cd "$XPACK_DIR" + +jobId=$(npx uuid) +export TEST_JOB_ID="$jobId" + +journeys=("ecommerce_dashboard" "flight_dashboard" "web_logs_dashboard" "promotion_tracking_dashboard") + +for i in "${journeys[@]}"; do + echo "JOURNEY[${i}] is running" + + export TEST_PERFORMANCE_PHASE=WARMUP + export ELASTIC_APM_ACTIVE=false + export JOURNEY_NAME="${i}" + + checks-reporter-with-killswitch "Run Performance Tests with Playwright Config (Journey:${i},Phase: WARMUP)" \ + node scripts/functional_tests \ + --config test/performance/config.playwright.ts \ + --include "test/performance/tests/playwright/${i}.ts" \ + --kibana-install-dir "$KIBANA_BUILD_LOCATION" \ + --debug \ + --bail + + export TEST_PERFORMANCE_PHASE=TEST + export ELASTIC_APM_ACTIVE=true + + checks-reporter-with-killswitch "Run Performance Tests with Playwright Config (Journey:${i},Phase: TEST)" \ + node scripts/functional_tests \ + --config test/performance/config.playwright.ts \ + --include "test/performance/tests/playwright/${i}.ts" \ + --kibana-install-dir "$KIBANA_BUILD_LOCATION" \ + --debug \ + --bail +done + +kill "$esPid" diff --git a/.buildkite/scripts/steps/functional/performance_sub_playwright.sh b/.buildkite/scripts/steps/functional/performance_sub_playwright.sh deleted file mode 100644 index fee171aef9a4..000000000000 --- a/.buildkite/scripts/steps/functional/performance_sub_playwright.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -source .buildkite/scripts/common/util.sh - -.buildkite/scripts/bootstrap.sh -.buildkite/scripts/download_build_artifacts.sh - -echo --- Run Performance Tests with Playwright config - -node scripts/es snapshot& - -esPid=$! - -export TEST_PERFORMANCE_PHASE=WARMUP -export TEST_ES_URL=http://elastic:changeme@localhost:9200 -export TEST_ES_DISABLE_STARTUP=true -export ELASTIC_APM_ACTIVE=false - -sleep 120 - -cd "$XPACK_DIR" - -# warmup round 1 -checks-reporter-with-killswitch "Run Performance Tests with Playwright Config (Phase: WARMUP)" \ - node scripts/functional_tests \ - --debug --bail \ - --kibana-install-dir "$KIBANA_BUILD_LOCATION" \ - --config "test/performance/config.playwright.ts"; - -export TEST_PERFORMANCE_PHASE=TEST -export ELASTIC_APM_ACTIVE=true - -checks-reporter-with-killswitch "Run Performance Tests with Playwright Config (Phase: TEST)" \ - node scripts/functional_tests \ - --debug --bail \ - --kibana-install-dir "$KIBANA_BUILD_LOCATION" \ - --config "test/performance/config.playwright.ts"; - -kill "$esPid" diff --git a/.buildkite/scripts/steps/functional/uptime.sh b/.buildkite/scripts/steps/functional/uptime.sh index 5a59f4dfa48b..a1c8c2bf6c85 100755 --- a/.buildkite/scripts/steps/functional/uptime.sh +++ b/.buildkite/scripts/steps/functional/uptime.sh @@ -14,4 +14,4 @@ echo "--- Uptime @elastic/synthetics Tests" cd "$XPACK_DIR" checks-reporter-with-killswitch "Uptime @elastic/synthetics Tests" \ - node plugins/uptime/scripts/e2e.js --kibana-install-dir "$KIBANA_BUILD_LOCATION" + node plugins/uptime/scripts/e2e.js --kibana-install-dir "$KIBANA_BUILD_LOCATION" ${GREP:+--grep \"${GREP}\"} diff --git a/.buildkite/scripts/steps/package_testing/test.sh b/.buildkite/scripts/steps/package_testing/test.sh index 8fcb665b67a9..a9a46502d5b3 100755 --- a/.buildkite/scripts/steps/package_testing/test.sh +++ b/.buildkite/scripts/steps/package_testing/test.sh @@ -10,13 +10,13 @@ mkdir -p target cd target if [[ "$TEST_PACKAGE" == "deb" ]]; then buildkite-agent artifact download 'kibana-*.deb' . --build "${KIBANA_BUILD_ID:-$BUILDKITE_BUILD_ID}" - KIBANA_IP_ADDRESS="192.168.50.5" + KIBANA_IP_ADDRESS="192.168.56.5" elif [[ "$TEST_PACKAGE" == "rpm" ]]; then buildkite-agent artifact download 'kibana-*.rpm' . --build "${KIBANA_BUILD_ID:-$BUILDKITE_BUILD_ID}" - KIBANA_IP_ADDRESS="192.168.50.6" + KIBANA_IP_ADDRESS="192.168.56.6" elif [[ "$TEST_PACKAGE" == "docker" ]]; then buildkite-agent artifact download "kibana-$KIBANA_PKG_VERSION-SNAPSHOT-docker-image.tar.gz" . --build "${KIBANA_BUILD_ID:-$BUILDKITE_BUILD_ID}" - KIBANA_IP_ADDRESS="192.168.50.7" + KIBANA_IP_ADDRESS="192.168.56.7" fi cd .. @@ -24,7 +24,7 @@ export VAGRANT_CWD=test/package vagrant up "$TEST_PACKAGE" --no-provision node scripts/es snapshot \ - -E network.bind_host=127.0.0.1,192.168.50.1 \ + -E network.bind_host=127.0.0.1,192.168.56.1 \ -E discovery.type=single-node \ --license=trial & while ! timeout 1 bash -c "echo > /dev/tcp/localhost/9200"; do sleep 30; done @@ -33,7 +33,7 @@ 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.50.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 diff --git a/.ci/package-testing/Jenkinsfile b/.ci/package-testing/Jenkinsfile deleted file mode 100644 index fec7dc9ea4cd..000000000000 --- a/.ci/package-testing/Jenkinsfile +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/groovy -library 'kibana-pipeline-library' -kibanaLibrary.load() -kibanaPipeline(timeoutMinutes: 120) { - slackNotifications.onFailure { - ciStats.trackBuild { - workers.ci(ramDisk: false, name: "package-build", size: 'l', runErrorReporter: false) { - withGcpServiceAccount.fromVaultSecret('secret/kibana-issues/dev/ci-artifacts-key', 'value') { - kibanaPipeline.bash("test/scripts/jenkins_xpack_package_build.sh", "Package builds") - } - } - def packageTypes = ['deb', 'docker', 'rpm'] - def workers = [:] - packageTypes.each { type -> - workers["package-${type}"] = { - testPackage(type) - } - } - parallel(workers) - } - } -} -def testPackage(packageType) { - workers.ci(ramDisk: false, name: "package-${packageType}", size: 's', runErrorReporter: false) { - withGcpServiceAccount.fromVaultSecret('secret/kibana-issues/dev/ci-artifacts-key', 'value') { - kibanaPipeline.bash("test/scripts/jenkins_xpack_package_${packageType}.sh", "Execute package testing for ${packageType}") - } - } -} diff --git a/.eslintignore b/.eslintignore index 7b9b7f77e837..9b745756b670 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,5 @@ **/*.js.snap +__tmp__ /.es /.chromium /build @@ -31,6 +32,7 @@ snapshots.js # package overrides /packages/elastic-eslint-config-kibana /packages/kbn-plugin-generator/template +/packages/kbn-generate/templates /packages/kbn-pm/dist /packages/kbn-test/src/functional_test_runner/__tests__/fixtures/ /packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/ diff --git a/.eslintrc.js b/.eslintrc.js index 6c98a016469f..af9d77c4a966 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,6 +6,11 @@ * Side Public License, v 1. */ +const Path = require('path'); +const Fs = require('fs'); + +const globby = require('globby'); + const APACHE_2_0_LICENSE_HEADER = ` /* * Licensed to Elasticsearch B.V. under one or more contributor @@ -89,22 +94,16 @@ const SAFER_LODASH_SET_DEFINITELYTYPED_HEADER = ` */ `; +const packagePkgJsons = globby.sync('*/package.json', { + cwd: Path.resolve(__dirname, 'packages'), + absolute: true, +}); + /** Packages which should not be included within production code. */ -const DEV_PACKAGES = [ - 'kbn-babel-code-parser', - 'kbn-dev-utils', - 'kbn-cli-dev-mode', - 'kbn-docs-utils', - 'kbn-es*', - 'kbn-eslint*', - 'kbn-optimizer', - 'kbn-plugin-generator', - 'kbn-plugin-helpers', - 'kbn-pm', - 'kbn-storybook', - 'kbn-telemetry-tools', - 'kbn-test', -]; +const DEV_PACKAGES = packagePkgJsons.flatMap((path) => { + const pkg = JSON.parse(Fs.readFileSync(path, 'utf8')); + return pkg.kibana && pkg.kibana.devOnly ? Path.dirname(Path.basename(path)) : []; +}); /** Directories (at any depth) which include dev-only code. */ const DEV_DIRECTORIES = [ @@ -1489,6 +1488,10 @@ module.exports = { 'import/newline-after-import': 'error', 'react-hooks/exhaustive-deps': 'off', 'react/jsx-boolean-value': ['error', 'never'], + '@typescript-eslint/no-unused-vars': [ + 'error', + { vars: 'all', args: 'after-used', ignoreRestSiblings: true, varsIgnorePattern: '^_' }, + ], }, }, { @@ -1632,28 +1635,6 @@ module.exports = { }, }, - /** - * Prettier disables all conflicting rules, listing as last override so it takes precedence - */ - { - files: ['**/*'], - rules: { - ...require('eslint-config-prettier').rules, - ...require('eslint-config-prettier/react').rules, - ...require('eslint-config-prettier/@typescript-eslint').rules, - }, - }, - /** - * Enterprise Search Prettier override - * Lints unnecessary backticks - @see https://github.com/prettier/eslint-config-prettier/blob/main/README.md#forbid-unnecessary-backticks - */ - { - files: ['x-pack/plugins/enterprise_search/**/*.{ts,tsx}'], - rules: { - quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }], - }, - }, - /** * Platform Security Team overrides */ @@ -1768,5 +1749,34 @@ module.exports = { '@kbn/eslint/no_export_all': 'error', }, }, + + { + files: ['packages/kbn-type-summarizer/**/*.ts'], + rules: { + 'no-bitwise': 'off', + }, + }, + + /** + * Prettier disables all conflicting rules, listing as last override so it takes precedence + */ + { + files: ['**/*'], + rules: { + ...require('eslint-config-prettier').rules, + ...require('eslint-config-prettier/react').rules, + ...require('eslint-config-prettier/@typescript-eslint').rules, + }, + }, + /** + * Enterprise Search Prettier override + * Lints unnecessary backticks - @see https://github.com/prettier/eslint-config-prettier/blob/main/README.md#forbid-unnecessary-backticks + */ + { + files: ['x-pack/plugins/enterprise_search/**/*.{ts,tsx}'], + rules: { + quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }], + }, + }, ], }; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 63e335067199..b8563e3e44e8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -72,8 +72,6 @@ /src/plugins/field_formats/ @elastic/kibana-app-services /src/plugins/data_view_editor/ @elastic/kibana-app-services /src/plugins/inspector/ @elastic/kibana-app-services -/src/plugins/kibana_react/ @elastic/kibana-app-services -/src/plugins/kibana_react/public/code_editor @elastic/kibana-presentation /src/plugins/kibana_utils/ @elastic/kibana-app-services /src/plugins/navigation/ @elastic/kibana-app-services /src/plugins/share/ @elastic/kibana-app-services @@ -223,9 +221,11 @@ /packages/kbn-test/ @elastic/kibana-operations /packages/kbn-ui-shared-deps-npm/ @elastic/kibana-operations /packages/kbn-ui-shared-deps-src/ @elastic/kibana-operations +/packages/kbn-bazel-packages/ @elastic/kibana-operations /packages/kbn-es-archiver/ @elastic/kibana-operations /packages/kbn-utils/ @elastic/kibana-operations /packages/kbn-cli-dev-mode/ @elastic/kibana-operations +/packages/kbn-generate/ @elastic/kibana-operations /src/cli/keystore/ @elastic/kibana-operations /.ci/es-snapshots/ @elastic/kibana-operations /.github/workflows/ @elastic/kibana-operations @@ -416,14 +416,17 @@ x-pack/plugins/security_solution/cypress/upgrade_integration @elastic/security-e x-pack/plugins/security_solution/cypress/README.md @elastic/security-engineering-productivity x-pack/test/security_solution_cypress @elastic/security-engineering-productivity +## Security Solution sub teams - adaptive-workload-protection +x-pack/plugins/session_view @elastic/awp-platform + # Security Intelligence And Analytics /x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules @elastic/security-intelligence-analytics # Security Asset Management /x-pack/plugins/osquery @elastic/security-asset-management -# Cloud Posture Security -/x-pack/plugins/cloud_security_posture/ @elastic/cloud-posture-security +# Cloud Security Posture +/x-pack/plugins/cloud_security_posture/ @elastic/cloud-security-posture-control-plane # Design (at the bottom for specificity of SASS files) **/*.scss @elastic/kibana-design @@ -464,9 +467,11 @@ x-pack/test/security_solution_cypress @elastic/security-engineering-productivity #CC# /x-pack/plugins/reporting/ @elastic/kibana-reporting-services # EUI design -/src/plugins/kibana_react/public/page_template/ @elastic/eui-design @elastic/kibana-app-services +/src/plugins/kibana_react/public/page_template/ @elastic/eui-design @elastic/shared-ux # Application Experience ## Shared UX -/src/plugins/shared_ux @elastic/shared-ux +/src/plugins/shared_ux/ @elastic/shared-ux +/src/plugins/kibana_react/ @elastic/shared-ux +/src/plugins/kibana_react/public/code_editor @elastic/kibana-presentation diff --git a/.github/workflows/add-to-imui-project.yml b/.github/workflows/add-to-imui-project.yml new file mode 100644 index 000000000000..3cf120b2e81b --- /dev/null +++ b/.github/workflows/add-to-imui-project.yml @@ -0,0 +1,31 @@ +name: Add to Infra Monitoring UI project +on: + issues: + types: + - labeled +jobs: + add_to_project: + runs-on: ubuntu-latest + if: | + contains(github.event.issue.labels.*.name, 'Team:Infra Monitoring UI') || + contains(github.event.issue.labels.*.name, 'Feature:Stack Monitoring') || + contains(github.event.issue.labels.*.name, 'Feature:Logs UI') || + contains(github.event.issue.labels.*.name, 'Feature:Metrics UI') + steps: + - uses: octokit/graphql-action@v2.x + id: add_to_project + with: + headers: '{"GraphQL-Features": "projects_next_graphql"}' + query: | + mutation add_to_project($projectid:ID!,$contentid:ID!) { + addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { + projectNextItem { + id + } + } + } + projectid: ${{ env.PROJECT_ID }} + contentid: ${{ github.event.issue.node_id }} + env: + PROJECT_ID: "PN_kwDOAGc3Zs1EEA" + GITHUB_TOKEN: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} diff --git a/.github/workflows/backport-next.yml b/.github/workflows/backport-next.yml deleted file mode 100644 index 6779bb424724..000000000000 --- a/.github/workflows/backport-next.yml +++ /dev/null @@ -1,27 +0,0 @@ -on: - pull_request_target: - branches: - - main - types: - - labeled - - closed - -jobs: - backport: - name: Backport PR - runs-on: ubuntu-latest - if: | - github.event.pull_request.merged == true - && contains(github.event.pull_request.labels.*.name, 'auto-backport-next') - && ( - (github.event.action == 'labeled' && github.event.label.name == 'auto-backport-next') - || (github.event.action == 'closed') - ) - steps: - - name: Backport Action - uses: sqren/backport-github-action@v7.3.1 - with: - github_token: ${{secrets.KIBANAMACHINE_TOKEN}} - - - name: Backport log - run: cat /home/runner/.backport/backport.log diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index d126ea6ec9b3..375854b9c54b 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -9,6 +9,7 @@ on: jobs: backport: name: Backport PR + runs-on: ubuntu-latest if: | github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'auto-backport') @@ -16,26 +17,11 @@ jobs: (github.event.action == 'labeled' && github.event.label.name == 'auto-backport') || (github.event.action == 'closed') ) - runs-on: ubuntu-latest steps: - - name: Checkout Actions - uses: actions/checkout@v2 - with: - repository: 'elastic/kibana-github-actions' - ref: main - path: ./actions - - - name: Install Actions - run: npm install --production --prefix ./actions - - - name: Fix Version Label Gaps - uses: ./actions/fix-version-gaps + - name: Backport Action + uses: sqren/backport-github-action@v7.4.0 with: github_token: ${{secrets.KIBANAMACHINE_TOKEN}} - - name: Run Backport - uses: ./actions/backport - with: - github_token: ${{secrets.KIBANAMACHINE_TOKEN}} - commit_user: kibanamachine - commit_email: 42973632+kibanamachine@users.noreply.github.com + - name: Backport log + run: cat ~/.backport/backport.log diff --git a/.github/workflows/label-qa-fixed-in.yml b/.github/workflows/label-qa-fixed-in.yml index 836aa308e92c..99803c2c4e88 100644 --- a/.github/workflows/label-qa-fixed-in.yml +++ b/.github/workflows/label-qa-fixed-in.yml @@ -19,7 +19,7 @@ jobs: github.event.pull_request.merged_at && contains(github.event.pull_request.labels.*.name, 'Team:Fleet') outputs: - matrix: ${{ steps.issues_to_label.outputs.value }} + issue_ids: ${{ steps.issues_to_label.outputs.value }} label_ids: ${{ steps.label_ids.outputs.value }} steps: - uses: octokit/graphql-action@v2.x @@ -66,22 +66,28 @@ jobs: label_issues: needs: fetch_issues_to_label runs-on: ubuntu-latest - # For each issue closed by the PR run this job + + # For each issue closed by the PR x each label to apply, run this job + if: | + fromJSON(needs.fetch_issues_to_label.outputs.issue_ids).length > 0 && + fromJSON(needs.fetch_issues_to_label.outputs.label_ids).length > 0 strategy: matrix: - issueNodeId: ${{ fromJSON(needs.fetch_issues_to_label.outputs.matrix) }} - name: Label issue ${{ matrix.issueNodeId }} + issueId: ${{ fromJSON(needs.fetch_issues_to_label.outputs.issue_ids) }} + labelId: ${{ fromJSON(needs.fetch_issues_to_label.outputs.label_ids) }} + + name: Label issue ${{ matrix.issueId }} with ${{ matrix.labelId }} steps: - uses: octokit/graphql-action@v2.x id: add_labels_to_closed_issue with: query: | - mutation add_label($issueid: ID!, $labelids:[ID!]!) { - addLabelsToLabelable(input: {labelableId: $issueid, labelIds: $labelids}) { + mutation add_label($issueid: ID!, $labelid:ID!) { + addLabelsToLabelable(input: {labelableId: $issueid, labelIds: [$labelid]}) { clientMutationId } } - issueid: ${{ matrix.issueNodeId }} - labelids: ${{ fromJSON(needs.fetch_issues_to_label.outputs.label_ids) }} + issueid: ${{ matrix.issueId }} + labelid: ${{ matrix.labelId }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/project-assigner.yml b/.github/workflows/project-assigner.yml index 65808dffd801..8c381dd1ecde 100644 --- a/.github/workflows/project-assigner.yml +++ b/.github/workflows/project-assigner.yml @@ -18,8 +18,6 @@ jobs: {"label": "Feature:Canvas", "projectNumber": 38, "columnName": "Inbox"}, {"label": "Feature:Dashboard", "projectNumber": 68, "columnName": "Inbox"}, {"label": "Feature:Drilldowns", "projectNumber": 68, "columnName": "Inbox"}, - {"label": "Feature:Input Controls", "projectNumber": 72, "columnName": "Inbox"}, - {"label": "Team:Security", "projectNumber": 320, "columnName": "Awaiting triage", "projectScope": "org"}, - {"label": "Team:Operations", "projectNumber": 314, "columnName": "Triage", "projectScope": "org"} + {"label": "Feature:Input Controls", "projectNumber": 72, "columnName": "Inbox"} ] ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} diff --git a/.gitignore b/.gitignore index 818d3a472d52..4704247e6f54 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ target *.iml *.log types.eslint.config.js +__tmp__ # Ignore example plugin builds /examples/*/build @@ -95,4 +96,3 @@ fleet-server-* elastic-agent.yml fleet-server.yml -/x-pack/plugins/fleet/server/bundled_packages diff --git a/.i18nrc.json b/.i18nrc.json index 5c362908a187..eeb2578ef347 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -66,7 +66,9 @@ "uiActions": "src/plugins/ui_actions", "uiActionsExamples": "examples/ui_action_examples", "usageCollection": "src/plugins/usage_collection", + "utils": "packages/kbn-securitysolution-utils/src", "visDefaultEditor": "src/plugins/vis_default_editor", + "visTypeGauge": "src/plugins/vis_types/gauge", "visTypeHeatmap": "src/plugins/vis_types/heatmap", "visTypeMarkdown": "src/plugins/vis_type_markdown", "visTypeMetric": "src/plugins/vis_types/metric", diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index 76c516ecc605..04f61b7f9506 100644 --- a/WORKSPACE.bazel +++ b/WORKSPACE.bazel @@ -64,4 +64,8 @@ yarn_install( symlink_node_modules = True, quiet = False, frozen_lockfile = False, + environment = { + "SASS_BINARY_SITE": "https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/node-sass", + "RE2_DOWNLOAD_MIRROR": "https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/node-re2", + } ) diff --git a/config/kibana.yml b/config/kibana.yml index 9143b23d590f..8ca8eb673c27 100644 --- a/config/kibana.yml +++ b/config/kibana.yml @@ -128,7 +128,7 @@ #ops.interval: 5000 # Specifies locale to be used for all localizable strings, dates and number formats. -# Supported languages are the following: English - en , by default , Chinese - zh-CN . +# Supported languages are the following: English (default) "en", Chinese "zh-CN", Japanese "ja-JP", French "fr-FR". #i18n.locale: "en" # =================== Frequently used (Optional)=================== diff --git a/dev_docs/contributing/best_practices.mdx b/dev_docs/contributing/best_practices.mdx index d7aa42946eac..e1f3b5ad4dbb 100644 --- a/dev_docs/contributing/best_practices.mdx +++ b/dev_docs/contributing/best_practices.mdx @@ -9,140 +9,12 @@ tags: ['kibana', 'onboarding', 'dev', 'architecture'] ## General -First things first, be sure to review our and check out all the available -platform that can simplify plugin development. +Be sure to follow our +and . + +## Documentation -## Developer documentation - -### High-level documentation - -#### Structure - -Refer to [divio documentation](https://documentation.divio.com/) for guidance on where and how to structure our high-level documentation. - - and - sections are both _explanation_ oriented, - covers both _tutorials_ and _How to_, and the section covers _reference_ material. - -#### Location - -If the information spans multiple plugins, consider adding it to the [dev_docs](https://github.com/elastic/kibana/tree/main/dev_docs) folder. If it is plugin specific, consider adding it inside the plugin folder. Write it in an mdx file if you would like it to show up in our new (beta) documentation system. - - - -To add docs into the new docs system, create an `.mdx` file that -contains . Read about the syntax . An extra step is needed to add a menu item. will walk you through how to set the docs system -up locally and edit the nav menu. - - - -#### Keep content fresh - -A fresh pair of eyes are invaluable. Recruit new hires to read, review and update documentation. Leads should also periodically review documentation to ensure it stays up to date. File issues any time you notice documentation is outdated. - -#### Consider your target audience - -Documentation in the Kibana Developer Guide is targeted towards developers building Kibana plugins. Keep implementation details about internal plugin code out of these docs. - -#### High to low level - -When a developer first lands in our docs, think about their journey. Introduce basic concepts before diving into details. The left navigation should be set up so documents on top are higher level than documents near the bottom. - -#### Think outside-in - -It's easy to forget what it felt like to first write code in Kibana, but do your best to frame these docs "outside-in". Don't use esoteric, internal language unless a definition is documented and linked. The fresh eyes of a new hire can be a great asset. - -### API documentation - -We automatically generate . The following guidelines will help ensure your are useful. - -#### Code comments - -Every publicly exposed function, class, interface, type, parameter and property should have a comment using JSDoc style comments. - -- Use `@param` tags for every function parameter. -- Use `@returns` tags for return types. -- Use `@throws` when appropriate. -- Use `@beta` or `@deprecated` when appropriate. -- Use `@removeBy {version}` on `@deprecated` APIs. The version should be the last version the API will work in. For example, `@removeBy 7.15` means the API will be removed in 7.16. This lets us avoid mid-release cycle coordination. The API can be removed as soon as the 7.15 branch is cut. -- Use `@internal` to indicate this API item is intended for internal use only, which will also remove it from the docs. - -#### Interfaces vs inlined types - -Prefer types and interfaces over complex inline objects. For example, prefer: - -```ts -/** -* The SearchSpec interface contains settings for creating a new SearchService, like -* username and password. -*/ -export interface SearchSpec { - /** - * Stores the username. Duh, - */ - username: string; - /** - * Stores the password. I hope it's encrypted! - */ - password: string; -} - - /** - * Retrieve search services - * @param searchSpec Configuration information for initializing the search service. - * @returns the id of the search service - */ -export getSearchService: (searchSpec: SearchSpec) => string; -``` - -over: - -```ts -/** - * Retrieve search services - * @param searchSpec Configuration information for initializing the search service. - * @returns the id of the search service - */ -export getSearchService: (searchSpec: { username: string; password: string }) => string; -``` - -In the former, there will be a link to the `SearchSpec` interface with documentation for the `username` and `password` properties. In the latter the object will render inline, without comments: - -![prefer interfaces documentation](../assets/dev_docs_nested_object.png) - -#### Export every type used in a public API - -When a publicly exported API items references a private type, this results in a broken link in our docs system. The private type is, by proxy, part of your public API, and as such, should be exported. - -Do: - -```ts -export interface AnInterface { bar: string }; -export type foo: string | AnInterface; -``` - -Don't: - -```ts -interface AnInterface { bar: string }; -export type foo: string | AnInterface; -``` - -#### Avoid “Pick” - -`Pick` not only ends up being unhelpful in our documentation system, but it's also of limited help in your IDE. For that reason, avoid `Pick` and other similarly complex types on your public API items. Using these semantics internally is fine. - -![pick api documentation](../assets/api_doc_pick.png) - -### Example plugins - -Running Kibana with `yarn start --run-examples` will include all [example plugins](https://github.com/elastic/kibana/tree/main/examples). These are tested examples of platform services in use. We strongly encourage anyone providing a platform level service or to include a tutorial that links to a tested example plugin. This is better than relying on copied code snippets, which can quickly get out of date. - -You can also visit these [examples plugins hosted online](https://demo.kibana.dev/8.0/app/home). Note that because anonymous access is enabled, some -of the demos are currently not working. +Documentation best practices can be found . ## Performance @@ -182,11 +54,27 @@ We use es-lint rules when possible, but please review our [styleguide](https://g Es-lint overrides on a per-plugin level are discouraged. -## Plugin best practices +## Using the SavedObjectClient -Don't export without reason. Make your public APIs as small as possible. You will have to maintain them, and consider backward compatibility when making changes. +The should always be used for reading and writing saved objects that you manage. For saved objects managed by other plugins, their plugin APIs should be used instead. + +Good: +``` +const dataView = dataViewStartContract.get(dataViewId); +``` + +Bad: +``` +const dataView = savedObjectsClient.get(dataViewId) as DataView; +``` + +## Resusable react components -Add `README.md` to all your plugins and services and include contact information. +Use [EUI](https://elastic.github.io/eui) for all your basic UI components to create a consistent UI experience. We also have generic UI components offered from the plugin and the plugin. + +## Don't export code that doesn't need to be public + +Don't export without reason. Make your public APIs as small as possible. You will have to maintain them, and consider backward compatibility when making changes. ## Re-inventing the wheel @@ -248,6 +136,77 @@ There are some exceptions where a separate repo makes sense. However, they are e It may be tempting to get caught up in the dream of writing the next package which is published to npm and downloaded millions of times a week. Knowing the quality of developers that are working on Kibana, this is a real possibility. However, knowing which packages will see mass adoption is impossible to predict. Instead of jumping directly to writing code in a separate repo and accepting all of the complications that come along with it, prefer keeping code inside the Kibana repo. A [Kibana package](https://github.com/elastic/kibana/tree/main/packages) can be used to publish a package to npm, while still keeping the code inside the Kibana repo. Move code to an external repo only when there is a good reason, for example to enable external contributions. +## Licensing + + + +Has there been a discussion about which license this feature should be available under? Open up a license issue in [https://github.com/elastic/dev](https://github.com/elastic/dev) if you are unsure. + + + +## Testing scenarios + +Every PR submitted should be accompanied by tests. Read through the for how to test. + +### Browser coverage + +Refer to the list of browsers and OS Kibana supports https://www.elastic.co/support/matrix + +Does the feature work efficiently on the below listed browsers + - chrome + - Firefox + - Safari + - IE11 + +### Upgrade Scenarios + - Migration scenarios- Does the feature affect old indices, saved objects ? + - Has the feature been tested with Kibana aliases + - Read/Write privileges of the indices before and after the upgrade? + +### Test coverage + - Does the feature have sufficient unit test coverage? (does it handle storeinSessions?) + - Does the feature have sufficient Functional UI test coverage? + - Does the feature have sufficient Rest API coverage test coverage? + - Does the feature have sufficient Integration test coverage? + +### Environment configurations + +- Kibana should be fully [cross cluster search](https://www.elastic.co/guide/en/elasticsearch/reference/master/modules-cross-cluster-search.html) compatible (aside from admin UIs which only work on the local cluster). +- How does your plugin behave when optional dependencies are disabled? +- How does your app behave under anonymous access, or with security disabled? +- Make sure to test your PR in a cloud environment. Read about the label which makes this very easy. + + +## Backward compatibility + +Any time you change state that is part of a Saved Object you will have to write a . + +Never store state from another plugin in your Saved Objects or URLs unless it implements the . Remember to check for migrations when deserializing that state. + +If you expose state and you wish to allow other plugins to persist you must ensure it implements the . This is very common for `by value` entities, like visualizations that exist on a dashboard but are not part of the visualization library. If you make a breaking change to this state you must remember to register a migration for it. + +Saved objects exported from past Kibana versions should always continue to work. Bookmarked URLs should also always work. Check out to learn about migrating state in URLs. + +## Avoid these common mistakes + +### Treating Kibana's filesystem as durable storage + +Plugins should rarely, if ever, access Kibana's filesystem directly. Kibana instances are commonly ephemeral and anything written to the filesystem will potentially +not be there on restart. + +### Storing state in server memory + +There are generally multiple instances of Kibana all hosted behind a round-robin load-balancer. As a result, storing state in server memory is risky as there is no +guarantee that a single end-user's HTTP requests will be served by the same Kibana instance. + +### Using WebSockets + +Kibana has a number of platform services that don't work with WebSockets, for example authentication and authorization. If your use-case would benefit substantially +from websockets, talk to the Kibana Core team about adding support. Do not hack around this limitation, everytime that someone has, it's created so many problems +it's been eventually removed. + + + ## Security best practices When writing code for Kibana, be sure to follow these best practices to avoid common vulnerabilities. Refer to the included Open Web diff --git a/dev_docs/contributing/documentation.mdx b/dev_docs/contributing/documentation.mdx new file mode 100644 index 000000000000..ad9286dd07ab --- /dev/null +++ b/dev_docs/contributing/documentation.mdx @@ -0,0 +1,195 @@ +--- +id: kibDocumentation +slug: /kibana-dev-docs/contributing/documentation +title: Documentation +summary: Writing documentation during development +date: 2022-03-01 +tags: ['kibana', 'onboarding', 'dev'] +--- + +Docs should be written during development and accompany PRs when relevant. There are multiple types of documentation, and different places to add each. + +## End-user documentation + +User-facing features should be documented in [asciidoc](http://asciidoc.org/) at [https://github.com/elastic/kibana/tree/main/docs](https://github.com/elastic/kibana/tree/main/docs) + +To build the docs, you must clone the [elastic/docs](https://github.com/elastic/docs) repo as a sibling of your Kibana repo. Follow the instructions in that project’s [README](https://github.com/elastic/docs#readme) for getting the docs tooling set up. + +To build the docs: + +```bash +node scripts/docs.js --open +``` + +## REST APIs +REST APIs should be documented using the following formats: + +- [API doc template](https://raw.githubusercontent.com/elastic/docs/main/shared/api-ref-ex.asciidoc) +- [API object definition template](https://raw.githubusercontent.com/elastic/docs/main/shared/api-definitions-ex.asciidoc) + +## Developer documentation + +Developer documentation can be segmented into two types: internal plugin details, and information on extending Kibana. Our [Kibana Developer Guide](https://docs.elastic.dev/kibana-dev-docs/getting-started/welcome) is meant to serve the latter. The guide can only be accessed internally at the moment, though the raw content is public in our [public repository]((https://github.com/elastic/kibana/tree/main/dev_docs)). + +Internal plugin details can be kept alongside the code it describes. Information about extending Kibana may go in the root of your plugin folder, or inside the top-level [dev_docs](https://github.com/elastic/kibana/tree/main/dev_docs) folder. + + + +Only `mdx` files with the appropriate are rendered inside the Developer Guide. Read about the syntax . Edit [kibana/nav-kibana-dev.docnav.json](https://github.com/elastic/kibana/blob/main/nav-kibana-dev.docnav.json) to have a link to your document appear in the navigation menu. Read for more details on how to add new content and test locally. + + + +### Structure + +The high-level developer documentation located in the [dev_docs](https://github.com/elastic/kibana/tree/main/dev_docs) folder attempts to follow [divio documentation](https://documentation.divio.com/) guidance. and sections are _explanation_ oriented, while + falls under both _tutorials_ and _how to_. The section is _reference_ material. + +Developers may choose to keep information that is specific to a particular plugin along side the code. + +### Best practices + +#### Keep content fresh + +A fresh pair of eyes are invaluable. Recruit new hires to read, review and update documentation. Leads should also periodically review documentation to ensure it stays up to date. File issues any time you notice documentation is outdated. + +#### Consider your target audience + +Documentation in the Kibana Developer Guide is targeted towards developers building Kibana plugins. Keep implementation details about internal plugin code out of these docs. + +#### High to low level + +When a developer first lands in our docs, think about their journey. Introduce basic concepts before diving into details. The left navigation should be set up so documents on top are higher level than documents near the bottom. + +#### Think outside-in + +It's easy to forget what it felt like to first write code in Kibana, but do your best to frame these docs "outside-in". Don't use esoteric, internal language unless a definition is documented and linked. The fresh eyes of a new hire can be a great asset. + + +## API documentation + +We automatically generate . The following guidelines will help ensure your are useful. + +If you encounter an error of the form: + + + +You can increase [max memory](https://nodejs.org/api/cli.html#--max-old-space-sizesize-in-megabytes) for node as follows: + +```bash +# As a runtime argument +node --max-old-space-size=8192 foo/bar + +# As an env variable, in order to apply it systematically +export NODE_OPTIONS=--max-old-space-size=8192 +``` + +### Code comments + +Every function, class, interface, type, parameter and property that is exposed to other plugins should have a [TSDoc](https://tsdoc.org/)-style comment. + +- Use `@param` tags for every function parameter. +- Use `@returns` tags for return types. +- Use `@throws` when appropriate. +- Use `@beta` or `@deprecated` when appropriate. +- Use `@removeBy {version}` on `@deprecated` APIs. The version should be the last version the API will work in. For example, `@removeBy 7.15` means the API will be removed in 7.16. This lets us avoid mid-release cycle coordination. The API can be removed as soon as the 7.15 branch is cut. +- Use `@internal` to indicate this API item is intended for internal use only, which will also remove it from the docs. + +### Interfaces vs inlined types + +Prefer types and interfaces over complex inline objects. For example, prefer: + +```ts +/** +* The SearchSpec interface contains settings for creating a new SearchService, like +* username and password. +*/ +export interface SearchSpec { + /** + * Stores the username. Duh, + */ + username: string; + /** + * Stores the password. I hope it's encrypted! + */ + password: string; +} + + /** + * Retrieve search services + * @param searchSpec Configuration information for initializing the search service. + * @returns the id of the search service + */ +export getSearchService: (searchSpec: SearchSpec) => string; +``` + +over: + +```ts +/** + * Retrieve search services + * @param searchSpec Configuration information for initializing the search service. + * @returns the id of the search service + */ +export getSearchService: (searchSpec: { username: string; password: string }) => string; +``` + +In the former, there will be a link to the `SearchSpec` interface with documentation for the `username` and `password` properties. In the latter the object will render inline, without comments: + +![prefer interfaces documentation](../assets/dev_docs_nested_object.png) + +### Export every type used in a public API + +When a publicly exported API item references a private type, this results in a broken link in our docs system. The private type is, by proxy, part of your public API, and as such, should be exported. + +Do: + +```ts +export interface AnInterface { bar: string }; +export type foo: string | AnInterface; +``` + +Don't: + +```ts +interface AnInterface { bar: string }; +export type foo: string | AnInterface; +``` + +### Avoid “Pick” + +`Pick` not only ends up being unhelpful in our documentation system, but it's also of limited help in your IDE. For that reason, avoid `Pick` and other similarly complex types on your public API items. Using these semantics internally is fine. + +![pick api documentation](../assets/api_doc_pick.png) + + +### Debugging tips + +There are three great ways to debug issues with the API infrastructure. + +1. Write a test + +[api_doc_suite.test.ts](https://github.com/elastic/kibana/blob/main/packages/kbn-docs-utils/src/api_docs/tests/api_doc_suite.test.ts) is a pretty comprehensive test suite that builds the test docs inside the [**fixtures** folder](https://github.com/elastic/kibana/tree/main/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src). + +Edit the code inside `__fixtures__` to replicate the bug, write a test to track what should happen, then run `yarn jest api_doc_suite`. + +Once you've verified the bug is reproducible, use debug messages to narrow down the problem. This is much faster than running the entire suite to debug. + +2. Use [ts-ast-viewer.com](https://ts-ast-viewer.com/#code/KYDwDg9gTgLgBASwHY2FAZgQwMbDgMQgjgG8AoOSudJAfgC44AKdIxgZximQHMBKOAF4AfHE7ckPANxkAvkA) + +This nifty website will let you add some types and see how the system parses it. For example, the link above shows there is a `QuestionToken` as a sibling to the `FunctionType` which is why [this bug](https://github.com/elastic/kibana/issues/107145) reported children being lost. The API infra system didn't categorize the node as a function type node. + +3. Play around with `ts-morph` in a Code Sandbox. + +You can fork [this Code Sandbox example](https://codesandbox.io/s/typescript-compiler-issue-0lkwx?file=/src/use_ts_compiler.ts) that was used to explore how to generate the node signature in different ways (e.g. `node.getType.getText()` shows different results than `node.getType.getText(node)`). Here is [another messy example](https://codesandbox.io/s/admiring-field-5btxs). + +The code sandbox approach can be a lot faster to iterate compared to running it in Kibana. + +## Example plugins + +Running Kibana with `yarn start --run-examples` will include all [example plugins](https://github.com/elastic/kibana/tree/main/examples). These are tested examples of platform services in use. We strongly encourage anyone providing a platform level service or to include a tutorial that links to a tested example plugin. This is better than relying on copied code snippets, which can quickly get out of date. + +You can also visit these [examples plugins hosted online](https://demo.kibana.dev/8.2/app/home). Note that because anonymous access is enabled, some +of the demos are currently not working. diff --git a/docs/api/saved-objects/resolve_import_errors.asciidoc b/docs/api/saved-objects/resolve_import_errors.asciidoc index 7a57e03875e3..162e9589e4f9 100644 --- a/docs/api/saved-objects/resolve_import_errors.asciidoc +++ b/docs/api/saved-objects/resolve_import_errors.asciidoc @@ -25,7 +25,7 @@ To resolve errors, you can: ==== Path parameters `space_id`:: - (Optional, string) An identifier for the <>. When `space_id` is unspecfied in the URL, the default space is used. + (Optional, string) An identifier for the <>. When `space_id` is unspecified in the URL, the default space is used. [[saved-objects-api-resolve-import-errors-query-params]] ==== Query parameters diff --git a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc index d79df2c085b1..9d26f9656d3f 100644 --- a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc +++ b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc @@ -68,7 +68,7 @@ Execute the <>, w `id`:::: (Required, string) The saved object ID. `overwrite`:::: - (Required, boolean) When set to `true`, the saved object from the source space (desigated by the <>) overwrites the conflicting object in the destination space. When set to `false`, this does nothing. + (Required, boolean) When set to `true`, the saved object from the source space (designated by the <>) overwrites the conflicting object in the destination space. When set to `false`, this does nothing. `destinationId`:::: (Optional, string) Specifies the destination ID that the copied object should have, if different from the current ID. `ignoreMissingReferences`::: diff --git a/docs/api/upgrade-assistant/default-field.asciidoc b/docs/api/upgrade-assistant/default-field.asciidoc index 8bdcd359d566..bbe44d894963 100644 --- a/docs/api/upgrade-assistant/default-field.asciidoc +++ b/docs/api/upgrade-assistant/default-field.asciidoc @@ -26,7 +26,7 @@ GET /api/upgrade_assistant/add_query_default_field/myIndex // KIBANA <1> A required array of {es} field types that generate the list of fields. -<2> An optional array of additional field names, dot-deliminated. +<2> An optional array of additional field names, dot-delimited. To add the `index.query.default_field` index setting to the specified index, {kib} generates an array of all fields from the index mapping. The fields contain the types specified in `fieldTypes`. {kib} appends any other fields specified in `otherFields` to the array of default fields. diff --git a/docs/apm/service-maps.asciidoc b/docs/apm/service-maps.asciidoc index f76b9976dd1d..8a2beef22b6b 100644 --- a/docs/apm/service-maps.asciidoc +++ b/docs/apm/service-maps.asciidoc @@ -84,7 +84,7 @@ image:apm/images/red-service.png[APM red service]:: Max anomaly score **≥75**. [role="screenshot"] image::apm/images/apm-service-map-anomaly.png[Example view of anomaly scores on service maps in the APM app] -If an anomaly has been detected, click *view anomalies* to view the anomaly detection metric viewier in the Machine learning app. +If an anomaly has been detected, click *view anomalies* to view the anomaly detection metric viewer in the Machine learning app. This time series analysis will display additional details on the severity and time of the detected anomalies. To learn how to create a machine learning job, see <>. diff --git a/docs/developer/advanced/development-es-snapshots.asciidoc b/docs/developer/advanced/development-es-snapshots.asciidoc index 38146e65b632..ad9eb17ec309 100644 --- a/docs/developer/advanced/development-es-snapshots.asciidoc +++ b/docs/developer/advanced/development-es-snapshots.asciidoc @@ -13,6 +13,7 @@ https://ci.kibana.dev/es-snapshots[A dashboard] is available that shows the curr 2. Each snapshot is uploaded to a public Google Cloud Storage bucket, `kibana-ci-es-snapshots-daily`. ** At this point, the snapshot is not automatically used in CI or local development. It needs to be tested/verified first. 3. Each snapshot is tested with the latest commit of the corresponding {kib} branch, using the full CI suite. +3a. If a test fails during snapshot verification the Kibana Operations team will skip it and create an issue for the team to fix the test, or work with the Elasticsearch team to get a fix implemented there. Once the fix is ready a Kibana PR can be opened to unskip the test. 4. After CI ** If the snapshot passes, it is promoted and automatically used in CI and local development. ** If the snapshot fails, the issue must be investigated and resolved. A new incompatibility may exist between {es} and {kib}. diff --git a/docs/developer/architecture/kibana-platform-plugin-api.asciidoc b/docs/developer/architecture/kibana-platform-plugin-api.asciidoc index 2005a90bb87b..9cf60cda76f7 100644 --- a/docs/developer/architecture/kibana-platform-plugin-api.asciidoc +++ b/docs/developer/architecture/kibana-platform-plugin-api.asciidoc @@ -221,7 +221,7 @@ These are the contracts exposed by the core services for each lifecycle: [cols=",,",options="header",] |=== |lifecycle |server contract|browser contract -|_contructor_ +|_constructor_ |{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md[PluginInitializerContext] |{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.plugininitializercontext.md[PluginInitializerContext] diff --git a/docs/developer/best-practices/typescript.asciidoc b/docs/developer/best-practices/typescript.asciidoc index 2631ee717c3d..92b6818a0986 100644 --- a/docs/developer/best-practices/typescript.asciidoc +++ b/docs/developer/best-practices/typescript.asciidoc @@ -51,7 +51,7 @@ Additionally, in order to migrate into project refs, you also need to make sure ], "references": [ { "path": "../../core/tsconfig.json" }, - // add references to other TypeScript projects your plugin dependes on + // add references to other TypeScript projects your plugin depends on ] } ---- diff --git a/docs/developer/contributing/development-ci-metrics.asciidoc b/docs/developer/contributing/development-ci-metrics.asciidoc index 3a133e64ea52..2905bd72a501 100644 --- a/docs/developer/contributing/development-ci-metrics.asciidoc +++ b/docs/developer/contributing/development-ci-metrics.asciidoc @@ -137,4 +137,4 @@ If you only want to run the build once you can run: node scripts/build_kibana_platform_plugins --validate-limits --focus {pluginId} ----------- -This command needs to apply production optimizations to get the right sizes, which means that the optimizer will take significantly longer to run and on most developmer machines will consume all of your machines resources for 20 minutes or more. If you'd like to multi-task while this is running you might need to limit the number of workers using the `--max-workers` flag. \ No newline at end of file +This command needs to apply production optimizations to get the right sizes, which means that the optimizer will take significantly longer to run and on most developer machines will consume all of your machines resources for 20 minutes or more. If you'd like to multi-task while this is running you might need to limit the number of workers using the `--max-workers` flag. \ No newline at end of file diff --git a/docs/developer/contributing/development-documentation.asciidoc b/docs/developer/contributing/development-documentation.asciidoc index 7137d5bad051..9f221c0f0130 100644 --- a/docs/developer/contributing/development-documentation.asciidoc +++ b/docs/developer/contributing/development-documentation.asciidoc @@ -3,14 +3,6 @@ Docs should be written during development and accompany PRs when relevant. There are multiple types of documentation, and different places to add each. -[discrete] -=== Developer services documentation - -Documentation about specific services a plugin offers should be encapsulated in: - -* README.asciidoc at the base of the plugin folder. -* Typescript comments for all public services. - [discrete] === End user documentation @@ -31,7 +23,7 @@ node scripts/docs.js --open REST APIs should be documented using the following recommended formats: -* https://raw.githubusercontent.com/elastic/docs/master/shared/api-ref-ex.asciidoc[API doc templaate] +* https://raw.githubusercontent.com/elastic/docs/master/shared/api-ref-ex.asciidoc[API doc template] * https://raw.githubusercontent.com/elastic/docs/master/shared/api-definitions-ex.asciidoc[API object definition template] [discrete] diff --git a/docs/developer/contributing/development-package-tests.asciidoc b/docs/developer/contributing/development-package-tests.asciidoc index 7883ce2d8320..2b4301399287 100644 --- a/docs/developer/contributing/development-package-tests.asciidoc +++ b/docs/developer/contributing/development-package-tests.asciidoc @@ -27,9 +27,9 @@ pip3 install --user ansible [cols=",,",options="header",] |=== |Hostname |IP |Description -|deb |192.168.50.5 |Installation of Kibana’s deb package -|rpm |192.168.50.6 |Installation of Kibana’s rpm package -|docker |192.168.50.7 |Installation of Kibana’s docker image +|deb |192.168.56.5 |Installation of Kibana’s deb package +|rpm |192.168.56.6 |Installation of Kibana’s rpm package +|docker |192.168.56.7 |Installation of Kibana’s docker image |=== === Running @@ -49,11 +49,11 @@ vagrant provision # Running functional tests node scripts/es snapshot \ - -E network.bind_host=127.0.0.1,192.168.50.1 \ + -E network.bind_host=127.0.0.1,192.168.56.1 \ -E discovery.type=single-node \ --license=trial TEST_KIBANA_URL=http://elastic:changeme@:5601 \ -TEST_ES_URL=http://elastic:changeme@192.168.50.1:9200 \ +TEST_ES_URL=http://elastic:changeme@192.168.56.1:9200 \ node scripts/functional_test_runner.js --include-tag=smoke ``` diff --git a/docs/developer/contributing/interpreting-ci-failures.asciidoc b/docs/developer/contributing/interpreting-ci-failures.asciidoc index ffbe448d79a4..eead720f03c6 100644 --- a/docs/developer/contributing/interpreting-ci-failures.asciidoc +++ b/docs/developer/contributing/interpreting-ci-failures.asciidoc @@ -22,7 +22,7 @@ image::images/job_view.png[Jenkins job view showing a test failure] 1. *Git Changes:* the list of commits that were in this build which weren't in the previous build. For Pull Requests this list is calculated by comparing against the most recent Pull Request which was tested, it is not limited to build for this specific Pull Request, so it's not very useful. 2. *Test Results:* A link to the test results screen, and shortcuts to the failed tests. Functional tests capture and store the log output from each specific test, and make it visible at these links. For other test runners only the error message is visible and log output must be tracked down in the *Pipeline Steps*. 3. *Google Cloud Storage (GCS) Upload Report:* Link to the screen which lists out the artifacts uploaded to GCS during this job execution. -4. *Pipeline Steps:*: A breakdown of the pipline that was executed, along with individual log output for each step in the pipeline. +4. *Pipeline Steps:*: A breakdown of the pipeline that was executed, along with individual log output for each step in the pipeline. [discrete] === Viewing ciGroup/test Logs diff --git a/docs/developer/index.asciidoc b/docs/developer/index.asciidoc index 86d1d32e75e3..7d9116a72d06 100644 --- a/docs/developer/index.asciidoc +++ b/docs/developer/index.asciidoc @@ -2,6 +2,9 @@ = Developer guide -- + +NOTE: This is our legacy developer guide, and while we strive to keep it accurate, new content is added inside the {kib-repo}blob/{branch}/dev_docs[Kibana repo]. The rendered https://docs.elastic.dev/kibana-dev-docs/getting-started/welcome[guide] can only be accessed internally at the moment, though the raw content is public in our {kib-repo}blob/{branch}/dev_docs[public repository]. + Contributing to {kib} can be daunting at first, but it doesn't have to be. The following sections should get you up and running in no time. If you have any problems, file an issue in the https://github.com/elastic/kibana/issues[Kibana repo]. diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 2dd78be3c101..bf81ab1e0bec 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -297,6 +297,10 @@ It acts as a container for a particular visualization and options tabs. Contains The plugin exposes the static DefaultEditorController class to consume. +|{kib-repo}blob/{branch}/src/plugins/vis_types/gauge[visTypeGauge] +|WARNING: Missing README. + + |{kib-repo}blob/{branch}/src/plugins/vis_types/heatmap[visTypeHeatmap] |WARNING: Missing README. @@ -519,8 +523,12 @@ newly created modules as well. Elastic. -|{kib-repo}blob/{branch}/x-pack/plugins/monitoring[monitoring] -|WARNING: Missing README. +|{kib-repo}blob/{branch}/x-pack/plugins/monitoring/readme.md[monitoring] +|This plugin provides the Stack Monitoring kibana application. + + +|{kib-repo}blob/{branch}/x-pack/plugins/monitoring_collection/README.md[monitoringCollection] +|This plugin allows for other plugins to add data to Kibana stack monitoring documents. |{kib-repo}blob/{branch}/x-pack/plugins/observability/README.md[observability] @@ -580,6 +588,10 @@ Kibana. |Welcome to the Kibana Security Solution plugin! This README will go over getting started with development and testing. +|{kib-repo}blob/{branch}/x-pack/plugins/session_view/README.md[sessionView] +|Session View is meant to provide a visualization into what is going on in a particular Linux environment where the agent is running. It looks likes a terminal emulator; however, it is a tool for introspecting process activity and understanding user and service behaviour in your Linux servers and infrastructure. It is a time-ordered series of process executions displayed in a tree over time. + + |{kib-repo}blob/{branch}/x-pack/plugins/snapshot_restore/README.md[snapshotRestore] |or diff --git a/docs/development/core/public/kibana-plugin-core-public.asyncplugin.md b/docs/development/core/public/kibana-plugin-core-public.asyncplugin.md deleted file mode 100644 index cb9559dddc68..000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.asyncplugin.md +++ /dev/null @@ -1,27 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AsyncPlugin](./kibana-plugin-core-public.asyncplugin.md) - -## AsyncPlugin interface - -> Warning: This API is now obsolete. -> -> Asynchronous lifecycles are deprecated, and should be migrated to sync -> - -A plugin with asynchronous lifecycle methods. - -Signature: - -```typescript -export interface AsyncPlugin -``` - -## Methods - -| Method | Description | -| --- | --- | -| [setup(core, plugins)](./kibana-plugin-core-public.asyncplugin.setup.md) | | -| [start(core, plugins)](./kibana-plugin-core-public.asyncplugin.start.md) | | -| [stop()?](./kibana-plugin-core-public.asyncplugin.stop.md) | (Optional) | - diff --git a/docs/development/core/public/kibana-plugin-core-public.asyncplugin.setup.md b/docs/development/core/public/kibana-plugin-core-public.asyncplugin.setup.md deleted file mode 100644 index 67a5dad22a0a..000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.asyncplugin.setup.md +++ /dev/null @@ -1,23 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AsyncPlugin](./kibana-plugin-core-public.asyncplugin.md) > [setup](./kibana-plugin-core-public.asyncplugin.setup.md) - -## AsyncPlugin.setup() method - -Signature: - -```typescript -setup(core: CoreSetup, plugins: TPluginsSetup): TSetup | Promise; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| core | CoreSetup<TPluginsStart, TStart> | | -| plugins | TPluginsSetup | | - -Returns: - -TSetup \| Promise<TSetup> - diff --git a/docs/development/core/public/kibana-plugin-core-public.asyncplugin.start.md b/docs/development/core/public/kibana-plugin-core-public.asyncplugin.start.md deleted file mode 100644 index 89554a1afaf1..000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.asyncplugin.start.md +++ /dev/null @@ -1,23 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AsyncPlugin](./kibana-plugin-core-public.asyncplugin.md) > [start](./kibana-plugin-core-public.asyncplugin.start.md) - -## AsyncPlugin.start() method - -Signature: - -```typescript -start(core: CoreStart, plugins: TPluginsStart): TStart | Promise; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| core | CoreStart | | -| plugins | TPluginsStart | | - -Returns: - -TStart \| Promise<TStart> - diff --git a/docs/development/core/public/kibana-plugin-core-public.asyncplugin.stop.md b/docs/development/core/public/kibana-plugin-core-public.asyncplugin.stop.md deleted file mode 100644 index 3fb7504879cf..000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.asyncplugin.stop.md +++ /dev/null @@ -1,15 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AsyncPlugin](./kibana-plugin-core-public.asyncplugin.md) > [stop](./kibana-plugin-core-public.asyncplugin.stop.md) - -## AsyncPlugin.stop() method - -Signature: - -```typescript -stop?(): void; -``` -Returns: - -void - diff --git a/docs/development/core/public/kibana-plugin-core-public.coresetup.executioncontext.md b/docs/development/core/public/kibana-plugin-core-public.coresetup.executioncontext.md new file mode 100644 index 000000000000..be5689ad7b08 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.coresetup.executioncontext.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [CoreSetup](./kibana-plugin-core-public.coresetup.md) > [executionContext](./kibana-plugin-core-public.coresetup.executioncontext.md) + +## CoreSetup.executionContext property + +[ExecutionContextSetup](./kibana-plugin-core-public.executioncontextsetup.md) + +Signature: + +```typescript +executionContext: ExecutionContextSetup; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.coresetup.md b/docs/development/core/public/kibana-plugin-core-public.coresetup.md index 9488b8a26b86..31793ec6f7a5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.coresetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.coresetup.md @@ -17,6 +17,7 @@ export interface CoreSetup + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [CoreStart](./kibana-plugin-core-public.corestart.md) > [executionContext](./kibana-plugin-core-public.corestart.executioncontext.md) + +## CoreStart.executionContext property + +[ExecutionContextStart](./kibana-plugin-core-public.executioncontextstart.md) + +Signature: + +```typescript +executionContext: ExecutionContextStart; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.corestart.md b/docs/development/core/public/kibana-plugin-core-public.corestart.md index ae67696e1250..edd80e1adb9f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.corestart.md +++ b/docs/development/core/public/kibana-plugin-core-public.corestart.md @@ -20,6 +20,7 @@ export interface CoreStart | [chrome](./kibana-plugin-core-public.corestart.chrome.md) | ChromeStart | [ChromeStart](./kibana-plugin-core-public.chromestart.md) | | [deprecations](./kibana-plugin-core-public.corestart.deprecations.md) | DeprecationsServiceStart | [DeprecationsServiceStart](./kibana-plugin-core-public.deprecationsservicestart.md) | | [docLinks](./kibana-plugin-core-public.corestart.doclinks.md) | DocLinksStart | [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | +| [executionContext](./kibana-plugin-core-public.corestart.executioncontext.md) | ExecutionContextStart | [ExecutionContextStart](./kibana-plugin-core-public.executioncontextstart.md) | | [fatalErrors](./kibana-plugin-core-public.corestart.fatalerrors.md) | FatalErrorsStart | [FatalErrorsStart](./kibana-plugin-core-public.fatalerrorsstart.md) | | [http](./kibana-plugin-core-public.corestart.http.md) | HttpStart | [HttpStart](./kibana-plugin-core-public.httpstart.md) | | [i18n](./kibana-plugin-core-public.corestart.i18n.md) | I18nStart | [I18nStart](./kibana-plugin-core-public.i18nstart.md) | diff --git a/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.clear.md b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.clear.md new file mode 100644 index 000000000000..94936b94d071 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.clear.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ExecutionContextSetup](./kibana-plugin-core-public.executioncontextsetup.md) > [clear](./kibana-plugin-core-public.executioncontextsetup.clear.md) + +## ExecutionContextSetup.clear() method + +clears the context + +Signature: + +```typescript +clear(): void; +``` +Returns: + +void + diff --git a/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.context_.md b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.context_.md new file mode 100644 index 000000000000..d6c74db6d603 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.context_.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ExecutionContextSetup](./kibana-plugin-core-public.executioncontextsetup.md) > [context$](./kibana-plugin-core-public.executioncontextsetup.context_.md) + +## ExecutionContextSetup.context$ property + +The current context observable + +Signature: + +```typescript +context$: Observable; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.get.md b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.get.md new file mode 100644 index 000000000000..65e9b1218649 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.get.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ExecutionContextSetup](./kibana-plugin-core-public.executioncontextsetup.md) > [get](./kibana-plugin-core-public.executioncontextsetup.get.md) + +## ExecutionContextSetup.get() method + +Get the current top level context + +Signature: + +```typescript +get(): KibanaExecutionContext; +``` +Returns: + +KibanaExecutionContext + diff --git a/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.getaslabels.md b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.getaslabels.md new file mode 100644 index 000000000000..0f0bda4e2913 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.getaslabels.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ExecutionContextSetup](./kibana-plugin-core-public.executioncontextsetup.md) > [getAsLabels](./kibana-plugin-core-public.executioncontextsetup.getaslabels.md) + +## ExecutionContextSetup.getAsLabels() method + +returns apm labels + +Signature: + +```typescript +getAsLabels(): Labels; +``` +Returns: + +Labels + diff --git a/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.md b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.md new file mode 100644 index 000000000000..01581d2e80a5 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.md @@ -0,0 +1,30 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ExecutionContextSetup](./kibana-plugin-core-public.executioncontextsetup.md) + +## ExecutionContextSetup interface + +Kibana execution context. Used to provide execution context to Elasticsearch, reporting, performance monitoring, etc. + +Signature: + +```typescript +export interface ExecutionContextSetup +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [context$](./kibana-plugin-core-public.executioncontextsetup.context_.md) | Observable<KibanaExecutionContext> | The current context observable | + +## Methods + +| Method | Description | +| --- | --- | +| [clear()](./kibana-plugin-core-public.executioncontextsetup.clear.md) | clears the context | +| [get()](./kibana-plugin-core-public.executioncontextsetup.get.md) | Get the current top level context | +| [getAsLabels()](./kibana-plugin-core-public.executioncontextsetup.getaslabels.md) | returns apm labels | +| [set(c$)](./kibana-plugin-core-public.executioncontextsetup.set.md) | Set the current top level context | +| [withGlobalContext(context)](./kibana-plugin-core-public.executioncontextsetup.withglobalcontext.md) | merges the current top level context with the specific event context | + diff --git a/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.set.md b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.set.md new file mode 100644 index 000000000000..e3dcea78c827 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.set.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ExecutionContextSetup](./kibana-plugin-core-public.executioncontextsetup.md) > [set](./kibana-plugin-core-public.executioncontextsetup.set.md) + +## ExecutionContextSetup.set() method + +Set the current top level context + +Signature: + +```typescript +set(c$: KibanaExecutionContext): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| c$ | KibanaExecutionContext | | + +Returns: + +void + diff --git a/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.withglobalcontext.md b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.withglobalcontext.md new file mode 100644 index 000000000000..574d0fd98975 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.executioncontextsetup.withglobalcontext.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ExecutionContextSetup](./kibana-plugin-core-public.executioncontextsetup.md) > [withGlobalContext](./kibana-plugin-core-public.executioncontextsetup.withglobalcontext.md) + +## ExecutionContextSetup.withGlobalContext() method + +merges the current top level context with the specific event context + +Signature: + +```typescript +withGlobalContext(context?: KibanaExecutionContext): KibanaExecutionContext; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| context | KibanaExecutionContext | | + +Returns: + +KibanaExecutionContext + diff --git a/docs/development/core/public/kibana-plugin-core-public.executioncontextstart.md b/docs/development/core/public/kibana-plugin-core-public.executioncontextstart.md new file mode 100644 index 000000000000..0d210ba5bb1c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.executioncontextstart.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ExecutionContextStart](./kibana-plugin-core-public.executioncontextstart.md) + +## ExecutionContextStart type + +See [ExecutionContextSetup](./kibana-plugin-core-public.executioncontextsetup.md). + +Signature: + +```typescript +export declare type ExecutionContextStart = ExecutionContextSetup; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md index 6266639b6397..d8f8a77d84b2 100644 --- a/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md @@ -10,9 +10,10 @@ Represents a meta-information about a Kibana entity initiating a search request. ```typescript export declare type KibanaExecutionContext = { - readonly type: string; - readonly name: string; - readonly id: string; + readonly type?: string; + readonly name?: string; + readonly page?: string; + readonly id?: string; readonly description?: string; readonly url?: string; child?: KibanaExecutionContext; diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index b51f5ed833fd..2e51a036dfe9 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -39,7 +39,6 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ApplicationStart](./kibana-plugin-core-public.applicationstart.md) | | | [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) | | | [AppNavOptions](./kibana-plugin-core-public.appnavoptions.md) | App navigation menu options | -| [AsyncPlugin](./kibana-plugin-core-public.asyncplugin.md) | A plugin with asynchronous lifecycle methods. | | [Capabilities](./kibana-plugin-core-public.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. | | [ChromeBadge](./kibana-plugin-core-public.chromebadge.md) | | | [ChromeDocTitle](./kibana-plugin-core-public.chromedoctitle.md) | APIs for accessing and updating the document title. | @@ -62,6 +61,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [DeprecationsServiceStart](./kibana-plugin-core-public.deprecationsservicestart.md) | DeprecationsService provides methods to fetch domain deprecation details from the Kibana server. | | [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | | | [ErrorToastOptions](./kibana-plugin-core-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) error APIs. | +| [ExecutionContextSetup](./kibana-plugin-core-public.executioncontextsetup.md) | Kibana execution context. Used to provide execution context to Elasticsearch, reporting, performance monitoring, etc. | | [FatalErrorInfo](./kibana-plugin-core-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | | [FatalErrorsSetup](./kibana-plugin-core-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | | [HttpFetchOptions](./kibana-plugin-core-public.httpfetchoptions.md) | All options that may be used with a [HttpHandler](./kibana-plugin-core-public.httphandler.md). | @@ -160,6 +160,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ChromeBreadcrumb](./kibana-plugin-core-public.chromebreadcrumb.md) | | | [ChromeHelpExtensionLinkBase](./kibana-plugin-core-public.chromehelpextensionlinkbase.md) | | | [ChromeHelpExtensionMenuLink](./kibana-plugin-core-public.chromehelpextensionmenulink.md) | | +| [ExecutionContextStart](./kibana-plugin-core-public.executioncontextstart.md) | See [ExecutionContextSetup](./kibana-plugin-core-public.executioncontextsetup.md). | | [FatalErrorsStart](./kibana-plugin-core-public.fatalerrorsstart.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | | [HttpStart](./kibana-plugin-core-public.httpstart.md) | See [HttpSetup](./kibana-plugin-core-public.httpsetup.md) | | [IToasts](./kibana-plugin-core-public.itoasts.md) | Methods for adding and removing global toast messages. See [ToastsApi](./kibana-plugin-core-public.toastsapi.md). | diff --git a/docs/development/core/public/kibana-plugin-core-public.plugininitializer.md b/docs/development/core/public/kibana-plugin-core-public.plugininitializer.md index b7c3e11e492b..1fcc2999dfd2 100644 --- a/docs/development/core/public/kibana-plugin-core-public.plugininitializer.md +++ b/docs/development/core/public/kibana-plugin-core-public.plugininitializer.md @@ -9,5 +9,5 @@ The `plugin` export at the root of a plugin's `public` directory should conform Signature: ```typescript -export declare type PluginInitializer = (core: PluginInitializerContext) => Plugin | AsyncPlugin; +export declare type PluginInitializer = (core: PluginInitializerContext) => Plugin; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkupdate.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkupdate.md index 0e3bfb2bd896..0cbfe4fcdead 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkupdate.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkupdate.md @@ -9,7 +9,7 @@ Update multiple documents at once Signature: ```typescript -bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; +bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; ``` ## Parameters @@ -20,7 +20,7 @@ bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): PromiseReturns: -Promise<SavedObjectsBatchResponse<unknown>> +Promise<SavedObjectsBatchResponse<T>> The result of the update operation containing both failed and updated saved objects. diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportfailure.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportfailure.md index be1a20b3c71a..e7de1014bdaf 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportfailure.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportfailure.md @@ -20,6 +20,5 @@ export interface SavedObjectsImportFailure | [id](./kibana-plugin-core-public.savedobjectsimportfailure.id.md) | string | | | [meta](./kibana-plugin-core-public.savedobjectsimportfailure.meta.md) | { title?: string; icon?: string; } | | | [overwrite?](./kibana-plugin-core-public.savedobjectsimportfailure.overwrite.md) | boolean | (Optional) If overwrite is specified, an attempt was made to overwrite an existing object. | -| [title?](./kibana-plugin-core-public.savedobjectsimportfailure.title.md) | string | (Optional) | | [type](./kibana-plugin-core-public.savedobjectsimportfailure.type.md) | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportfailure.title.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportfailure.title.md deleted file mode 100644 index 0024358bda03..000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportfailure.title.md +++ /dev/null @@ -1,16 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportFailure](./kibana-plugin-core-public.savedobjectsimportfailure.md) > [title](./kibana-plugin-core-public.savedobjectsimportfailure.title.md) - -## SavedObjectsImportFailure.title property - -> Warning: This API is now obsolete. -> -> Use `meta.title` instead -> - -Signature: - -```typescript -title?: string; -``` diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md index f53b6e529286..412154f7ac2e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `SimpleSavedObject` class Signature: ```typescript -constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, }: SavedObjectType); +constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, updated_at: updatedAt, }: SavedObjectType); ``` ## Parameters @@ -17,5 +17,5 @@ constructor(client: SavedObjectsClientContract, { id, type, version, attributes, | Parameter | Type | Description | | --- | --- | --- | | client | SavedObjectsClientContract | | -| { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, } | SavedObjectType<T> | | +| { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, updated\_at: updatedAt, } | SavedObjectType<T> | | diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md index 2aac93f9b5bc..512fc74d538e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md @@ -18,7 +18,7 @@ export declare class SimpleSavedObject | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(client, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, })](./kibana-plugin-core-public.simplesavedobject._constructor_.md) | | Constructs a new instance of the SimpleSavedObject class | +| [(constructor)(client, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, updated\_at: updatedAt, })](./kibana-plugin-core-public.simplesavedobject._constructor_.md) | | Constructs a new instance of the SimpleSavedObject class | ## Properties @@ -33,6 +33,7 @@ export declare class SimpleSavedObject | [namespaces](./kibana-plugin-core-public.simplesavedobject.namespaces.md) | | SavedObjectType<T>\['namespaces'\] | Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with namespaceType: 'agnostic'. | | [references](./kibana-plugin-core-public.simplesavedobject.references.md) | | SavedObjectType<T>\['references'\] | | | [type](./kibana-plugin-core-public.simplesavedobject.type.md) | | SavedObjectType<T>\['type'\] | | +| [updatedAt](./kibana-plugin-core-public.simplesavedobject.updatedat.md) | | SavedObjectType<T>\['updated\_at'\] | | ## Methods diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.updatedat.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.updatedat.md new file mode 100644 index 000000000000..80b1f9596993 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.updatedat.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SimpleSavedObject](./kibana-plugin-core-public.simplesavedobject.md) > [updatedAt](./kibana-plugin-core-public.simplesavedobject.updatedat.md) + +## SimpleSavedObject.updatedAt property + +Signature: + +```typescript +updatedAt: SavedObjectType['updated_at']; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchclient.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchclient.md index 9a04a1d58176..1dfb1ab7a0b4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchclient.md @@ -9,5 +9,5 @@ Client used to query the elasticsearch cluster. Signature: ```typescript -export declare type ElasticsearchClient = Omit; +export declare type ElasticsearchClient = Omit; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.legacy.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.legacy.md index bcc2f474fa48..03e2be0da7a1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.legacy.md +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.legacy.md @@ -6,7 +6,6 @@ > Warning: This API is now obsolete. > -> Use [ElasticsearchServiceStart.legacy](./kibana-plugin-core-server.elasticsearchservicestart.legacy.md) instead. > Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.legacy.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.legacy.md deleted file mode 100644 index 844ebf3815a9..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.legacy.md +++ /dev/null @@ -1,18 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ElasticsearchServiceStart](./kibana-plugin-core-server.elasticsearchservicestart.md) > [legacy](./kibana-plugin-core-server.elasticsearchservicestart.legacy.md) - -## ElasticsearchServiceStart.legacy property - -> Warning: This API is now obsolete. -> -> Provided for the backward compatibility. Switch to the new elasticsearch client as soon as https://github.com/elastic/kibana/issues/35508 done. -> - -Signature: - -```typescript -legacy: { - readonly config$: Observable; - }; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md index 5bd8f9d0a433..66ff94ee9c80 100644 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md @@ -17,5 +17,4 @@ export interface ElasticsearchServiceStart | --- | --- | --- | | [client](./kibana-plugin-core-server.elasticsearchservicestart.client.md) | IClusterClient | A pre-configured [Elasticsearch client](./kibana-plugin-core-server.iclusterclient.md) | | [createClient](./kibana-plugin-core-server.elasticsearchservicestart.createclient.md) | (type: string, clientConfig?: Partial<ElasticsearchClientConfig>) => ICustomClusterClient | Create application specific Elasticsearch cluster API client with customized config. See [IClusterClient](./kibana-plugin-core-server.iclusterclient.md). | -| [legacy](./kibana-plugin-core-server.elasticsearchservicestart.legacy.md) | { readonly config$: Observable<ElasticsearchConfig>; } | | diff --git a/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.getaslabels.md b/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.getaslabels.md new file mode 100644 index 000000000000..c8816a3deee4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.getaslabels.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ExecutionContextSetup](./kibana-plugin-core-server.executioncontextsetup.md) > [getAsLabels](./kibana-plugin-core-server.executioncontextsetup.getaslabels.md) + +## ExecutionContextSetup.getAsLabels() method + +Signature: + +```typescript +getAsLabels(): apm.Labels; +``` +Returns: + +apm.Labels + diff --git a/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.md b/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.md index 24591648ad95..7fdc4d1ec1d5 100644 --- a/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.md @@ -15,5 +15,6 @@ export interface ExecutionContextSetup | Method | Description | | --- | --- | +| [getAsLabels()](./kibana-plugin-core-server.executioncontextsetup.getaslabels.md) | | | [withContext(context, fn)](./kibana-plugin-core-server.executioncontextsetup.withcontext.md) | Keeps track of execution context while the passed function is executed. Data are carried over all async operations spawned by the passed function. The nested calls stack the registered context on top of each other. | diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.auth.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.auth.md deleted file mode 100644 index da348a2282b1..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.auth.md +++ /dev/null @@ -1,18 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) > [auth](./kibana-plugin-core-server.httpservicesetup.auth.md) - -## HttpServiceSetup.auth property - -> Warning: This API is now obsolete. -> -> use [the start contract](./kibana-plugin-core-server.httpservicestart.auth.md) instead. -> - -Auth status. See [HttpAuth](./kibana-plugin-core-server.httpauth.md) - -Signature: - -```typescript -auth: HttpAuth; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md index 81ddeaaaa5a1..f3be1a9130b9 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md @@ -77,7 +77,6 @@ async (context, request, response) => { | Property | Type | Description | | --- | --- | --- | -| [auth](./kibana-plugin-core-server.httpservicesetup.auth.md) | HttpAuth | Auth status. See [HttpAuth](./kibana-plugin-core-server.httpauth.md) | | [basePath](./kibana-plugin-core-server.httpservicesetup.basepath.md) | IBasePath | Access or manipulate the Kibana base path See [IBasePath](./kibana-plugin-core-server.ibasepath.md). | | [createCookieSessionStorageFactory](./kibana-plugin-core-server.httpservicesetup.createcookiesessionstoragefactory.md) | <T>(cookieOptions: SessionStorageCookieOptions<T>) => Promise<SessionStorageFactory<T>> | Creates cookie based session storage factory [SessionStorageFactory](./kibana-plugin-core-server.sessionstoragefactory.md) | | [createRouter](./kibana-plugin-core-server.httpservicesetup.createrouter.md) | <Context extends RequestHandlerContext = RequestHandlerContext>() => IRouter<Context> | Provides ability to declare a handler function for a particular path and HTTP request method. | diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md index 0d65a3662da6..792af8f69386 100644 --- a/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md @@ -10,9 +10,10 @@ Represents a meta-information about a Kibana entity initiating a search request. ```typescript export declare type KibanaExecutionContext = { - readonly type: string; - readonly name: string; - readonly id: string; + readonly type?: string; + readonly name?: string; + readonly page?: string; + readonly id?: string; readonly description?: string; readonly url?: string; child?: KibanaExecutionContext; diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportfailure.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportfailure.md index 52db837479cf..4bdc3d99028a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportfailure.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportfailure.md @@ -20,6 +20,5 @@ export interface SavedObjectsImportFailure | [id](./kibana-plugin-core-server.savedobjectsimportfailure.id.md) | string | | | [meta](./kibana-plugin-core-server.savedobjectsimportfailure.meta.md) | { title?: string; icon?: string; } | | | [overwrite?](./kibana-plugin-core-server.savedobjectsimportfailure.overwrite.md) | boolean | (Optional) If overwrite is specified, an attempt was made to overwrite an existing object. | -| [title?](./kibana-plugin-core-server.savedobjectsimportfailure.title.md) | string | (Optional) | | [type](./kibana-plugin-core-server.savedobjectsimportfailure.type.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportfailure.title.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportfailure.title.md deleted file mode 100644 index 12326e6b0e4b..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportfailure.title.md +++ /dev/null @@ -1,16 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportFailure](./kibana-plugin-core-server.savedobjectsimportfailure.md) > [title](./kibana-plugin-core-server.savedobjectsimportfailure.title.md) - -## SavedObjectsImportFailure.title property - -> Warning: This API is now obsolete. -> -> Use `meta.title` instead -> - -Signature: - -```typescript -title?: string; -``` diff --git a/docs/management/cases/add-connectors.asciidoc b/docs/management/cases/add-connectors.asciidoc new file mode 100644 index 000000000000..cd0ed1e1b640 --- /dev/null +++ b/docs/management/cases/add-connectors.asciidoc @@ -0,0 +1,56 @@ +[[add-case-connectors]] +== Add connectors + +preview::[] + +You can add connectors to cases to push information to these external incident +management systems: + +* IBM Resilient +* Jira +* ServiceNow ITSM +* ServiceNow SecOps +* {swimlane} + +NOTE: To create connectors and send cases to external systems, you must have the +appropriate {kib} feature privileges. Refer to <>. + +[discrete] +[[create-case-connectors]] +== Create connectors + +You can create connectors in *Management > {stack-manage-app} > {rules-ui}*, as +described in <>. Alternatively, you can create them in +*Management > {stack-manage-app} > Cases*: + +. Click *Edit external connection*. ++ +[role="screenshot"] +image::images/cases-connectors.png[] + +. From the *Incident management system* list, select *Add new connector*. + +. Select an external incident management system. + +. Enter your required settings. Refer to <>, +<>, <>, <>, +or <> for connector configuration details. + +. Click *Save*. + +[discrete] +[[edit-case-connector-settings]] +== Edit connector settings + +You can create additional connectors, update existing connectors, change +the default connector, and change case closure options. + +. Go to *Management > {stack-manage-app} > Cases*, click *Edit external connection*. + +. To change whether cases are automatically closed after they are sent to an +external system, update the case closure options. + +. To change the default connector for new cases, select the connector from the +*Incident management system* list. + +. To update a connector, click *Update * and edit the connector fields as required. diff --git a/docs/management/cases/cases.asciidoc b/docs/management/cases/cases.asciidoc new file mode 100644 index 000000000000..c08b99894eea --- /dev/null +++ b/docs/management/cases/cases.asciidoc @@ -0,0 +1,22 @@ +[[cases]] +== Cases + +preview::[] + +Cases are used to open and track issues directly in {kib}. All cases list +the original reporter and all the users who contribute to a case (_participants_). +You can also send cases to external incident management systems by configuring +connectors. + +[role="screenshot"] +image::images/cases.png[Cases page] + +NOTE: If you create cases in the {observability} or {security-app}, they are not +visible in *{stack-manage-app}*. Likewise, the cases you create in +*{stack-manage-app}* are not visible in the {observability} or {security-app}. +You also cannot attach alerts from the {observability} or {security-app} to +cases in *{stack-manage-app}*. + +* <> +* <> +* <> \ No newline at end of file diff --git a/docs/management/cases/images/cases-connectors.png b/docs/management/cases/images/cases-connectors.png new file mode 100644 index 000000000000..95af429aef2d Binary files /dev/null and b/docs/management/cases/images/cases-connectors.png differ diff --git a/docs/management/cases/images/cases-visualization.png b/docs/management/cases/images/cases-visualization.png new file mode 100644 index 000000000000..77f249f26d09 Binary files /dev/null and b/docs/management/cases/images/cases-visualization.png differ diff --git a/docs/management/cases/images/cases.png b/docs/management/cases/images/cases.png new file mode 100644 index 000000000000..7b0c551cb690 Binary files /dev/null and b/docs/management/cases/images/cases.png differ diff --git a/docs/management/cases/index.asciidoc b/docs/management/cases/index.asciidoc new file mode 100644 index 000000000000..981c8a9821a9 --- /dev/null +++ b/docs/management/cases/index.asciidoc @@ -0,0 +1,4 @@ +include::cases.asciidoc[] +include::setup-cases.asciidoc[leveloffset=+1] +include::manage-cases.asciidoc[leveloffset=+1] +include::add-connectors.asciidoc[leveloffset=+1] \ No newline at end of file diff --git a/docs/management/cases/manage-cases.asciidoc b/docs/management/cases/manage-cases.asciidoc new file mode 100644 index 000000000000..f4693ef25950 --- /dev/null +++ b/docs/management/cases/manage-cases.asciidoc @@ -0,0 +1,70 @@ +[[manage-cases]] +== Open and manage cases + +preview::[] + +[[open-case]] +=== Open a new case + +Open a new case to keep track of issues and share their details with colleagues. + +. Go to *Management > {stack-manage-app} > Cases*, then click *Create case*. + +. Give the case a name, add any relevant tags and a description. ++ +TIP: In the `Description` area, you can use +https://www.markdownguide.org/cheat-sheet[Markdown] syntax to create formatted +text. + +. For *External incident management system*, select a connector. For more +information, refer to <>. + +. After you've completed all of the required fields, click *Create case*. + +[[add-case-visualization]] +=== Add a visualization + +After you create a case, you can optionally add a visualization. For +example, you can portray event and alert data through charts and graphs. + +[role="screenshot"] +image::images/cases-visualization.png[Cases page] + +To add a visualization to a comment within your case: + +. Click the *Visualization* button. The *Add visualization* dialog appears. + +. Select an existing visualization from your Visualize Library or create a new +visualization. ++ +IMPORTANT: Set an absolute time range for your visualization. This ensures your +visualization doesn't change over time after you save it to your case and +provides important context for viewers. + +. After you've finished creating your visualization, click *Save and return* to +go back to your case. + +. Click *Preview* to see how the visualization will appear in the case comment. + +. Click *Add Comment* to add the visualization to your case. + +After a visualization has been added to a case, you can modify or interact with +it by clicking the *Open Visualization* option in the comment menu. + +[[manage-case]] +=== Manage cases + +In *Management > {stack-manage-app} > Cases*, you can search cases and filter +them by tags, reporter. + +To view a case, click on its name. You can then: + +* Add a new comment. +* Edit existing comments and the description. +* Add a connector. +* Send updates to external systems (if external connections are configured). +* Edit tags. +* Refresh the case to retrieve the latest updates. +* Change the status. +* Close or delete the case. +* Reopen a closed case. \ No newline at end of file diff --git a/docs/management/cases/setup-cases.asciidoc b/docs/management/cases/setup-cases.asciidoc new file mode 100644 index 000000000000..b0d68a22d991 --- /dev/null +++ b/docs/management/cases/setup-cases.asciidoc @@ -0,0 +1,28 @@ +[[setup-cases]] +== Configure access to cases + +preview::[] + +To access cases in *{stack-manage-app}*, you must have the appropriate {kib} +privileges: + +[options="header"] +|=== + +| Action | {kib} privileges +| Give full access to manage cases +a| +* `All` for the *Cases* feature under *Management*. +* `All` for the *Actions and Connectors* feature under *Management*. + +NOTE: The `All` *Actions and Connectors* feature privilege is required to +create, add, delete, and modify case connectors and to send updates to external +systems. + +| Give view-only access for cases | `Read` for the *Cases* feature under *Management*. + +| Revoke all access to cases | `None` for the *Cases* feature under *Management*. + +|=== + +For more details, refer to <>. diff --git a/docs/management/connectors/images/jira-connector.png b/docs/management/connectors/images/jira-connector.png index 5ff5ebf83afc..fc9a8ab31f87 100644 Binary files a/docs/management/connectors/images/jira-connector.png and b/docs/management/connectors/images/jira-connector.png differ diff --git a/docs/management/connectors/images/servicenow-connector.png b/docs/management/connectors/images/servicenow-connector.png index 9891a80ee758..cb74e8abcfba 100644 Binary files a/docs/management/connectors/images/servicenow-connector.png and b/docs/management/connectors/images/servicenow-connector.png differ diff --git a/docs/management/connectors/images/servicenow-sir-connector.png b/docs/management/connectors/images/servicenow-sir-connector.png index fbb137bd4f7d..71c7ce5ed05f 100644 Binary files a/docs/management/connectors/images/servicenow-sir-connector.png and b/docs/management/connectors/images/servicenow-sir-connector.png differ diff --git a/docs/management/connectors/images/swimlane-connector.png b/docs/management/connectors/images/swimlane-connector.png index 520c35d00381..6f557e694f41 100644 Binary files a/docs/management/connectors/images/swimlane-connector.png and b/docs/management/connectors/images/swimlane-connector.png differ diff --git a/docs/management/managing-licenses.asciidoc b/docs/management/managing-licenses.asciidoc index 8944414f6bfb..cf501518ea53 100644 --- a/docs/management/managing-licenses.asciidoc +++ b/docs/management/managing-licenses.asciidoc @@ -79,6 +79,7 @@ cluster. * The deprecation API is disabled. * SQL support is disabled. * Aggregations provided by the analytics plugin are no longer usable. +* All searchable snapshots indices are unassigned and cannot be searched. [discrete] [[expiration-watcher]] diff --git a/docs/osquery/osquery.asciidoc b/docs/osquery/osquery.asciidoc index 3231d2162f2e..e745835c8799 100644 --- a/docs/osquery/osquery.asciidoc +++ b/docs/osquery/osquery.asciidoc @@ -273,12 +273,18 @@ for an agent policy through Fleet. This integration supports x64 architecture on Windows, MacOS, and Linux platforms, and ARM64 architecture on Linux. -NOTE: The original {filebeat-ref}/filebeat-module-osquery.html[Filebeat Osquery module] +[NOTE] +========================= + +* The original {filebeat-ref}/filebeat-module-osquery.html[Filebeat Osquery module] and the https://docs.elastic.co/en/integrations/osquery[Osquery] integration collect logs from self-managed Osquery deployments. The *Osquery Manager* integration manages Osquery deployments and supports running and scheduling queries from {kib}. +* *Osquery Manager* cannot be integrated with an Elastic Agent in standalone mode. +========================= + [float] === Customize Osquery sub-feature privileges diff --git a/docs/settings/enterprise-search-settings.asciidoc b/docs/settings/enterprise-search-settings.asciidoc new file mode 100644 index 000000000000..736a7614b31e --- /dev/null +++ b/docs/settings/enterprise-search-settings.asciidoc @@ -0,0 +1,26 @@ +[role="xpack"] +[[enterprise-search-settings-kb]] +=== Enterprise Search settings in {kib} +++++ +Enterprise Search settings +++++ + +On Elastic Cloud, you do not need to configure any settings to use Enterprise Search in {kib}. It is enabled by default. On self-managed installations, you must configure `enterpriseSearch.host`. + +`enterpriseSearch.host`:: +The http(s) URL of your Enterprise Search instance. For example, in a local self-managed setup, +set this to `http://localhost:3002`. Authentication between {kib} and the Enterprise Search host URL, +such as via OAuth, is not supported. You can also +{enterprise-search-ref}/configure-ssl-tls.html#configure-ssl-tls-in-kibana[configure {kib} to trust +your Enterprise Search TLS certificate authority]. + + +`enterpriseSearch.accessCheckTimeout`:: +When launching the Enterprise Search UI, the maximum number of milliseconds for {kib} to wait +for a response from Enterprise Search +before considering the attempt failed and logging a warning. +Default: 5000. + +`enterpriseSearch.accessCheckTimeoutWarning`:: +When launching the Enterprise Search UI, the maximum number of milliseconds for {kib} to wait for a response from +Enterprise Search before logging a warning. Default: 300. diff --git a/docs/settings/monitoring-settings.asciidoc b/docs/settings/monitoring-settings.asciidoc index a328700ebeb5..ddc9b1c096b5 100644 --- a/docs/settings/monitoring-settings.asciidoc +++ b/docs/settings/monitoring-settings.asciidoc @@ -29,10 +29,17 @@ For more information, see [[monitoring-general-settings]] ==== General monitoring settings +`monitoring.cluster_alerts.email_notifications.enabled`:: +deprecated:[7.11.0] +When enabled, sends email notifications for Watcher alerts to the specified email address. The default is `true`. + +`monitoring.cluster_alerts.email_notifications.email_address` {ess-icon}:: +deprecated:[7.11.0] +When enabled, specifies the email address where you want to receive cluster alert notifications. + `monitoring.ui.ccs.enabled`:: Set to `true` (default) to enable {ref}/modules-cross-cluster-search.html[cross-cluster search] of your monitoring data. The {ref}/modules-remote-clusters.html#remote-cluster-settings[`remote_cluster_client`] role must exist on each node. - `monitoring.ui.elasticsearch.hosts`:: Specifies the location of the {es} cluster where your monitoring data is stored. + diff --git a/docs/setup/configuring-reporting.asciidoc b/docs/setup/configuring-reporting.asciidoc index 0b2fe4867077..6bdf6e5b27a6 100644 --- a/docs/setup/configuring-reporting.asciidoc +++ b/docs/setup/configuring-reporting.asciidoc @@ -6,7 +6,16 @@ Configure reporting ++++ -To enable users to manually and automatically generate reports, install the reporting packages, grant users access to the {report-features}, and secure the reporting endpoints. +For security, you grant users access to the {report-features} and secure the reporting endpoints +with TLS/SSL encryption. Additionally, you can install graphical packages into the operating system +to enable the {kib} server to have screenshotting capabilities. + +* <> +* <> +* <> +* <> +* <> +* <> [float] [[install-reporting-packages]] @@ -30,8 +39,9 @@ If you are using Ubuntu/Debian systems, install the following packages: * `fonts-liberation` * `libfontconfig1` +* `libnss3` -If the system is missing dependencies, *Reporting* fails in a non-deterministic way. {kib} runs a self-test at server startup, and +If the system is missing dependencies, a screenshot report job may fail in a non-deterministic way. {kib} runs a self-test at server startup, and if it encounters errors, logs them in the Console. The error message does not include information about why Chromium failed to run. The most common error message is `Error: connect ECONNREFUSED`, which indicates that {kib} could not connect to the Chromium process. @@ -52,7 +62,7 @@ xpack.reporting.roles.enabled: false + NOTE: If you use the default settings, you can still create a custom role that grants reporting privileges. The default role is `reporting_user`. This behavior is being deprecated and does not allow application-level access controls for {report-features}, and does not allow API keys or authentication tokens to authorize report generation. Refer to <> for information and caveats about the deprecated access control features. -. Create the reporting role. +. Create the reporting role. .. Open the main menu, then click *Stack Management*. @@ -76,14 +86,13 @@ For more information, refer to {ref}/security-privileges.html[Security privilege .. Click *Customize*, then click *Analytics*. -.. Next each application listed, click *All* or click *Read*. You will need to enable the *Customize sub-feature -privileges* checkbox to grant reporting privileges if you select *Read*. +.. For each application, select *All*, or to customize the privileges, select *Read* and *Customize sub-feature privileges*. + -If you’ve followed the example above, you should end up on a screen defining your customized privileges that looks like this: +NOTE: If you have a Basic license, sub-feature privileges are unavailable. For details, check out <>. [role="screenshot"] -image::user/reporting/images/kibana-privileges-with-reporting.png["Kibana privileges with Reporting options"] +image::user/reporting/images/kibana-privileges-with-reporting.png["Kibana privileges with Reporting options, Gold or higher license"] + -NOTE: If *Reporting* options for application features are not available, contact your administrator, or <>. +NOTE: If the *Reporting* options for application features are unavailable, and the cluster license is higher than Basic, contact your administrator, or <>. .. Click *Add {kib} privilege*. @@ -93,7 +102,7 @@ NOTE: If *Reporting* options for application features are not available, contact .. Open the main menu, then click *Stack Management*. -.. Click *Users*, then click the user you want to assign the reporting role to. +.. Click *Users*, then click the user you want to assign the reporting role to. .. From the *Roles* dropdown, select *custom_reporting_user*. @@ -104,29 +113,43 @@ Granting the privilege to generate reports also grants the user the privilege to [float] [[reporting-roles-user-api]] ==== Grant access with the role API -With <> enabled in Reporting, you can also use the {ref}/security-api-put-role.html[role API] to grant access to the {report-features}. Grant custom reporting roles to users in combination with other roles that grant read access to the data in {es}, and at least read access in the applications where users can generate reports. +With <> enabled in Reporting, you can also use the {ref}/security-api-put-role.html[role API] to grant access to the {report-features}, using *All* privileges, or sub-feature privileges. -[source, sh] +NOTE: If you have a Basic license, sub-feature privileges are unavailable. For details, check out the API command to grant *All* privileges in <>. + +Grant users custom Reporting roles, other roles that grant read access to the data in {es}, and at least read access in the applications where users can generate reports. + +[source, json] --------------------------------------------------------------- -POST /_security/role/custom_reporting_user +PUT localhost:5601/api/security/role/custom_reporting_user { - metadata: {}, - elasticsearch: { cluster: [], indices: [], run_as: [] }, - kibana: [ + "elasticsearch": { "cluster": [], "indices": [], "run_as": [] }, + "kibana": [ { - base: [], - feature: { - dashboard: [ - 'generate_report', <1> - 'download_csv_report' <2> + "base": [], + "feature": { + "dashboard": [ + "minimal_read", + "generate_report", <1> + "download_csv_report" <2> + ], + "discover": [ + "minimal_read", + "generate_report" <3> + ], + "canvas": [ + "minimal_read", + "generate_report" <4> ], - discover: ['generate_report'], <3> - canvas: ['generate_report'], <4> - visualize: ['generate_report'], <5> + "visualize": [ + "minimal_read", + "generate_report" <5> + ] }, - spaces: ['*'], + "spaces": [ "*" ] } - ] + ], + "metadata": {} // optional } --------------------------------------------------------------- // CONSOLE @@ -138,6 +161,41 @@ POST /_security/role/custom_reporting_user <5> Grants access to generate PNG and PDF reports in *Visualize Library*. [float] +[[grant-user-access-basic]] +=== Grant users access with a Basic license + +With a Basic license, you can grant users access with custom roles to {report-features} with <>. However, with a Basic license, sub-feature privileges are unavailable. <>, then select *All* privileges for the applications where users can create reports. + +[role="screenshot"] +image::user/reporting/images/kibana-privileges-with-reporting-basic.png["Kibana privileges with Reporting options, Basic license"] + +With a Basic license, sub-feature application privileges are unavailable, but you can use the {ref}/security-api-put-role.html[role API] to grant access to CSV {report-features}: + +[source, sh] +--------------------------------------------------------------- +PUT localhost:5601/api/security/role/custom_reporting_user +{ + "elasticsearch": { "cluster": [], "indices": [], "run_as": [] }, + "kibana": [ + { + "base": [], + "feature": { + "dashboard": [ "all" ], <1> + "discover": [ "all" ], <2> + }, + "spaces": [ "*" ] + } + ], + "metadata": {} // optional +} +--------------------------------------------------------------- +// CONSOLE + +<1> Grants access to generate CSV reports from saved searches in *Discover*. +<2> Grants access to download CSV reports from saved search panels in *Dashboard*. + +[float] +[[grant-user-access-external-provider]] ==== Grant access using an external provider If you are using an external identity provider, such as LDAP or Active Directory, you can assign roles to individual users or groups of users. Role mappings are configured in {ref}/mapping-roles.html[`config/role_mapping.yml`]. diff --git a/docs/setup/connect-to-elasticsearch.asciidoc b/docs/setup/connect-to-elasticsearch.asciidoc index 1d698e908793..9e1ee62f093f 100644 --- a/docs/setup/connect-to-elasticsearch.asciidoc +++ b/docs/setup/connect-to-elasticsearch.asciidoc @@ -55,7 +55,7 @@ https://www.elastic.co/guide/en/elasticsearch/client/index.html[{es} Client docu If you are running {kib} on our hosted {es} Service, click *View deployment details* on the *Integrations* view -to verify your {es} endpoint and Cloud ID, and create API keys for integestion. +to verify your {es} endpoint and Cloud ID, and create API keys for integration. [float] === Add sample data diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index e55a94a516d6..3a1e0f1a7f4f 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -282,9 +282,6 @@ on the {kib} index at startup. {kib} users still need to authenticate with that the {kib} server uses to perform maintenance on the {kib} index at startup. This setting is an alternative to `elasticsearch.username` and `elasticsearch.password`. -| `enterpriseSearch.host` - | The http(s) URL of your Enterprise Search instance. For example, in a local self-managed setup, set this to `http://localhost:3002`. Authentication between Kibana and the Enterprise Search host URL, such as via OAuth, is not supported. You can also {enterprise-search-ref}/configure-ssl-tls.html#configure-ssl-tls-in-kibana[configure Kibana to trust your Enterprise Search TLS certificate authority]. - | `interpreter.enableInVisualize` | Enables use of interpreter in Visualize. *Default: `true`* @@ -719,6 +716,7 @@ Valid locales are: `en`, `zh-CN`, `ja-JP`. *Default: `en`* include::{kib-repo-dir}/settings/alert-action-settings.asciidoc[] include::{kib-repo-dir}/settings/apm-settings.asciidoc[] include::{kib-repo-dir}/settings/banners-settings.asciidoc[] +include::{kib-repo-dir}/settings/enterprise-search-settings.asciidoc[] include::{kib-repo-dir}/settings/fleet-settings.asciidoc[] include::{kib-repo-dir}/settings/i18n-settings.asciidoc[] include::{kib-repo-dir}/settings/logging-settings.asciidoc[] @@ -726,8 +724,8 @@ include::{kib-repo-dir}/settings/logs-ui-settings.asciidoc[] include::{kib-repo-dir}/settings/infrastructure-ui-settings.asciidoc[] include::{kib-repo-dir}/settings/monitoring-settings.asciidoc[] include::{kib-repo-dir}/settings/reporting-settings.asciidoc[] -include::secure-settings.asciidoc[] include::{kib-repo-dir}/settings/search-sessions-settings.asciidoc[] +include::secure-settings.asciidoc[] include::{kib-repo-dir}/settings/security-settings.asciidoc[] include::{kib-repo-dir}/settings/spaces-settings.asciidoc[] include::{kib-repo-dir}/settings/task-manager-settings.asciidoc[] diff --git a/docs/user/dashboard/aggregation-based.asciidoc b/docs/user/dashboard/aggregation-based.asciidoc index c4f26e701bcc..bf13661b9aad 100644 --- a/docs/user/dashboard/aggregation-based.asciidoc +++ b/docs/user/dashboard/aggregation-based.asciidoc @@ -111,6 +111,8 @@ Choose the type of visualization you want to create, then use the editor to conf .. Select the visualization type you want to create. .. Select the data source you want to visualize. ++ +NOTE: There is no performance impact on the data source you select. For example, *Discover* saved searches perform the same as {data-sources}. . Add the <> you want to visualize using the editor, then click *Update*. + diff --git a/docs/user/dashboard/tsvb.asciidoc b/docs/user/dashboard/tsvb.asciidoc index a097e34b2091..56e3606c18b7 100644 --- a/docs/user/dashboard/tsvb.asciidoc +++ b/docs/user/dashboard/tsvb.asciidoc @@ -276,20 +276,4 @@ For other types of month over month calculations, use <> o Calculating the duration between the start and end of an event is unsupported in *TSVB* because *TSVB* requires correlation between different time periods. *TSVB* requires that the duration is pre-calculated. -==== - -[discrete] -[group-on-multiple-fields] -.*How do I group on multiple fields?* -[%collapsible] -==== - -To group with multiple fields, create runtime fields in the {data-source} you are visualizing. - -. Create a runtime field. Refer to <> for more information. -+ -[role="screenshot"] -image::images/tsvb_group_by_multiple_fields.png[Group by multiple fields] - -. Create a *TSVB* visualization and group by this field. ==== \ No newline at end of file diff --git a/docs/user/management.asciidoc b/docs/user/management.asciidoc index 6c309d56f229..908cdc792431 100644 --- a/docs/user/management.asciidoc +++ b/docs/user/management.asciidoc @@ -78,6 +78,9 @@ You can add and remove remote clusters, and check their connectivity. | Centrally <> across {kib}. Create and <> for triggering actions. +| <> +| Create and manage cases to investigate issues. + | <> | Monitor the generation of reports—PDF, PNG, and CSV—and download reports that you previously generated. A report can contain a dashboard, visualization, saved search, or Canvas workpad. @@ -175,6 +178,8 @@ see the https://www.elastic.co/subscriptions[subscription page]. include::{kib-repo-dir}/management/advanced-options.asciidoc[] +include::{kib-repo-dir}/management/cases/index.asciidoc[] + include::{kib-repo-dir}/management/action-types.asciidoc[] include::{kib-repo-dir}/management/managing-licenses.asciidoc[] diff --git a/docs/user/monitoring/xpack-monitoring.asciidoc b/docs/user/monitoring/xpack-monitoring.asciidoc index c3aafe7f90db..751710d1e74f 100644 --- a/docs/user/monitoring/xpack-monitoring.asciidoc +++ b/docs/user/monitoring/xpack-monitoring.asciidoc @@ -17,9 +17,6 @@ instance, and Beat is considered unique based on its persistent UUID, which is written to the <> directory when the node or instance starts. -NOTE: Watcher must be enabled to view cluster alerts. If you have a Basic -license, Top Cluster Alerts are not displayed. - For more information, see <> and {ref}/monitor-elasticsearch-cluster.html[Monitor a cluster]. diff --git a/docs/user/production-considerations/task-manager-production-considerations.asciidoc b/docs/user/production-considerations/task-manager-production-considerations.asciidoc index 672c310f138e..28c5f6e4f14c 100644 --- a/docs/user/production-considerations/task-manager-production-considerations.asciidoc +++ b/docs/user/production-considerations/task-manager-production-considerations.asciidoc @@ -101,7 +101,7 @@ Scaling {kib} instances horizontally requires a higher degree of coordination, w A recommended strategy is to follow these steps: 1. Produce a <> as a guide to provisioning as many {kib} instances as needed. Include any growth in tasks that you predict experiencing in the near future, and a buffer to better address ad-hoc tasks. -2. After provisioning a deployment, assess whether the provisioned {kib} instances achieve the required throughput by evaluating the <> as described in <>. +2. After provisioning a deployment, assess whether the provisioned {kib} instances achieve the required throughput by evaluating the <> as described in <>. 3. If the throughput is insufficient, and {kib} instances exhibit low resource usage, incrementally scale vertically while <> the impact of these changes. 4. If the throughput is insufficient, and {kib} instances are exhibiting high resource usage, incrementally scale horizontally by provisioning new {kib} instances and reassess. diff --git a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc index a22d46902f54..606dd3c8a24e 100644 --- a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc +++ b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc @@ -412,7 +412,7 @@ This assessment is based on the following: * Comparing the `last_successful_poll` to the `timestamp` (value of `2021-02-16T11:38:10.077Z`) at the root, where you can see the last polling cycle took place 1 second before the monitoring stats were exposed by the health monitoring API. * Comparing the `last_polling_delay` to the `timestamp` (value of `2021-02-16T11:38:10.077Z`) at the root, where you can see the last polling cycle delay took place 2 days ago, suggesting {kib} instances are not conflicting often. -* The `p50` of the `duration` shows that at least 50% of polling cycles take, at most, 13 millisconds to complete. +* The `p50` of the `duration` shows that at least 50% of polling cycles take, at most, 13 milliseconds to complete. * Evaluating the `result_frequency_percent_as_number`: ** 80% of the polling cycles completed without claiming any tasks (suggesting that there aren't any overdue tasks). ** 20% completed with Task Manager claiming tasks that were then executed. @@ -508,7 +508,7 @@ For details on achieving higher throughput by adjusting your scaling strategy, s Tasks run for too long, overrunning their schedule *Diagnosis*: -The <> theory analyzed a hypothetical scenario where both drift and load were unusually high. +The <> theory analyzed a hypothetical scenario where both drift and load were unusually high. Suppose an alternate scenario, where `drift` is high, but `load` is not, such as the following: @@ -688,7 +688,7 @@ Keep in mind that these stats give you a glimpse at a moment in time, and even t [[task-manager-health-evaluate-the-workload]] ===== Evaluate the Workload -Predicting the required throughput a deplyment might need to support Task Manager is difficult, as features can schedule an unpredictable number of tasks at a variety of scheduled cadences. +Predicting the required throughput a deployment might need to support Task Manager is difficult, as features can schedule an unpredictable number of tasks at a variety of scheduled cadences. <> provides statistics that make it easier to monitor the adequacy of the existing throughput. By evaluating the workload, the required throughput can be estimated, which is used when following the Task Manager <>. diff --git a/docs/user/reporting/images/kibana-privileges-with-reporting-basic.png b/docs/user/reporting/images/kibana-privileges-with-reporting-basic.png new file mode 100644 index 000000000000..6d2c3ba645b5 Binary files /dev/null and b/docs/user/reporting/images/kibana-privileges-with-reporting-basic.png differ diff --git a/docs/user/reporting/reporting-troubleshooting.asciidoc b/docs/user/reporting/reporting-troubleshooting.asciidoc index 50a163c08858..7d2d0dff37a0 100644 --- a/docs/user/reporting/reporting-troubleshooting.asciidoc +++ b/docs/user/reporting/reporting-troubleshooting.asciidoc @@ -1,6 +1,7 @@ [role="xpack"] [[reporting-troubleshooting]] == Reporting troubleshooting + ++++ Troubleshooting ++++ diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index 2f2b27938979..446de62326f8 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -5,7 +5,7 @@ Authentication ++++ :keywords: administrator, concept, security, authentication -:description: A list of the supported authentication mechanisms in {kib}. +:description: A list of the supported authentication mechanisms in {kib}. {kib} supports the following authentication mechanisms: @@ -483,4 +483,4 @@ To make this iframe leverage anonymous access automatically, you will need to mo NOTE: `auth_provider_hint` query string parameter goes *before* the hash URL fragment. -For more information on how to embed, refer to <>. +For more information, refer to <>. diff --git a/examples/embeddable_examples/public/list_container/list_container_component.tsx b/examples/embeddable_examples/public/list_container/list_container_component.tsx index 031100c07409..d939cc2029f6 100644 --- a/examples/embeddable_examples/public/list_container/list_container_component.tsx +++ b/examples/embeddable_examples/public/list_container/list_container_component.tsx @@ -15,6 +15,7 @@ import { ContainerInput, ContainerOutput, EmbeddableStart, + EmbeddableChildPanel, } from '../../../../src/plugins/embeddable/public'; interface Props { @@ -31,7 +32,6 @@ function renderList( ) { let number = 0; const list = Object.values(panels).map((panel) => { - const child = embeddable.getChild(panel.explicitInput.id); number++; return ( @@ -42,7 +42,11 @@ function renderList( - + diff --git a/logs.tar.gz b/logs.tar.gz deleted file mode 100644 index 2a1acc4b5eba..000000000000 Binary files a/logs.tar.gz and /dev/null differ diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index a86772d3ef27..43ca1ed4bf81 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -6,7 +6,7 @@ "description": "Developer documentation for building custom Kibana plugins and extending Kibana functionality.", "items": [ { - "category": "Getting started", + "label": "Getting started", "items": [ { "id": "kibDevDocsWelcome" }, { "id": "kibDevTutorialSetupDevEnv" }, @@ -16,7 +16,20 @@ ] }, { - "category": "Key concepts", + "label": "Contributing", + "items": [ + { "id": "kibDevPrinciples" }, + { "id": "kibRepoStructure" }, + { "id": "kibStandards" }, + { "id": "kibBestPractices" }, + { "id": "kibDocumentation" }, + { "id": "kibStyleGuide" }, + { "id": "ktRFCProcess" }, + { "id": "kibGitHub" } + ] + }, + { + "label": "Key concepts", "items": [ { "id": "kibPlatformIntro" }, { "id": "kibDevAnatomyOfAPlugin" }, @@ -32,7 +45,7 @@ ] }, { - "category": "Tutorials", + "label": "Tutorials", "items": [ { "id": "kibDevTutorialTestingPlugins" }, { "id": "kibDevTutorialSavedObject" }, @@ -53,19 +66,7 @@ ] }, { - "category": "Contributing", - "items": [ - { "id": "kibRepoStructure" }, - { "id": "kibDevPrinciples" }, - { "id": "kibStandards" }, - { "id": "ktRFCProcess" }, - { "id": "kibBestPractices" }, - { "id": "kibStyleGuide" }, - { "id": "kibGitHub" } - ] - }, - { - "category": "Contributors Newsletters", + "label": "Contributors Newsletters", "items": [ { "id": "kibFebruary2022ContributorNewsletter" }, { "id": "kibJanuary2022ContributorNewsletter" }, @@ -82,7 +83,7 @@ ] }, { - "category": "API documentation", + "label": "API documentation", "items": [ { "id": "kibDevDocsApiWelcome" }, { "id": "kibDevDocsPluginDirectory" }, diff --git a/package.json b/package.json index fdb358c25fb8..b756d9cb05f1 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "docs:acceptApiChanges": "node --max-old-space-size=6144 scripts/check_published_api_changes.js --accept", "es": "node scripts/es", "preinstall": "node ./preinstall_check", - "postinstall": "node scripts/kbn patch_native_modules", "kbn": "node scripts/kbn", "lint": "yarn run lint:es && yarn run lint:style", "lint:es": "node scripts/eslint", @@ -85,6 +84,7 @@ "**/isomorphic-fetch/node-fetch": "^2.6.7", "**/istanbul-lib-coverage": "^3.2.0", "**/json-schema": "^0.4.0", + "**/minimatch": "^3.1.2", "**/minimist": "^1.2.5", "**/node-forge": "^1.2.1", "**/pdfkit/crypto-js": "4.0.0", @@ -92,7 +92,7 @@ "**/react-syntax-highlighter/**/highlight.js": "^10.4.1", "**/refractor/prismjs": "~1.27.0", "**/trim": "1.0.1", - "**/typescript": "4.5.3", + "**/typescript": "4.6.2", "**/underscore": "^1.13.1", "globby/fast-glob": "3.2.7", "puppeteer/node-fetch": "^2.6.7" @@ -107,8 +107,8 @@ "@elastic/apm-synthtrace": "link:bazel-bin/packages/elastic-apm-synthtrace", "@elastic/charts": "43.1.1", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", - "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.1.0-canary.3", - "@elastic/ems-client": "8.0.0", + "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.2.0-canary.1", + "@elastic/ems-client": "8.1.0", "@elastic/eui": "48.1.1", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", @@ -278,7 +278,7 @@ "js-search": "^1.4.3", "js-sha256": "^0.9.0", "js-sql-parser": "^1.4.1", - "js-yaml": "^3.14.0", + "js-yaml": "^3.14.1", "json-stable-stringify": "^1.0.1", "json-stringify-pretty-compact": "1.2.0", "json-stringify-safe": "5.0.1", @@ -299,7 +299,7 @@ "mime": "^2.4.4", "mime-types": "^2.1.27", "mini-css-extract-plugin": "1.1.0", - "minimatch": "^3.0.4", + "minimatch": "^3.1.2", "moment": "^2.24.0", "moment-duration-format": "^2.3.2", "moment-timezone": "^0.5.27", @@ -333,7 +333,7 @@ "raw-loader": "^3.1.0", "rbush": "^3.0.1", "re-resizable": "^6.1.1", - "re2": "^1.16.0", + "re2": "1.17.4", "react": "^16.12.0", "react-ace": "^7.0.5", "react-beautiful-dnd": "^13.1.0", @@ -348,7 +348,7 @@ "react-moment-proptypes": "^1.7.0", "react-monaco-editor": "^0.41.2", "react-popper-tooltip": "^2.10.1", - "react-query": "^3.34.0", + "react-query": "^3.34.7", "react-redux": "^7.2.0", "react-resizable": "^1.7.5", "react-resize-detector": "^4.2.0", @@ -425,16 +425,16 @@ }, "devDependencies": { "@apidevtools/swagger-parser": "^10.0.3", - "@babel/cli": "^7.17.0", - "@babel/core": "^7.17.2", + "@babel/cli": "^7.17.6", + "@babel/core": "^7.17.5", "@babel/eslint-parser": "^7.17.0", "@babel/eslint-plugin": "^7.16.5", - "@babel/generator": "^7.17.0", - "@babel/parser": "^7.17.0", + "@babel/generator": "^7.17.3", + "@babel/parser": "^7.17.3", "@babel/plugin-proposal-class-properties": "^7.16.7", "@babel/plugin-proposal-export-namespace-from": "^7.16.7", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7", - "@babel/plugin-proposal-object-rest-spread": "^7.16.7", + "@babel/plugin-proposal-object-rest-spread": "^7.17.3", "@babel/plugin-proposal-optional-chaining": "^7.16.7", "@babel/plugin-proposal-private-methods": "^7.16.11", "@babel/plugin-transform-runtime": "^7.17.0", @@ -442,7 +442,7 @@ "@babel/preset-react": "^7.16.7", "@babel/preset-typescript": "^7.16.7", "@babel/register": "^7.17.0", - "@babel/traverse": "^7.17.0", + "@babel/traverse": "^7.17.3", "@babel/types": "^7.17.0", "@bazel/ibazel": "^0.15.10", "@bazel/typescript": "4.0.0", @@ -462,6 +462,7 @@ "@jest/reporters": "^26.6.2", "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser", "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset", + "@kbn/bazel-packages": "link:bazel-bin/packages/kbn-bazel-packages", "@kbn/cli-dev-mode": "link:bazel-bin/packages/kbn-cli-dev-mode", "@kbn/dev-utils": "link:bazel-bin/packages/kbn-dev-utils", "@kbn/docs-utils": "link:bazel-bin/packages/kbn-docs-utils", @@ -470,6 +471,7 @@ "@kbn/eslint-import-resolver-kibana": "link:bazel-bin/packages/kbn-eslint-import-resolver-kibana", "@kbn/eslint-plugin-eslint": "link:bazel-bin/packages/kbn-eslint-plugin-eslint", "@kbn/expect": "link:bazel-bin/packages/kbn-expect", + "@kbn/generate": "link:bazel-bin/packages/kbn-generate", "@kbn/optimizer": "link:bazel-bin/packages/kbn-optimizer", "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:bazel-bin/packages/kbn-plugin-helpers", @@ -480,6 +482,7 @@ "@kbn/test": "link:bazel-bin/packages/kbn-test", "@kbn/test-jest-helpers": "link:bazel-bin/packages/kbn-test-jest-helpers", "@kbn/test-subj-selector": "link:bazel-bin/packages/kbn-test-subj-selector", + "@kbn/type-summarizer": "link:bazel-bin/packages/kbn-type-summarizer", "@loaders.gl/polyfills": "^2.3.5", "@mapbox/vector-tile": "1.3.1", "@microsoft/api-documenter": "7.13.68", @@ -567,7 +570,7 @@ "@types/js-levenshtein": "^1.1.0", "@types/js-search": "^1.4.0", "@types/js-yaml": "^3.11.1", - "@types/jsdom": "^16.2.13", + "@types/jsdom": "^16.2.14", "@types/json-stable-stringify": "^1.0.32", "@types/json5": "^0.0.30", "@types/kbn__ace": "link:bazel-bin/packages/kbn-ace/npm_module_types", @@ -575,6 +578,7 @@ "@types/kbn__analytics": "link:bazel-bin/packages/kbn-analytics/npm_module_types", "@types/kbn__apm-config-loader": "link:bazel-bin/packages/kbn-apm-config-loader/npm_module_types", "@types/kbn__apm-utils": "link:bazel-bin/packages/kbn-apm-utils/npm_module_types", + "@types/kbn__bazel-packages": "link:bazel-bin/packages/kbn-bazel-packages/npm_module_types", "@types/kbn__cli-dev-mode": "link:bazel-bin/packages/kbn-cli-dev-mode/npm_module_types", "@types/kbn__config": "link:bazel-bin/packages/kbn-config/npm_module_types", "@types/kbn__config-schema": "link:bazel-bin/packages/kbn-config-schema/npm_module_types", @@ -585,6 +589,7 @@ "@types/kbn__es-archiver": "link:bazel-bin/packages/kbn-es-archiver/npm_module_types", "@types/kbn__es-query": "link:bazel-bin/packages/kbn-es-query/npm_module_types", "@types/kbn__field-types": "link:bazel-bin/packages/kbn-field-types/npm_module_types", + "@types/kbn__generate": "link:bazel-bin/packages/kbn-generate/npm_module_types", "@types/kbn__i18n": "link:bazel-bin/packages/kbn-i18n/npm_module_types", "@types/kbn__i18n-react": "link:bazel-bin/packages/kbn-i18n-react/npm_module_types", "@types/kbn__interpreter": "link:bazel-bin/packages/kbn-interpreter/npm_module_types", @@ -645,7 +650,7 @@ "@types/nock": "^10.0.3", "@types/node": "16.10.2", "@types/node-fetch": "^2.6.0", - "@types/node-forge": "^1.0.0", + "@types/node-forge": "^1.0.1", "@types/nodemailer": "^6.4.0", "@types/normalize-path": "^3.0.0", "@types/object-hash": "^1.3.0", @@ -714,9 +719,9 @@ "@types/yargs": "^15.0.0", "@types/yauzl": "^2.9.1", "@types/zen-observable": "^0.8.0", - "@typescript-eslint/eslint-plugin": "^5.6.0", - "@typescript-eslint/parser": "^5.6.0", - "@typescript-eslint/typescript-estree": "^5.6.0", + "@typescript-eslint/eslint-plugin": "^5.14.0", + "@typescript-eslint/parser": "^5.14.0", + "@typescript-eslint/typescript-estree": "^5.14.0", "@yarnpkg/lockfile": "^1.1.0", "abab": "^2.0.4", "aggregate-error": "^3.1.0", @@ -731,13 +736,13 @@ "babel-plugin-add-module-exports": "^1.0.4", "babel-plugin-istanbul": "^6.1.1", "babel-plugin-require-context-hook": "^1.0.0", - "babel-plugin-styled-components": "^2.0.2", + "babel-plugin-styled-components": "^2.0.6", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "backport": "^7.3.1", "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^98.0.0", + "chromedriver": "^99.0.0", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compression-webpack-plugin": "^4.0.0", @@ -842,7 +847,7 @@ "multimatch": "^4.0.0", "mutation-observer": "^1.0.3", "ncp": "^2.0.0", - "node-sass": "^6.0.1", + "node-sass": "6.0.1", "null-loader": "^3.0.0", "nyc": "^15.1.0", "oboe": "^2.1.4", @@ -870,6 +875,7 @@ "simple-git": "1.116.0", "sinon": "^7.4.2", "sort-package-json": "^1.53.1", + "source-map": "^0.7.3", "spawn-sync": "^1.0.15", "string-replace-loader": "^2.2.0", "strong-log-transformer": "^2.1.0", @@ -888,7 +894,7 @@ "ts-loader": "^7.0.5", "ts-morph": "^13.0.2", "tsd": "^0.13.1", - "typescript": "4.5.3", + "typescript": "4.6.2", "unlazy-loader": "^0.1.3", "url-loader": "^2.2.0", "val-loader": "^1.1.1", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 02e82476cd88..66361060f1ee 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -1,78 +1,87 @@ +################ +################ +## This file is automatically generated, to create a new package use `node scripts/generate package --help` +################ +################ + # It will build all declared code packages filegroup( name = "build_pkg_code", srcs = [ - "//packages/elastic-apm-synthtrace:build", - "//packages/elastic-datemath:build", - "//packages/elastic-eslint-config-kibana:build", - "//packages/elastic-safer-lodash-set:build", - "//packages/kbn-ace:build", - "//packages/kbn-alerts:build", - "//packages/kbn-analytics:build", - "//packages/kbn-apm-config-loader:build", - "//packages/kbn-apm-utils:build", - "//packages/kbn-babel-code-parser:build", - "//packages/kbn-babel-preset:build", - "//packages/kbn-cli-dev-mode:build", - "//packages/kbn-config:build", - "//packages/kbn-config-schema:build", - "//packages/kbn-crypto:build", - "//packages/kbn-dev-utils:build", - "//packages/kbn-doc-links:build", - "//packages/kbn-docs-utils:build", - "//packages/kbn-es:build", - "//packages/kbn-es-archiver:build", - "//packages/kbn-es-query:build", - "//packages/kbn-eslint-import-resolver-kibana:build", - "//packages/kbn-eslint-plugin-eslint:build", - "//packages/kbn-expect:build", - "//packages/kbn-field-types:build", - "//packages/kbn-flot-charts:build", - "//packages/kbn-i18n:build", - "//packages/kbn-i18n-react:build", - "//packages/kbn-interpreter:build", - "//packages/kbn-io-ts-utils:build", - "//packages/kbn-logging:build", - "//packages/kbn-logging-mocks:build", - "//packages/kbn-mapbox-gl:build", - "//packages/kbn-monaco:build", - "//packages/kbn-optimizer:build", - "//packages/kbn-plugin-generator:build", - "//packages/kbn-plugin-helpers:build", - "//packages/kbn-react-field:build", - "//packages/kbn-rule-data-utils:build", - "//packages/kbn-securitysolution-autocomplete:build", - "//packages/kbn-securitysolution-list-constants:build", - "//packages/kbn-securitysolution-io-ts-types:build", - "//packages/kbn-securitysolution-io-ts-alerting-types:build", - "//packages/kbn-securitysolution-io-ts-list-types:build", - "//packages/kbn-securitysolution-io-ts-utils:build", - "//packages/kbn-securitysolution-list-api:build", - "//packages/kbn-securitysolution-list-hooks:build", - "//packages/kbn-securitysolution-list-utils:build", - "//packages/kbn-securitysolution-rules:build", - "//packages/kbn-securitysolution-utils:build", - "//packages/kbn-securitysolution-es-utils:build", - "//packages/kbn-securitysolution-t-grid:build", - "//packages/kbn-securitysolution-hook-utils:build", - "//packages/kbn-server-http-tools:build", - "//packages/kbn-server-route-repository:build", - "//packages/kbn-spec-to-console:build", - "//packages/kbn-std:build", - "//packages/kbn-storybook:build", - "//packages/kbn-telemetry-tools:build", - "//packages/kbn-test:build", - "//packages/kbn-test-jest-helpers:build", - "//packages/kbn-test-subj-selector:build", - "//packages/kbn-timelion-grammar:build", - "//packages/kbn-tinymath:build", - "//packages/kbn-typed-react-router-config:build", - "//packages/kbn-ui-framework:build", - "//packages/kbn-ui-shared-deps-npm:build", - "//packages/kbn-ui-shared-deps-src:build", - "//packages/kbn-ui-theme:build", - "//packages/kbn-utility-types:build", - "//packages/kbn-utils:build", + "//packages/elastic-apm-synthtrace:build", + "//packages/elastic-datemath:build", + "//packages/elastic-eslint-config-kibana:build", + "//packages/elastic-safer-lodash-set:build", + "//packages/kbn-ace:build", + "//packages/kbn-alerts:build", + "//packages/kbn-analytics:build", + "//packages/kbn-apm-config-loader:build", + "//packages/kbn-apm-utils:build", + "//packages/kbn-babel-code-parser:build", + "//packages/kbn-babel-preset:build", + "//packages/kbn-bazel-packages:build", + "//packages/kbn-cli-dev-mode:build", + "//packages/kbn-config-schema:build", + "//packages/kbn-config:build", + "//packages/kbn-crypto:build", + "//packages/kbn-dev-utils:build", + "//packages/kbn-doc-links:build", + "//packages/kbn-docs-utils:build", + "//packages/kbn-es-archiver:build", + "//packages/kbn-es-query:build", + "//packages/kbn-es:build", + "//packages/kbn-eslint-import-resolver-kibana:build", + "//packages/kbn-eslint-plugin-eslint:build", + "//packages/kbn-expect:build", + "//packages/kbn-field-types:build", + "//packages/kbn-flot-charts:build", + "//packages/kbn-generate:build", + "//packages/kbn-i18n-react:build", + "//packages/kbn-i18n:build", + "//packages/kbn-interpreter:build", + "//packages/kbn-io-ts-utils:build", + "//packages/kbn-logging-mocks:build", + "//packages/kbn-logging:build", + "//packages/kbn-mapbox-gl:build", + "//packages/kbn-monaco:build", + "//packages/kbn-optimizer:build", + "//packages/kbn-plugin-generator:build", + "//packages/kbn-plugin-helpers:build", + "//packages/kbn-react-field:build", + "//packages/kbn-rule-data-utils:build", + "//packages/kbn-securitysolution-autocomplete:build", + "//packages/kbn-securitysolution-es-utils:build", + "//packages/kbn-securitysolution-hook-utils:build", + "//packages/kbn-securitysolution-io-ts-alerting-types:build", + "//packages/kbn-securitysolution-io-ts-list-types:build", + "//packages/kbn-securitysolution-io-ts-types:build", + "//packages/kbn-securitysolution-io-ts-utils:build", + "//packages/kbn-securitysolution-list-api:build", + "//packages/kbn-securitysolution-list-constants:build", + "//packages/kbn-securitysolution-list-hooks:build", + "//packages/kbn-securitysolution-list-utils:build", + "//packages/kbn-securitysolution-rules:build", + "//packages/kbn-securitysolution-t-grid:build", + "//packages/kbn-securitysolution-utils:build", + "//packages/kbn-server-http-tools:build", + "//packages/kbn-server-route-repository:build", + "//packages/kbn-spec-to-console:build", + "//packages/kbn-std:build", + "//packages/kbn-storybook:build", + "//packages/kbn-telemetry-tools:build", + "//packages/kbn-test-jest-helpers:build", + "//packages/kbn-test-subj-selector:build", + "//packages/kbn-test:build", + "//packages/kbn-timelion-grammar:build", + "//packages/kbn-tinymath:build", + "//packages/kbn-type-summarizer:build", + "//packages/kbn-typed-react-router-config:build", + "//packages/kbn-ui-framework:build", + "//packages/kbn-ui-shared-deps-npm:build", + "//packages/kbn-ui-shared-deps-src:build", + "//packages/kbn-ui-theme:build", + "//packages/kbn-utility-types:build", + "//packages/kbn-utils:build", ], ) @@ -80,76 +89,77 @@ filegroup( filegroup( name = "build_pkg_types", srcs = [ - "//packages/elastic-apm-synthtrace:build_types", - "//packages/elastic-datemath:build_types", - "//packages/elastic-safer-lodash-set:build_types", - "//packages/kbn-ace:build_types", - "//packages/kbn-alerts:build_types", - "//packages/kbn-analytics:build_types", - "//packages/kbn-apm-config-loader:build_types", - "//packages/kbn-apm-utils:build_types", - "//packages/kbn-cli-dev-mode:build_types", - "//packages/kbn-config:build_types", - "//packages/kbn-config-schema:build_types", - "//packages/kbn-crypto:build_types", - "//packages/kbn-dev-utils:build_types", - "//packages/kbn-doc-links:build_types", - "//packages/kbn-docs-utils:build_types", - "//packages/kbn-es-archiver:build_types", - "//packages/kbn-es-query:build_types", - "//packages/kbn-field-types:build_types", - "//packages/kbn-i18n:build_types", - "//packages/kbn-i18n-react:build_types", - "//packages/kbn-interpreter:build_types", - "//packages/kbn-io-ts-utils:build_types", - "//packages/kbn-logging:build_types", - "//packages/kbn-logging-mocks:build_types", - "//packages/kbn-mapbox-gl:build_types", - "//packages/kbn-monaco:build_types", - "//packages/kbn-optimizer:build_types", - "//packages/kbn-plugin-generator:build_types", - "//packages/kbn-plugin-helpers:build_types", - "//packages/kbn-react-field:build_types", - "//packages/kbn-rule-data-utils:build_types", - "//packages/kbn-securitysolution-autocomplete:build_types", - "//packages/kbn-securitysolution-es-utils:build_types", - "//packages/kbn-securitysolution-hook-utils:build_types", - "//packages/kbn-securitysolution-io-ts-alerting-types:build_types", - "//packages/kbn-securitysolution-io-ts-list-types:build_types", - "//packages/kbn-securitysolution-io-ts-types:build_types", - "//packages/kbn-securitysolution-io-ts-utils:build_types", - "//packages/kbn-securitysolution-list-api:build_types", - "//packages/kbn-securitysolution-list-constants:build_types", - "//packages/kbn-securitysolution-list-hooks:build_types", - "//packages/kbn-securitysolution-list-utils:build_types", - "//packages/kbn-securitysolution-rules:build_types", - "//packages/kbn-securitysolution-t-grid:build_types", - "//packages/kbn-securitysolution-utils:build_types", - "//packages/kbn-server-http-tools:build_types", - "//packages/kbn-server-route-repository:build_types", - "//packages/kbn-std:build_types", - "//packages/kbn-storybook:build_types", - "//packages/kbn-telemetry-tools:build_types", - "//packages/kbn-test:build_types", - "//packages/kbn-test-jest-helpers:build_types", - "//packages/kbn-typed-react-router-config:build_types", - "//packages/kbn-ui-shared-deps-npm:build_types", - "//packages/kbn-ui-shared-deps-src:build_types", - "//packages/kbn-ui-theme:build_types", - "//packages/kbn-utility-types:build_types", - "//packages/kbn-utils:build_types", + "//packages/elastic-apm-synthtrace:build_types", + "//packages/elastic-datemath:build_types", + "//packages/elastic-safer-lodash-set:build_types", + "//packages/kbn-ace:build_types", + "//packages/kbn-alerts:build_types", + "//packages/kbn-analytics:build_types", + "//packages/kbn-apm-config-loader:build_types", + "//packages/kbn-apm-utils:build_types", + "//packages/kbn-bazel-packages:build_types", + "//packages/kbn-cli-dev-mode:build_types", + "//packages/kbn-config-schema:build_types", + "//packages/kbn-config:build_types", + "//packages/kbn-crypto:build_types", + "//packages/kbn-dev-utils:build_types", + "//packages/kbn-doc-links:build_types", + "//packages/kbn-docs-utils:build_types", + "//packages/kbn-es-archiver:build_types", + "//packages/kbn-es-query:build_types", + "//packages/kbn-field-types:build_types", + "//packages/kbn-generate:build_types", + "//packages/kbn-i18n-react:build_types", + "//packages/kbn-i18n:build_types", + "//packages/kbn-interpreter:build_types", + "//packages/kbn-io-ts-utils:build_types", + "//packages/kbn-logging-mocks:build_types", + "//packages/kbn-logging:build_types", + "//packages/kbn-mapbox-gl:build_types", + "//packages/kbn-monaco:build_types", + "//packages/kbn-optimizer:build_types", + "//packages/kbn-plugin-generator:build_types", + "//packages/kbn-plugin-helpers:build_types", + "//packages/kbn-react-field:build_types", + "//packages/kbn-rule-data-utils:build_types", + "//packages/kbn-securitysolution-autocomplete:build_types", + "//packages/kbn-securitysolution-es-utils:build_types", + "//packages/kbn-securitysolution-hook-utils:build_types", + "//packages/kbn-securitysolution-io-ts-alerting-types:build_types", + "//packages/kbn-securitysolution-io-ts-list-types:build_types", + "//packages/kbn-securitysolution-io-ts-types:build_types", + "//packages/kbn-securitysolution-io-ts-utils:build_types", + "//packages/kbn-securitysolution-list-api:build_types", + "//packages/kbn-securitysolution-list-constants:build_types", + "//packages/kbn-securitysolution-list-hooks:build_types", + "//packages/kbn-securitysolution-list-utils:build_types", + "//packages/kbn-securitysolution-rules:build_types", + "//packages/kbn-securitysolution-t-grid:build_types", + "//packages/kbn-securitysolution-utils:build_types", + "//packages/kbn-server-http-tools:build_types", + "//packages/kbn-server-route-repository:build_types", + "//packages/kbn-std:build_types", + "//packages/kbn-storybook:build_types", + "//packages/kbn-telemetry-tools:build_types", + "//packages/kbn-test-jest-helpers:build_types", + "//packages/kbn-test:build_types", + "//packages/kbn-type-summarizer:build_types", + "//packages/kbn-typed-react-router-config:build_types", + "//packages/kbn-ui-shared-deps-npm:build_types", + "//packages/kbn-ui-shared-deps-src:build_types", + "//packages/kbn-ui-theme:build_types", + "//packages/kbn-utility-types:build_types", + "//packages/kbn-utils:build_types", ], ) - - # Grouping target to call all underlying packages build # targets so we can build them all at once # It will auto build all declared code packages and types packages filegroup( name = "build", srcs = [ - ":build_pkg_code", - ":build_pkg_types" + ":build_pkg_code", + ":build_pkg_types" ], ) diff --git a/packages/elastic-apm-synthtrace/BUILD.bazel b/packages/elastic-apm-synthtrace/BUILD.bazel index 09406644f44b..646b94891f7c 100644 --- a/packages/elastic-apm-synthtrace/BUILD.bazel +++ b/packages/elastic-apm-synthtrace/BUILD.bazel @@ -32,6 +32,7 @@ RUNTIME_DEPS = [ "@npm//object-hash", "@npm//p-limit", "@npm//yargs", + "@npm//node-fetch", ] TYPES_DEPS = [ @@ -43,6 +44,7 @@ TYPES_DEPS = [ "@npm//@types/object-hash", "@npm//moment", "@npm//p-limit", + "@npm//@types/node-fetch", ] jsts_transpiler( diff --git a/packages/elastic-apm-synthtrace/src/index.ts b/packages/elastic-apm-synthtrace/src/index.ts index 381222ee10ef..0138a6525baf 100644 --- a/packages/elastic-apm-synthtrace/src/index.ts +++ b/packages/elastic-apm-synthtrace/src/index.ts @@ -14,3 +14,4 @@ export { createLogger, LogLevel } from './lib/utils/create_logger'; export type { Fields } from './lib/entity'; export type { ApmException, ApmSynthtraceEsClient } from './lib/apm'; +export type { SpanIterable } from './lib/span_iterable'; diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts b/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts index 4afebf0352a6..f117fc879c0e 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts @@ -31,9 +31,14 @@ export type ApmUserAgentFields = Partial<{ export interface ApmException { message: string; } +export interface Observer { + version: string; + version_major: number; +} export type ApmFields = Fields & Partial<{ + 'timestamp.us'?: number; 'agent.name': string; 'agent.version': string; 'container.id': string; @@ -47,8 +52,7 @@ export type ApmFields = Fields & 'host.name': string; 'kubernetes.pod.uid': string; 'metricset.name': string; - 'observer.version': string; - 'observer.version_major': number; + observer: Observer; 'parent.id': string; 'processor.event': string; 'processor.name': string; diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/base_span.ts b/packages/elastic-apm-synthtrace/src/lib/apm/base_span.ts index 4fd5ee269860..fa57c2871d8a 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/base_span.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/base_span.ts @@ -24,7 +24,7 @@ export class BaseSpan extends Serializable { }); } - parent(span: BaseSpan) { + parent(span: BaseSpan): this { this.fields['trace.id'] = span.fields['trace.id']; this.fields['parent.id'] = span.isSpan() ? span.fields['span.id'] @@ -40,7 +40,7 @@ export class BaseSpan extends Serializable { return this; } - children(...children: BaseSpan[]) { + children(...children: BaseSpan[]): this { children.forEach((child) => { child.parent(this); }); @@ -50,17 +50,17 @@ export class BaseSpan extends Serializable { return this; } - success() { + success(): this { this.fields['event.outcome'] = 'success'; return this; } - failure() { + failure(): this { this.fields['event.outcome'] = 'failure'; return this; } - outcome(outcome: 'success' | 'failure' | 'unknown') { + outcome(outcome: 'success' | 'failure' | 'unknown'): this { this.fields['event.outcome'] = outcome; return this; } diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/client/apm_synthtrace_es_client.ts b/packages/elastic-apm-synthtrace/src/lib/apm/client/apm_synthtrace_es_client.ts index 4a25d7009ad0..f5d68d8614e6 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/client/apm_synthtrace_es_client.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/client/apm_synthtrace_es_client.ts @@ -7,56 +7,115 @@ */ import { Client } from '@elastic/elasticsearch'; -import { uploadEvents } from '../../../scripts/utils/upload_events'; -import { Fields } from '../../entity'; import { cleanWriteTargets } from '../../utils/clean_write_targets'; -import { getBreakdownMetrics } from '../utils/get_breakdown_metrics'; -import { getSpanDestinationMetrics } from '../utils/get_span_destination_metrics'; -import { getTransactionMetrics } from '../utils/get_transaction_metrics'; import { getApmWriteTargets } from '../utils/get_apm_write_targets'; import { Logger } from '../../utils/create_logger'; -import { apmEventsToElasticsearchOutput } from '../utils/apm_events_to_elasticsearch_output'; +import { ApmFields } from '../apm_fields'; +import { SpanIterable } from '../../span_iterable'; +import { StreamProcessor } from '../../stream_processor'; +import { SpanGeneratorsUnion } from '../../span_generators_union'; + +export interface StreamToBulkOptions { + concurrency?: number; + maxDocs?: number; + mapToIndex?: (document: Record) => string; +} export class ApmSynthtraceEsClient { - constructor(private readonly client: Client, private readonly logger: Logger) {} + constructor( + private readonly client: Client, + private readonly logger: Logger, + private readonly forceDataStreams: boolean + ) {} private getWriteTargets() { - return getApmWriteTargets({ client: this.client }); + return getApmWriteTargets({ client: this.client, forceDataStreams: this.forceDataStreams }); } clean() { - return this.getWriteTargets().then((writeTargets) => - cleanWriteTargets({ + return this.getWriteTargets().then(async (writeTargets) => { + const indices = Object.values(writeTargets); + this.logger.info(`Attempting to clean: ${indices}`); + if (this.forceDataStreams) { + for (const name of indices) { + const dataStream = await this.client.indices.getDataStream({ name }, { ignore: [404] }); + if (dataStream.data_streams && dataStream.data_streams.length > 0) { + this.logger.debug(`Deleting datastream: ${name}`); + await this.client.indices.deleteDataStream({ name }); + } + } + return; + } + + return cleanWriteTargets({ client: this.client, - targets: Object.values(writeTargets), + targets: indices, logger: this.logger, - }) - ); + }); + }); } - async index(events: Fields[]) { - const writeTargets = await this.getWriteTargets(); + async updateComponentTemplates(numberOfPrimaryShards: number) { + const response = await this.client.cluster.getComponentTemplate({ name: '*apm*@custom' }); + for (const componentTemplate of response.component_templates) { + if (componentTemplate.component_template._meta?.package?.name !== 'apm') continue; - const eventsToIndex = apmEventsToElasticsearchOutput({ - events: [ - ...events, - ...getTransactionMetrics(events), - ...getSpanDestinationMetrics(events), - ...getBreakdownMetrics(events), - ], - writeTargets, - }); + componentTemplate.component_template.template.settings = { + index: { + number_of_shards: numberOfPrimaryShards, + }, + }; - await uploadEvents({ - batchSize: 1000, - client: this.client, - clientWorkers: 5, - events: eventsToIndex, - logger: this.logger, + const putTemplate = await this.client.cluster.putComponentTemplate({ + name: componentTemplate.name, + ...componentTemplate.component_template, + }); + this.logger.info( + `- updated component template ${componentTemplate.name}, acknowledged: ${putTemplate.acknowledged}` + ); + } + } + + async index(events: SpanIterable | SpanIterable[], options?: StreamToBulkOptions) { + const dataStream = Array.isArray(events) ? new SpanGeneratorsUnion(events) : events; + + const writeTargets = await this.getWriteTargets(); + // TODO logger.perf + await this.client.helpers.bulk({ + concurrency: options?.concurrency ?? 10, + refresh: false, + refreshOnCompletion: false, + datasource: new StreamProcessor({ + processors: StreamProcessor.apmProcessors, + maxSourceEvents: options?.maxDocs, + logger: this.logger, + }) + // TODO https://github.com/elastic/elasticsearch-js/issues/1610 + // having to map here is awkward, it'd be better to map just before serialization. + .streamToDocumentAsync(StreamProcessor.toDocument, dataStream), + onDrop: (doc) => { + this.logger.info(doc); + }, + // TODO bug in client not passing generic to BulkHelperOptions<> + // https://github.com/elastic/elasticsearch-js/issues/1611 + onDocument: (doc: unknown) => { + const d = doc as Record; + const index = options?.mapToIndex + ? options?.mapToIndex(d) + : this.forceDataStreams + ? StreamProcessor.getDataStreamForEvent(d, writeTargets) + : StreamProcessor.getIndexForEvent(d, writeTargets); + return { create: { _index: index } }; + }, }); + const indices = Object.values(writeTargets); + this.logger.info(`Indexed all data attempting to refresh: ${indices}`); + return this.client.indices.refresh({ - index: Object.values(writeTargets), + index: indices, + allow_no_indices: true, + ignore_unavailable: true, }); } } diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/client/apm_synthtrace_kibana_client.ts b/packages/elastic-apm-synthtrace/src/lib/apm/client/apm_synthtrace_kibana_client.ts new file mode 100644 index 000000000000..6abd3e7633c6 --- /dev/null +++ b/packages/elastic-apm-synthtrace/src/lib/apm/client/apm_synthtrace_kibana_client.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 fetch from 'node-fetch'; +import { Logger } from '../../utils/create_logger'; + +export class ApmSynthtraceKibanaClient { + constructor(private readonly logger: Logger) {} + + async migrateCloudToManagedApm(cloudId: string, username: string, password: string) { + await this.logger.perf('migrate_apm_on_cloud', async () => { + this.logger.info('attempting to migrate cloud instance over to managed APM'); + const cloudUrls = Buffer.from(cloudId.split(':')[1], 'base64').toString().split('$'); + const kibanaCloudUrl = `https://${cloudUrls[2]}.${cloudUrls[0]}`; + const response = await fetch( + kibanaCloudUrl + '/internal/apm/fleet/cloud_apm_package_policy', + { + method: 'POST', // *GET, POST, PUT, DELETE, etc. + headers: { + Authorization: 'Basic ' + Buffer.from(username + ':' + password).toString('base64'), + Accept: 'application/json', + 'Content-Type': 'application/json', + 'kbn-xsrf': 'kibana', + }, + } + ); + const responseJson = await response.json(); + if (responseJson.message) { + this.logger.info(`Cloud Instance already migrated to managed APM: ${responseJson.message}`); + } + if (responseJson.cloudApmPackagePolicy) { + this.logger.info( + `Cloud Instance migrated to managed APM: ${responseJson.cloudApmPackagePolicy.package.version}` + ); + } + }); + } +} diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/index.ts b/packages/elastic-apm-synthtrace/src/lib/apm/index.ts index f020d9a1282e..fcb8e078bf02 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/index.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/index.ts @@ -7,12 +7,10 @@ */ import { service } from './service'; import { browser } from './browser'; -import { getTransactionMetrics } from './utils/get_transaction_metrics'; -import { getSpanDestinationMetrics } from './utils/get_span_destination_metrics'; -import { getObserverDefaults } from './defaults/get_observer_defaults'; +import { getTransactionMetrics } from './processors/get_transaction_metrics'; +import { getSpanDestinationMetrics } from './processors/get_span_destination_metrics'; import { getChromeUserAgentDefaults } from './defaults/get_chrome_user_agent_defaults'; -import { apmEventsToElasticsearchOutput } from './utils/apm_events_to_elasticsearch_output'; -import { getBreakdownMetrics } from './utils/get_breakdown_metrics'; +import { getBreakdownMetrics } from './processors/get_breakdown_metrics'; import { getApmWriteTargets } from './utils/get_apm_write_targets'; import { ApmSynthtraceEsClient } from './client/apm_synthtrace_es_client'; @@ -23,9 +21,7 @@ export const apm = { browser, getTransactionMetrics, getSpanDestinationMetrics, - getObserverDefaults, getChromeUserAgentDefaults, - apmEventsToElasticsearchOutput, getBreakdownMetrics, getApmWriteTargets, ApmSynthtraceEsClient, diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_breakdown_metrics.ts b/packages/elastic-apm-synthtrace/src/lib/apm/processors/get_breakdown_metrics.ts similarity index 96% rename from packages/elastic-apm-synthtrace/src/lib/apm/utils/get_breakdown_metrics.ts rename to packages/elastic-apm-synthtrace/src/lib/apm/processors/get_breakdown_metrics.ts index 4f29a31d5d27..1772b5f65571 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_breakdown_metrics.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/processors/get_breakdown_metrics.ts @@ -8,7 +8,7 @@ import objectHash from 'object-hash'; import { groupBy, pickBy } from 'lodash'; import { ApmFields } from '../apm_fields'; -import { createPicker } from './create_picker'; +import { createPicker } from '../utils/create_picker'; const instanceFields = [ 'container.*', @@ -41,7 +41,10 @@ export function getBreakdownMetrics(events: ApmFields[]) { Object.keys(txWithSpans).forEach((transactionId) => { const txEvents = txWithSpans[transactionId]; - const transaction = txEvents.find((event) => event['processor.event'] === 'transaction')!; + const transaction = txEvents.find((event) => event['processor.event'] === 'transaction'); + if (transaction === undefined) { + return; + } const eventsById: Record = {}; const activityByParentId: Record> = {}; diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_span_destination_metrics.ts b/packages/elastic-apm-synthtrace/src/lib/apm/processors/get_span_destination_metrics.ts similarity index 95% rename from packages/elastic-apm-synthtrace/src/lib/apm/utils/get_span_destination_metrics.ts rename to packages/elastic-apm-synthtrace/src/lib/apm/processors/get_span_destination_metrics.ts index 7adcdaa6ff94..b806948a0949 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_span_destination_metrics.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/processors/get_span_destination_metrics.ts @@ -7,7 +7,7 @@ */ import { ApmFields } from '../apm_fields'; -import { aggregate } from './aggregate'; +import { aggregate } from '../utils/aggregate'; export function getSpanDestinationMetrics(events: ApmFields[]) { const exitSpans = events.filter((event) => !!event['span.destination.service.resource']); diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_transaction_metrics.ts b/packages/elastic-apm-synthtrace/src/lib/apm/processors/get_transaction_metrics.ts similarity index 94% rename from packages/elastic-apm-synthtrace/src/lib/apm/utils/get_transaction_metrics.ts rename to packages/elastic-apm-synthtrace/src/lib/apm/processors/get_transaction_metrics.ts index baa9f57a19a4..c5d8de7d4299 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_transaction_metrics.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/processors/get_transaction_metrics.ts @@ -8,7 +8,7 @@ import { sortBy } from 'lodash'; import { ApmFields } from '../apm_fields'; -import { aggregate } from './aggregate'; +import { aggregate } from '../utils/aggregate'; function sortAndCompressHistogram(histogram?: { values: number[]; counts: number[] }) { return sortBy(histogram?.values).reduce( @@ -34,12 +34,13 @@ export function getTransactionMetrics(events: ApmFields[]) { .map((transaction) => { return { ...transaction, - ['trace.root']: transaction['parent.id'] === undefined, + ['transaction.root']: transaction['parent.id'] === undefined, }; }); const metricsets = aggregate(transactions, [ 'trace.root', + 'transaction.root', 'transaction.name', 'transaction.type', 'event.outcome', @@ -77,7 +78,6 @@ export function getTransactionMetrics(events: ApmFields[]) { histogram.counts.push(1); histogram.values.push(Number(transaction['transaction.duration.us'])); } - return { ...metricset.key, 'metricset.name': 'transaction', diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/utils/aggregate.ts b/packages/elastic-apm-synthtrace/src/lib/apm/utils/aggregate.ts index 505f7452fe5d..0a57debc5922 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/utils/aggregate.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/utils/aggregate.ts @@ -29,7 +29,6 @@ export function aggregate(events: ApmFields[], fields: string[]) { const id = objectHash(key); let metricset = metricsets.get(id); - if (!metricset) { metricset = { key: { ...key, 'processor.event': 'metric', 'processor.name': 'metric' }, @@ -37,7 +36,6 @@ export function aggregate(events: ApmFields[], fields: string[]) { }; metricsets.set(id, metricset); } - metricset.events.push(event); } diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/utils/apm_events_to_elasticsearch_output.ts b/packages/elastic-apm-synthtrace/src/lib/apm/utils/apm_events_to_elasticsearch_output.ts deleted file mode 100644 index 46456098df4a..000000000000 --- a/packages/elastic-apm-synthtrace/src/lib/apm/utils/apm_events_to_elasticsearch_output.ts +++ /dev/null @@ -1,60 +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 { getObserverDefaults } from '../defaults/get_observer_defaults'; -import { ApmFields } from '../apm_fields'; -import { dedot } from '../../utils/dedot'; -import { ElasticsearchOutput } from '../../utils/to_elasticsearch_output'; - -export interface ApmElasticsearchOutputWriteTargets { - transaction: string; - span: string; - error: string; - metric: string; -} - -const observerDefaults = getObserverDefaults(); - -const esDocumentDefaults = { - ecs: { - version: '1.4', - }, -}; - -dedot(observerDefaults, esDocumentDefaults); - -export function apmEventsToElasticsearchOutput({ - events, - writeTargets, -}: { - events: ApmFields[]; - writeTargets: ApmElasticsearchOutputWriteTargets; -}): ElasticsearchOutput[] { - return events.map((event) => { - const values = {}; - - Object.assign(values, event, { - '@timestamp': new Date(event['@timestamp']!).toISOString(), - 'timestamp.us': event['@timestamp']! * 1000, - 'service.node.name': - event['service.node.name'] || event['container.id'] || event['host.name'], - }); - - const document = {}; - - Object.assign(document, esDocumentDefaults); - - dedot(values, document); - - return { - _index: writeTargets[event['processor.event'] as keyof ApmElasticsearchOutputWriteTargets], - _source: document, - timestamp: event['@timestamp']!, - }; - }); -} diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_apm_write_targets.ts b/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_apm_write_targets.ts index f040ca46a9db..ec2e675d415f 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_apm_write_targets.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_apm_write_targets.ts @@ -7,13 +7,32 @@ */ import { Client } from '@elastic/elasticsearch'; -import { ApmElasticsearchOutputWriteTargets } from './apm_events_to_elasticsearch_output'; + +export interface ApmElasticsearchOutputWriteTargets { + transaction: string; + span: string; + error: string; + metric: string; + app_metric: string; +} export async function getApmWriteTargets({ client, + forceDataStreams, }: { client: Client; + forceDataStreams?: boolean; }): Promise { + if (forceDataStreams) { + return { + transaction: 'traces-apm-default', + span: 'traces-apm-default', + metric: 'metrics-apm.internal-default', + app_metric: 'metrics-apm.app-default', + error: 'logs-apm.error-default', + }; + } + const [indicesResponse, datastreamsResponse] = await Promise.all([ client.indices.getAlias({ index: 'apm-*', @@ -40,11 +59,12 @@ export async function getApmWriteTargets({ .find(({ key, writeIndexAlias }) => writeIndexAlias && key.includes(filter)) ?.writeIndexAlias!; } - + const metricsTarget = getDataStreamName('metrics-apm') || getAlias('-metric'); const targets = { transaction: getDataStreamName('traces-apm') || getAlias('-transaction'), span: getDataStreamName('traces-apm') || getAlias('-span'), - metric: getDataStreamName('metrics-apm') || getAlias('-metric'), + metric: metricsTarget, + app_metric: metricsTarget, error: getDataStreamName('logs-apm') || getAlias('-error'), }; diff --git a/packages/elastic-apm-synthtrace/src/lib/interval.ts b/packages/elastic-apm-synthtrace/src/lib/interval.ts index bafd1a06c534..fc31d0290968 100644 --- a/packages/elastic-apm-synthtrace/src/lib/interval.ts +++ b/packages/elastic-apm-synthtrace/src/lib/interval.ts @@ -6,27 +6,69 @@ * Side Public License, v 1. */ import moment from 'moment'; +import { ApmFields } from './apm/apm_fields'; +import { SpanIterable } from './span_iterable'; +import { SpanGenerator } from './span_generator'; -export class Interval { +export function parseInterval(interval: string): [number, string] { + const args = interval.match(/(\d+)(s|m|h|d)/); + if (!args || args.length < 3) { + throw new Error('Failed to parse interval'); + } + return [Number(args[1]), args[2] as any]; +} + +export class Interval implements Iterable { constructor( - private readonly from: number, - private readonly to: number, - private readonly interval: string - ) {} + public readonly from: Date, + public readonly to: Date, + public readonly interval: string, + public readonly yieldRate: number = 1 + ) { + [this.intervalAmount, this.intervalUnit] = parseInterval(interval); + } - rate(rate: number) { - let now = this.from; - const args = this.interval.match(/(.*)(s|m|h|d)/); - if (!args) { - throw new Error('Failed to parse interval'); - } - const timestamps: number[] = []; - while (now < this.to) { - timestamps.push(...new Array(rate).fill(now)); - now = moment(now) - .add(Number(args[1]), args[2] as any) - .valueOf(); + private readonly intervalAmount: number; + private readonly intervalUnit: any; + + spans(map: (timestamp: number, index?: number) => ApmFields[]): SpanIterable { + return new SpanGenerator(this, [ + function* (i) { + let index = 0; + for (const x of i) { + for (const a of map(x, index)) { + yield a; + index++; + } + } + }, + ]); + } + + rate(rate: number): Interval { + return new Interval(this.from, this.to, this.interval, rate); + } + private yieldRateTimestamps(timestamp: number) { + return new Array(this.yieldRate).fill(timestamp); + } + + private *_generate(): Iterable { + if (this.from > this.to) { + let now = this.from; + do { + yield* this.yieldRateTimestamps(now.getTime()); + now = new Date(moment(now).subtract(this.intervalAmount, this.intervalUnit).valueOf()); + } while (now > this.to); + } else { + let now = this.from; + do { + yield* this.yieldRateTimestamps(now.getTime()); + now = new Date(moment(now).add(this.intervalAmount, this.intervalUnit).valueOf()); + } while (now < this.to); } - return timestamps; + } + + [Symbol.iterator]() { + return this._generate()[Symbol.iterator](); } } diff --git a/packages/elastic-apm-synthtrace/src/lib/serializable.ts b/packages/elastic-apm-synthtrace/src/lib/serializable.ts index e9ffe3ae9699..1211098519da 100644 --- a/packages/elastic-apm-synthtrace/src/lib/serializable.ts +++ b/packages/elastic-apm-synthtrace/src/lib/serializable.ts @@ -15,7 +15,7 @@ export class Serializable extends Entity { }); } - timestamp(time: number) { + timestamp(time: number): this { this.fields['@timestamp'] = time; return this; } diff --git a/packages/elastic-apm-synthtrace/src/lib/span_generator.ts b/packages/elastic-apm-synthtrace/src/lib/span_generator.ts new file mode 100644 index 000000000000..aa043e982b50 --- /dev/null +++ b/packages/elastic-apm-synthtrace/src/lib/span_generator.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Interval } from './interval'; +import { ApmFields } from './apm/apm_fields'; +import { SpanGeneratorsUnion } from './span_generators_union'; +import { SpanIterable } from './span_iterable'; + +export class SpanGenerator implements SpanIterable { + constructor( + private readonly interval: Interval, + private readonly dataGenerator: Array<(interval: Interval) => Generator> + ) { + this._order = interval.from > interval.to ? 'desc' : 'asc'; + } + + private readonly _order: 'desc' | 'asc'; + order() { + return this._order; + } + + toArray(): ApmFields[] { + return Array.from(this); + } + + concat(...iterables: SpanGenerator[]) { + return new SpanGeneratorsUnion([this, ...iterables]); + } + + *[Symbol.iterator]() { + for (const iterator of this.dataGenerator) { + for (const fields of iterator(this.interval)) { + yield fields; + } + } + } + + async *[Symbol.asyncIterator]() { + for (const iterator of this.dataGenerator) { + for (const fields of iterator(this.interval)) { + yield fields; + } + } + } +} diff --git a/packages/elastic-apm-synthtrace/src/lib/span_generators_union.ts b/packages/elastic-apm-synthtrace/src/lib/span_generators_union.ts new file mode 100644 index 000000000000..9bbea307276c --- /dev/null +++ b/packages/elastic-apm-synthtrace/src/lib/span_generators_union.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { ApmFields } from './apm/apm_fields'; +import { SpanIterable } from './span_iterable'; +import { merge } from './utils/merge_iterable'; + +export class SpanGeneratorsUnion implements SpanIterable { + constructor(private readonly dataGenerators: SpanIterable[]) { + const orders = new Set<'desc' | 'asc'>(dataGenerators.map((d) => d.order())); + if (orders.size > 1) throw Error('Can only combine intervals with the same order()'); + this._order = orders.has('asc') ? 'asc' : 'desc'; + } + + static empty: SpanGeneratorsUnion = new SpanGeneratorsUnion([]); + + private readonly _order: 'desc' | 'asc'; + order() { + return this._order; + } + + toArray(): ApmFields[] { + return Array.from(this); + } + + concat(...iterables: SpanIterable[]) { + return new SpanGeneratorsUnion([...this.dataGenerators, ...iterables]); + } + + *[Symbol.iterator]() { + const iterator = merge(this.dataGenerators); + for (const fields of iterator) { + yield fields; + } + } + + async *[Symbol.asyncIterator]() { + for (const iterator of this.dataGenerators) { + for (const fields of iterator) { + yield fields; + } + } + } +} diff --git a/packages/elastic-apm-synthtrace/src/lib/span_iterable.ts b/packages/elastic-apm-synthtrace/src/lib/span_iterable.ts new file mode 100644 index 000000000000..f40658feba1f --- /dev/null +++ b/packages/elastic-apm-synthtrace/src/lib/span_iterable.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 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 { ApmFields } from './apm/apm_fields'; +import { SpanGeneratorsUnion } from './span_generators_union'; + +export interface SpanIterable extends Iterable, AsyncIterable { + order(): 'desc' | 'asc'; + + toArray(): ApmFields[]; + + concat(...iterables: SpanIterable[]): SpanGeneratorsUnion; +} + +export class SpanArrayIterable implements SpanIterable { + constructor(private fields: ApmFields[]) { + const timestamps = fields.filter((f) => f['@timestamp']).map((f) => f['@timestamp']!); + this._order = timestamps.length > 1 ? (timestamps[0] > timestamps[1] ? 'desc' : 'asc') : 'asc'; + } + + private readonly _order: 'desc' | 'asc'; + order() { + return this._order; + } + + async *[Symbol.asyncIterator](): AsyncIterator { + return this.fields[Symbol.iterator](); + } + + [Symbol.iterator](): Iterator { + return this.fields[Symbol.iterator](); + } + + concat(...iterables: SpanIterable[]): SpanGeneratorsUnion { + return new SpanGeneratorsUnion([this, ...iterables]); + } + + toArray(): ApmFields[] { + return this.fields; + } +} diff --git a/packages/elastic-apm-synthtrace/src/lib/stack_monitoring/cluster_stats.ts b/packages/elastic-apm-synthtrace/src/lib/stack_monitoring/cluster_stats.ts index 0995013cbcbb..1505303e6b83 100644 --- a/packages/elastic-apm-synthtrace/src/lib/stack_monitoring/cluster_stats.ts +++ b/packages/elastic-apm-synthtrace/src/lib/stack_monitoring/cluster_stats.ts @@ -17,13 +17,13 @@ export class ClusterStats extends Serializable { this.fields['license.status'] = 'active'; } - timestamp(timestamp: number) { + timestamp(timestamp: number): this { super.timestamp(timestamp); this.fields['cluster_stats.timestamp'] = new Date(timestamp).toISOString(); return this; } - indices(count: number) { + indices(count: number): this { this.fields['cluster_stats.indices.count'] = count; return this; } diff --git a/packages/elastic-apm-synthtrace/src/lib/stack_monitoring/kibana_stats.ts b/packages/elastic-apm-synthtrace/src/lib/stack_monitoring/kibana_stats.ts index 495e5f013600..96df653119fd 100644 --- a/packages/elastic-apm-synthtrace/src/lib/stack_monitoring/kibana_stats.ts +++ b/packages/elastic-apm-synthtrace/src/lib/stack_monitoring/kibana_stats.ts @@ -10,15 +10,15 @@ import { Serializable } from '../serializable'; import { StackMonitoringFields } from './stack_monitoring_fields'; export class KibanaStats extends Serializable { - timestamp(timestamp: number) { - super.timestamp(timestamp); + timestamp(timestamp: number): this { this.fields['kibana_stats.timestamp'] = new Date(timestamp).toISOString(); this.fields['kibana_stats.response_times.max'] = 250; this.fields['kibana_stats.kibana.status'] = 'green'; + this.fields.timestamp = timestamp; return this; } - requests(disconnects: number, total: number) { + requests(disconnects: number, total: number): this { this.fields['kibana_stats.requests.disconnects'] = disconnects; this.fields['kibana_stats.requests.total'] = total; return this; diff --git a/packages/elastic-apm-synthtrace/src/lib/stack_monitoring/stack_monitoring_fields.ts b/packages/elastic-apm-synthtrace/src/lib/stack_monitoring/stack_monitoring_fields.ts index 3e80d1e9f733..1d79b9b01411 100644 --- a/packages/elastic-apm-synthtrace/src/lib/stack_monitoring/stack_monitoring_fields.ts +++ b/packages/elastic-apm-synthtrace/src/lib/stack_monitoring/stack_monitoring_fields.ts @@ -26,4 +26,5 @@ export type StackMonitoringFields = Fields & 'kibana_stats.requests.total': number; 'kibana_stats.timestamp': string; 'kibana_stats.response_times.max': number; + timestamp: number; }>; diff --git a/packages/elastic-apm-synthtrace/src/lib/stream_processor.ts b/packages/elastic-apm-synthtrace/src/lib/stream_processor.ts new file mode 100644 index 000000000000..35cb8d05582d --- /dev/null +++ b/packages/elastic-apm-synthtrace/src/lib/stream_processor.ts @@ -0,0 +1,207 @@ +/* + * Copyright 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 moment from 'moment'; +import { ApmFields } from './apm/apm_fields'; +import { SpanIterable } from './span_iterable'; +import { getTransactionMetrics } from './apm/processors/get_transaction_metrics'; +import { getSpanDestinationMetrics } from './apm/processors/get_span_destination_metrics'; +import { getBreakdownMetrics } from './apm/processors/get_breakdown_metrics'; +import { parseInterval } from './interval'; +import { dedot } from './utils/dedot'; +import { ApmElasticsearchOutputWriteTargets } from './apm/utils/get_apm_write_targets'; +import { Logger } from './utils/create_logger'; + +export interface StreamProcessorOptions { + processors: Array<(events: ApmFields[]) => ApmFields[]>; + flushInterval?: string; + maxBufferSize?: number; + // the maximum source events to process, not the maximum documents outputted by the processor + maxSourceEvents?: number; + logger?: Logger; +} + +export class StreamProcessor { + public static readonly apmProcessors = [ + getTransactionMetrics, + getSpanDestinationMetrics, + getBreakdownMetrics, + ]; + + constructor(private readonly options: StreamProcessorOptions) { + [this.intervalAmount, this.intervalUnit] = this.options.flushInterval + ? parseInterval(this.options.flushInterval) + : parseInterval('1m'); + } + private readonly intervalAmount: number; + private readonly intervalUnit: any; + + // TODO move away from chunking and feed this data one by one to processors + *stream(...eventSources: SpanIterable[]) { + const maxBufferSize = this.options.maxBufferSize ?? 10000; + const maxSourceEvents = this.options.maxSourceEvents; + let localBuffer = []; + let flushAfter: number | null = null; + let sourceEventsYielded = 0; + for (const eventSource of eventSources) { + const order = eventSource.order(); + this.options.logger?.info(`order: ${order}`); + for (const event of eventSource) { + const eventDate = event['@timestamp'] as number; + localBuffer.push(event); + if (flushAfter === null && eventDate !== null) { + flushAfter = this.calculateFlushAfter(eventDate, order); + } + + yield StreamProcessor.enrich(event); + sourceEventsYielded++; + if (maxSourceEvents && sourceEventsYielded % (maxSourceEvents / 10) === 0) { + this.options.logger?.info(`Yielded ${sourceEventsYielded} events`); + } + if (maxSourceEvents && sourceEventsYielded >= maxSourceEvents) { + // yielded the maximum source events, we still want the local buffer to generate derivative documents + break; + } + if ( + localBuffer.length === maxBufferSize || + (flushAfter != null && + ((order === 'asc' && eventDate > flushAfter) || + (order === 'desc' && eventDate < flushAfter))) + ) { + const e = new Date(eventDate).toISOString(); + const f = new Date(flushAfter!).toISOString(); + this.options.logger?.debug( + `flush ${localBuffer.length} documents ${order}: ${e} => ${f}` + ); + for (const processor of this.options.processors) { + yield* processor(localBuffer).map(StreamProcessor.enrich); + } + localBuffer = []; + flushAfter = this.calculateFlushAfter(flushAfter, order); + } + } + if (maxSourceEvents && sourceEventsYielded >= maxSourceEvents) { + this.options.logger?.info(`Yielded maximum number of documents: ${maxSourceEvents}`); + break; + } + } + if (localBuffer.length > 0) { + this.options.logger?.info(`Processing remaining buffer: ${localBuffer.length} items left`); + for (const processor of this.options.processors) { + yield* processor(localBuffer).map(StreamProcessor.enrich); + } + } + } + + private calculateFlushAfter(eventDate: number | null, order: 'asc' | 'desc') { + if (order === 'desc') { + return moment(eventDate).subtract(this.intervalAmount, this.intervalUnit).valueOf(); + } else { + return moment(eventDate).add(this.intervalAmount, this.intervalUnit).valueOf(); + } + } + + async *streamAsync(...eventSources: SpanIterable[]): AsyncIterator { + yield* this.stream(...eventSources); + } + *streamToDocument( + map: (d: ApmFields) => TDocument, + ...eventSources: SpanIterable[] + ): Generator { + for (const apmFields of this.stream(...eventSources)) { + yield map(apmFields); + } + } + async *streamToDocumentAsync( + map: (d: ApmFields) => TDocument, + ...eventSources: SpanIterable[] + ): AsyncIterator { + for (const apmFields of this.stream(...eventSources)) { + yield map(apmFields); + } + } + streamToArray(...eventSources: SpanIterable[]) { + return Array.from(this.stream(...eventSources)); + } + + static enrich(document: ApmFields): ApmFields { + // see https://github.com/elastic/apm-server/issues/7088 can not be provided as flat key/values + document.observer = { + version: '8.0.0', + version_major: 8, + }; + document['service.node.name'] = + document['service.node.name'] || document['container.id'] || document['host.name']; + document['ecs.version'] = '1.4'; + // TODO this non standard field should not be enriched here + if (document['processor.event'] !== 'metric') { + document['timestamp.us'] = document['@timestamp']! * 1000; + } + return document; + } + + static toDocument(document: ApmFields): Record { + if (!document.observer) { + document = StreamProcessor.enrich(document); + } + const newDoc: Record = {}; + dedot(document, newDoc); + if (typeof newDoc['@timestamp'] === 'number') { + const timestamp = newDoc['@timestamp']; + newDoc['@timestamp'] = new Date(timestamp).toISOString(); + } + return newDoc; + } + + static getDataStreamForEvent( + d: Record, + writeTargets: ApmElasticsearchOutputWriteTargets + ) { + if (!d.processor?.event) { + throw Error("'processor.event' is not set on document, can not determine target index"); + } + const eventType = d.processor.event as keyof ApmElasticsearchOutputWriteTargets; + let dataStream = writeTargets[eventType]; + if (eventType === 'metric') { + if (!d.service?.name) { + dataStream = 'metrics-apm.app-default'; + } else { + if (!d.transaction && !d.span) { + dataStream = 'metrics-apm.app-default'; + } + } + } + return dataStream; + } + + static getIndexForEvent( + d: Record, + writeTargets: ApmElasticsearchOutputWriteTargets + ) { + if (!d.processor?.event) { + throw Error("'processor.event' is not set on document, can not determine target index"); + } + + const eventType = d.processor.event as keyof ApmElasticsearchOutputWriteTargets; + return writeTargets[eventType]; + } +} + +export async function* streamProcessAsync( + processors: Array<(events: ApmFields[]) => ApmFields[]>, + ...eventSources: SpanIterable[] +) { + return new StreamProcessor({ processors }).streamAsync(...eventSources); +} + +export function streamProcessToArray( + processors: Array<(events: ApmFields[]) => ApmFields[]>, + ...eventSources: SpanIterable[] +) { + return new StreamProcessor({ processors }).streamToArray(...eventSources); +} diff --git a/packages/elastic-apm-synthtrace/src/lib/timerange.ts b/packages/elastic-apm-synthtrace/src/lib/timerange.ts index 14111ad7b849..95e998e9ca20 100644 --- a/packages/elastic-apm-synthtrace/src/lib/timerange.ts +++ b/packages/elastic-apm-synthtrace/src/lib/timerange.ts @@ -9,13 +9,16 @@ import { Interval } from './interval'; export class Timerange { - constructor(private from: number, private to: number) {} + constructor(private from: Date, private to: Date) {} interval(interval: string) { return new Interval(this.from, this.to, interval); } } -export function timerange(from: number, to: number) { - return new Timerange(from, to); +export function timerange(from: Date | number, to: Date | number) { + return new Timerange( + from instanceof Date ? from : new Date(from), + to instanceof Date ? to : new Date(to) + ); } diff --git a/packages/elastic-apm-synthtrace/src/lib/utils/merge_iterable.ts b/packages/elastic-apm-synthtrace/src/lib/utils/merge_iterable.ts new file mode 100644 index 000000000000..415aa7eccfa1 --- /dev/null +++ b/packages/elastic-apm-synthtrace/src/lib/utils/merge_iterable.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { ApmFields } from '../apm/apm_fields'; +import { SpanIterable } from '../span_iterable'; + +export function merge(iterables: SpanIterable[]): Iterable { + if (iterables.length === 1) return iterables[0]; + + const iterators = iterables.map>((i) => { + return i[Symbol.iterator](); + }); + let done = false; + const myIterable: Iterable = { + *[Symbol.iterator]() { + do { + const items = iterators.map((i) => i.next()); + done = items.every((item) => item.done); + if (!done) { + yield* items.filter((i) => !i.done).map((i) => i.value); + } + } while (!done); + // Done for the first time: close all iterators + for (const iterator of iterators) { + if (typeof iterator.return === 'function') { + iterator.return(); + } + } + }, + }; + return myIterable; +} diff --git a/packages/elastic-apm-synthtrace/src/lib/utils/to_elasticsearch_output.ts b/packages/elastic-apm-synthtrace/src/lib/utils/to_elasticsearch_output.ts deleted file mode 100644 index 58bafffaff69..000000000000 --- a/packages/elastic-apm-synthtrace/src/lib/utils/to_elasticsearch_output.ts +++ /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 { Fields } from '../entity'; -import { dedot } from './dedot'; - -export interface ElasticsearchOutput { - _index: string; - _source: unknown; - timestamp: number; -} - -export function eventsToElasticsearchOutput({ - events, - writeTarget, -}: { - events: Fields[]; - writeTarget: string; -}): ElasticsearchOutput[] { - return events.map((event) => { - const values = {}; - - const timestamp = event['@timestamp']!; - - Object.assign(values, event, { - '@timestamp': new Date(timestamp).toISOString(), - }); - - const document = {}; - - dedot(values, document); - - return { - _index: writeTarget, - _source: document, - timestamp, - }; - }); -} diff --git a/packages/elastic-apm-synthtrace/src/scripts/examples/01_simple_trace.ts b/packages/elastic-apm-synthtrace/src/scripts/examples/01_simple_trace.ts index 4ea1af15f43e..b8792f6e1753 100644 --- a/packages/elastic-apm-synthtrace/src/scripts/examples/01_simple_trace.ts +++ b/packages/elastic-apm-synthtrace/src/scripts/examples/01_simple_trace.ts @@ -7,16 +7,15 @@ */ import { apm, timerange } from '../../index'; -import { apmEventsToElasticsearchOutput } from '../../lib/apm/utils/apm_events_to_elasticsearch_output'; -import { getApmWriteTargets } from '../../lib/apm/utils/get_apm_write_targets'; +import { Instance } from '../../lib/apm/instance'; import { Scenario } from '../scenario'; import { getCommonServices } from '../utils/get_common_services'; +import { RunOptions } from '../utils/parse_run_cli_flags'; -const scenario: Scenario = async ({ target, logLevel, scenarioOpts }) => { - const { client, logger } = getCommonServices({ target, logLevel }); - const writeTargets = await getApmWriteTargets({ client }); +const scenario: Scenario = async (runOptions: RunOptions) => { + const { logger } = getCommonServices(runOptions); - const { numServices = 3 } = scenarioOpts || {}; + const { numServices = 3 } = runOptions.scenarioOpts || {}; return { generate: ({ from, to }) => { @@ -28,79 +27,65 @@ const scenario: Scenario = async ({ target, logLevel, scenarioOpts }) => { const failedTimestamps = range.interval('1s').rate(1); - return new Array(numServices).fill(undefined).flatMap((_, index) => { - const events = logger.perf('generating_apm_events', () => { - const instance = apm - .service(`opbeans-go-${index}`, 'production', 'go') - .instance('instance'); + const instances = [...Array(numServices).keys()].map((index) => + apm.service(`opbeans-go-${index}`, 'production', 'go').instance('instance') + ); + const instanceSpans = (instance: Instance) => { + const successfulTraceEvents = successfulTimestamps.spans((timestamp) => + instance + .transaction(transactionName) + .timestamp(timestamp) + .duration(1000) + .success() + .children( + instance + .span('GET apm-*/_search', 'db', 'elasticsearch') + .duration(1000) + .success() + .destination('elasticsearch') + .timestamp(timestamp), + instance + .span('custom_operation', 'custom') + .duration(100) + .success() + .timestamp(timestamp) + ) + .serialize() + ); - const successfulTraceEvents = successfulTimestamps.flatMap((timestamp) => - instance - .transaction(transactionName) - .timestamp(timestamp) - .duration(1000) - .success() - .children( - instance - .span('GET apm-*/_search', 'db', 'elasticsearch') - .duration(1000) - .success() - .destination('elasticsearch') - .timestamp(timestamp), - instance - .span('custom_operation', 'custom') - .duration(100) - .success() - .timestamp(timestamp) - ) - .serialize() - ); + const failedTraceEvents = failedTimestamps.spans((timestamp) => + instance + .transaction(transactionName) + .timestamp(timestamp) + .duration(1000) + .failure() + .errors( + instance.error('[ResponseError] index_not_found_exception').timestamp(timestamp + 50) + ) + .serialize() + ); - const failedTraceEvents = failedTimestamps.flatMap((timestamp) => + const metricsets = range + .interval('30s') + .rate(1) + .spans((timestamp) => instance - .transaction(transactionName) + .appMetrics({ + 'system.memory.actual.free': 800, + 'system.memory.total': 1000, + 'system.cpu.total.norm.pct': 0.6, + 'system.process.cpu.total.norm.pct': 0.7, + }) .timestamp(timestamp) - .duration(1000) - .failure() - .errors( - instance - .error('[ResponseError] index_not_found_exception') - .timestamp(timestamp + 50) - ) .serialize() ); - const metricsets = range - .interval('30s') - .rate(1) - .flatMap((timestamp) => - instance - .appMetrics({ - 'system.memory.actual.free': 800, - 'system.memory.total': 1000, - 'system.cpu.total.norm.pct': 0.6, - 'system.process.cpu.total.norm.pct': 0.7, - }) - .timestamp(timestamp) - .serialize() - ); - return [...successfulTraceEvents, ...failedTraceEvents, ...metricsets]; - }); + return successfulTraceEvents.concat(failedTraceEvents, metricsets); + }; - return logger.perf('apm_events_to_es_output', () => - apmEventsToElasticsearchOutput({ - events: [ - ...events, - ...logger.perf('get_transaction_metrics', () => apm.getTransactionMetrics(events)), - ...logger.perf('get_span_destination_metrics', () => - apm.getSpanDestinationMetrics(events) - ), - ...logger.perf('get_breakdown_metrics', () => apm.getBreakdownMetrics(events)), - ], - writeTargets, - }) - ); - }); + return instances + .map((instance) => logger.perf('generating_apm_events', () => instanceSpans(instance))) + .reduce((p, c) => p.concat(c)); }, }; }; diff --git a/packages/elastic-apm-synthtrace/src/scripts/examples/02_kibana_stats.ts b/packages/elastic-apm-synthtrace/src/scripts/examples/02_kibana_stats.ts index 2ba3c4a29c52..a19f0454e344 100644 --- a/packages/elastic-apm-synthtrace/src/scripts/examples/02_kibana_stats.ts +++ b/packages/elastic-apm-synthtrace/src/scripts/examples/02_kibana_stats.ts @@ -7,14 +7,14 @@ */ import { stackMonitoring, timerange } from '../../index'; -import { eventsToElasticsearchOutput } from '../../lib/utils/to_elasticsearch_output'; import { Scenario } from '../scenario'; import { getCommonServices } from '../utils/get_common_services'; +import { RunOptions } from '../utils/parse_run_cli_flags'; -const scenario: Scenario = async ({ target, writeTarget, logLevel }) => { - const { logger } = getCommonServices({ target, logLevel }); +const scenario: Scenario = async (runOptions: RunOptions) => { + const { logger } = getCommonServices(runOptions); - if (!writeTarget) { + if (!runOptions.writeTarget) { throw new Error('Write target is not defined'); } @@ -26,20 +26,11 @@ const scenario: Scenario = async ({ target, writeTarget, logLevel }) => { return range .interval('30s') .rate(1) - .flatMap((timestamp) => { + .spans((timestamp) => { const events = logger.perf('generating_sm_events', () => { return kibanaStats.timestamp(timestamp).requests(10, 20).serialize(); }); - - return logger.perf('sm_events_to_es_output', () => { - const smEvents = eventsToElasticsearchOutput({ events, writeTarget }); - smEvents.forEach((event: any) => { - const ts = event._source['@timestamp']; - delete event._source['@timestamp']; - event._source.timestamp = ts; - }); - return smEvents; - }); + return events; }); }, }; diff --git a/packages/elastic-apm-synthtrace/src/scripts/examples/03_monitoring.ts b/packages/elastic-apm-synthtrace/src/scripts/examples/03_monitoring.ts index 53dcd820f551..cd98cf6ca063 100644 --- a/packages/elastic-apm-synthtrace/src/scripts/examples/03_monitoring.ts +++ b/packages/elastic-apm-synthtrace/src/scripts/examples/03_monitoring.ts @@ -9,32 +9,19 @@ // Run with: node ./src/scripts/run ./src/scripts/examples/03_monitoring.ts --target=http://elastic:changeme@localhost:9200 import { stackMonitoring, timerange } from '../../index'; -import { - ElasticsearchOutput, - eventsToElasticsearchOutput, -} from '../../lib/utils/to_elasticsearch_output'; import { Scenario } from '../scenario'; import { getCommonServices } from '../utils/get_common_services'; -import { StackMonitoringFields } from '../../lib/stack_monitoring/stack_monitoring_fields'; +import { RunOptions } from '../utils/parse_run_cli_flags'; -// TODO (mat): move this into a function like utils/apm_events_to_elasticsearch_output.ts -function smEventsToElasticsearchOutput( - events: StackMonitoringFields[], - writeTarget: string -): ElasticsearchOutput[] { - const smEvents = eventsToElasticsearchOutput({ events, writeTarget }); - smEvents.forEach((event: any) => { - const ts = event._source['@timestamp']; - delete event._source['@timestamp']; - event._source.timestamp = ts; - }); - return smEvents; -} - -const scenario: Scenario = async ({ target, logLevel }) => { - const { logger } = getCommonServices({ target, logLevel }); +const scenario: Scenario = async (runOptions: RunOptions) => { + const { logger } = getCommonServices(runOptions); return { + mapToIndex: (data) => { + return data.kibana_stats?.kibana?.name + ? '.monitoring-kibana-7-synthtrace' + : '.monitoring-es-7-synthtrace'; + }, generate: ({ from, to }) => { const cluster = stackMonitoring.cluster('test-cluster'); const clusterStats = cluster.stats(); @@ -44,24 +31,14 @@ const scenario: Scenario = async ({ target, logLevel }) => { return range .interval('10s') .rate(1) - .flatMap((timestamp) => { + .spans((timestamp) => { const clusterEvents = logger.perf('generating_es_events', () => { return clusterStats.timestamp(timestamp).indices(115).serialize(); }); - const clusterOutputs = smEventsToElasticsearchOutput( - clusterEvents, - '.monitoring-es-7-synthtrace' - ); - const kibanaEvents = logger.perf('generating_kb_events', () => { return kibanaStats.timestamp(timestamp).requests(10, 20).serialize(); }); - const kibanaOutputs = smEventsToElasticsearchOutput( - kibanaEvents, - '.monitoring-kibana-7-synthtrace' - ); - - return [...clusterOutputs, ...kibanaOutputs]; + return [...clusterEvents, ...kibanaEvents]; }); }, }; diff --git a/packages/elastic-apm-synthtrace/src/scripts/run.ts b/packages/elastic-apm-synthtrace/src/scripts/run.ts index 96bef3e958bd..36b3974c1216 100644 --- a/packages/elastic-apm-synthtrace/src/scripts/run.ts +++ b/packages/elastic-apm-synthtrace/src/scripts/run.ts @@ -13,6 +13,8 @@ import { startHistoricalDataUpload } from './utils/start_historical_data_upload' import { startLiveDataUpload } from './utils/start_live_data_upload'; import { parseRunCliFlags } from './utils/parse_run_cli_flags'; import { getCommonServices } from './utils/get_common_services'; +import { ApmSynthtraceKibanaClient } from '../lib/apm/client/apm_synthtrace_kibana_client'; +import { ApmSynthtraceEsClient } from '../lib/apm/client/apm_synthtrace_es_client'; function options(y: Argv) { return y @@ -22,9 +24,23 @@ function options(y: Argv) { string: true, }) .option('target', { - describe: 'Elasticsearch target, including username/password', + describe: 'Elasticsearch target', + string: true, + }) + .option('cloudId', { + describe: + 'Provide connection information and will force APM on the cloud to migrate to run as a Fleet integration', + string: true, + }) + .option('username', { + describe: 'Basic authentication username', + string: true, demandOption: true, + }) + .option('password', { + describe: 'Basic authentication password', string: true, + demandOption: true, }) .option('from', { description: 'The start of the time window', @@ -36,6 +52,20 @@ function options(y: Argv) { description: 'Generate and index data continuously', boolean: true, }) + .option('--dryRun', { + description: 'Enumerates the stream without sending events to Elasticsearch ', + boolean: true, + }) + .option('maxDocs', { + description: + 'The maximum number of documents we are allowed to generate, should be multiple of 10.000', + number: true, + }) + .option('numShards', { + description: + 'Updates the component templates to update the number of primary shards, requires cloudId to be provided', + number: true, + }) .option('clean', { describe: 'Clean APM indices before indexing new data', default: false, @@ -75,7 +105,9 @@ function options(y: Argv) { return arg as Record | undefined; }, }) - .conflicts('to', 'live'); + .conflicts('to', 'live') + .conflicts('maxDocs', 'live') + .conflicts('target', 'cloudId'); } export type RunCliFlags = ReturnType['argv']; @@ -84,38 +116,57 @@ yargs(process.argv.slice(2)) .command('*', 'Generate data and index into Elasticsearch', options, async (argv) => { const runOptions = parseRunCliFlags(argv); - const { logger } = getCommonServices(runOptions); + const { logger, client } = getCommonServices(runOptions); - const to = datemath.parse(String(argv.to ?? 'now'))!.valueOf(); - const from = argv.from + const toMs = datemath.parse(String(argv.to ?? 'now'))!.valueOf(); + const to = new Date(toMs); + const defaultTimeRange = !runOptions.maxDocs ? '15m' : '52w'; + const fromMs = argv.from ? datemath.parse(String(argv.from))!.valueOf() - : to - intervalToMs('15m'); + : toMs - intervalToMs(defaultTimeRange); + const from = new Date(fromMs); const live = argv.live; + const forceDataStreams = !!runOptions.cloudId; + const esClient = new ApmSynthtraceEsClient(client, logger, forceDataStreams); + if (runOptions.dryRun) { + await startHistoricalDataUpload(esClient, logger, runOptions, from, to); + return; + } + if (runOptions.cloudId) { + const kibanaClient = new ApmSynthtraceKibanaClient(logger); + await kibanaClient.migrateCloudToManagedApm( + runOptions.cloudId, + runOptions.username, + runOptions.password + ); + } + + if (runOptions.cloudId && runOptions.numShards && runOptions.numShards > 0) { + await esClient.updateComponentTemplates(runOptions.numShards); + } + + if (argv.clean) { + await esClient.clean(); + } + logger.info( `Starting data generation\n: ${JSON.stringify( { ...runOptions, - from: new Date(from).toISOString(), - to: new Date(to).toISOString(), + from: from.toISOString(), + to: to.toISOString(), }, null, 2 )}` ); - startHistoricalDataUpload({ - ...runOptions, - from, - to, - }); + await startHistoricalDataUpload(esClient, logger, runOptions, from, to); if (live) { - startLiveDataUpload({ - ...runOptions, - start: to, - }); + await startLiveDataUpload(esClient, logger, runOptions, to); } }) .parse(); diff --git a/packages/elastic-apm-synthtrace/src/scripts/scenario.ts b/packages/elastic-apm-synthtrace/src/scripts/scenario.ts index c134c08cd835..089babab0441 100644 --- a/packages/elastic-apm-synthtrace/src/scripts/scenario.ts +++ b/packages/elastic-apm-synthtrace/src/scripts/scenario.ts @@ -6,8 +6,11 @@ * Side Public License, v 1. */ -import { ElasticsearchOutput } from '../lib/utils/to_elasticsearch_output'; import { RunOptions } from './utils/parse_run_cli_flags'; +import { SpanIterable } from '../lib/span_iterable'; -type Generate = (range: { from: number; to: number }) => ElasticsearchOutput[]; -export type Scenario = (options: RunOptions) => Promise<{ generate: Generate }>; +type Generate = (range: { from: Date; to: Date }) => SpanIterable; +export type Scenario = (options: RunOptions) => Promise<{ + generate: Generate; + mapToIndex?: (data: Record) => string; +}>; diff --git a/packages/elastic-apm-synthtrace/src/scripts/utils/get_common_services.ts b/packages/elastic-apm-synthtrace/src/scripts/utils/get_common_services.ts index 0dee6dbc951e..88e5c75e3999 100644 --- a/packages/elastic-apm-synthtrace/src/scripts/utils/get_common_services.ts +++ b/packages/elastic-apm-synthtrace/src/scripts/utils/get_common_services.ts @@ -6,13 +6,21 @@ * Side Public License, v 1. */ -import { Client } from '@elastic/elasticsearch'; -import { createLogger, LogLevel } from '../../lib/utils/create_logger'; +import { Client, ClientOptions } from '@elastic/elasticsearch'; +import { createLogger } from '../../lib/utils/create_logger'; +import { RunOptions } from './parse_run_cli_flags'; -export function getCommonServices({ target, logLevel }: { target: string; logLevel: LogLevel }) { - const client = new Client({ - node: target, - }); +export function getCommonServices({ target, cloudId, username, password, logLevel }: RunOptions) { + if (!target && !cloudId) { + throw Error('target or cloudId needs to be specified'); + } + const options: ClientOptions = !!target ? { node: target } : { cloud: { id: cloudId! } }; + options.auth = { + username, + password, + }; + + const client = new Client(options); const logger = createLogger(logLevel); diff --git a/packages/elastic-apm-synthtrace/src/scripts/utils/parse_run_cli_flags.ts b/packages/elastic-apm-synthtrace/src/scripts/utils/parse_run_cli_flags.ts index 47359bd07aa8..5a8b6f96c631 100644 --- a/packages/elastic-apm-synthtrace/src/scripts/utils/parse_run_cli_flags.ts +++ b/packages/elastic-apm-synthtrace/src/scripts/utils/parse_run_cli_flags.ts @@ -49,12 +49,18 @@ export function parseRunCliFlags(flags: RunCliFlags) { return { ...pick( flags, + 'maxDocs', 'target', + 'cloudId', + 'username', + 'password', 'workers', 'clientWorkers', 'batchSize', 'writeTarget', - 'scenarioOpts' + 'numShards', + 'scenarioOpts', + 'dryRun' ), intervalInMs, bucketSizeInMs, diff --git a/packages/elastic-apm-synthtrace/src/scripts/utils/start_historical_data_upload.ts b/packages/elastic-apm-synthtrace/src/scripts/utils/start_historical_data_upload.ts index ee462085ef79..415292a85e0e 100644 --- a/packages/elastic-apm-synthtrace/src/scripts/utils/start_historical_data_upload.ts +++ b/packages/elastic-apm-synthtrace/src/scripts/utils/start_historical_data_upload.ts @@ -5,101 +5,55 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import pLimit from 'p-limit'; -import Path from 'path'; -import { Worker } from 'worker_threads'; -import { getCommonServices } from './get_common_services'; import { RunOptions } from './parse_run_cli_flags'; -import { WorkerData } from './upload_next_batch'; - -export async function startHistoricalDataUpload({ - from, - to, - intervalInMs, - bucketSizeInMs, - workers, - clientWorkers, - batchSize, - logLevel, - target, - file, - writeTarget, - scenarioOpts, -}: RunOptions & { from: number; to: number }) { - let requestedUntil: number = from; - - const { logger } = getCommonServices({ target, logLevel }); - - function processNextBatch() { - const bucketFrom = requestedUntil; - const bucketTo = Math.min(to, bucketFrom + bucketSizeInMs); - - if (bucketFrom === bucketTo) { - return; - } - - requestedUntil = bucketTo; - - logger.info( - `Starting worker for ${new Date(bucketFrom).toISOString()} to ${new Date( - bucketTo - ).toISOString()}` - ); - - const workerData: WorkerData = { - bucketFrom, - bucketTo, - file, - logLevel, - batchSize, - bucketSizeInMs, - clientWorkers, - intervalInMs, - target, - workers, - writeTarget, - scenarioOpts, - }; - - const worker = new Worker(Path.join(__dirname, './upload_next_batch.js'), { - workerData, - }); - - logger.perf('created_worker', () => { - return new Promise((resolve, reject) => { - worker.on('online', () => { - resolve(); - }); - }); - }); - - logger.perf('completed_worker', () => { - return new Promise((resolve, reject) => { - worker.on('exit', () => { - resolve(); - }); - }); - }); - - return new Promise((resolve, reject) => { - worker.on('error', (err) => { - reject(err); - }); - - worker.on('exit', (code) => { - if (code !== 0) { - reject(new Error(`Worker stopped: exit code ${code}`)); - return; - } - logger.debug('Worker completed'); - resolve(); - }); +import { getScenario } from './get_scenario'; +import { ApmSynthtraceEsClient } from '../../lib/apm'; +import { Logger } from '../../lib/utils/create_logger'; +import { StreamProcessor } from '../../lib/stream_processor'; + +export async function startHistoricalDataUpload( + esClient: ApmSynthtraceEsClient, + logger: Logger, + runOptions: RunOptions, + from: Date, + to: Date +) { + const file = runOptions.file; + const scenario = await logger.perf('get_scenario', () => getScenario({ file, logger })); + + const { generate, mapToIndex } = await scenario(runOptions); + + // if we want to generate a maximum number of documents reverse generation to descend. + [from, to] = runOptions.maxDocs ? [to, from] : [from, to]; + + logger.info(`Generating data from ${from} to ${to}`); + + const events = logger.perf('generate_scenario', () => generate({ from, to })); + + if (runOptions.dryRun) { + const maxDocs = runOptions.maxDocs; + const stream = new StreamProcessor({ + processors: StreamProcessor.apmProcessors, + maxSourceEvents: maxDocs, + logger, + }).streamToDocument(StreamProcessor.toDocument, events); + logger.perf('enumerate_scenario', () => { + // @ts-ignore + // We just want to enumerate + let yielded = 0; + for (const _ of stream) { + yielded++; + } }); + return; } - const numBatches = Math.ceil((to - from) / bucketSizeInMs); - - const limiter = pLimit(workers); - - return Promise.all(new Array(numBatches).fill(undefined).map((_) => limiter(processNextBatch))); + const clientWorkers = runOptions.clientWorkers; + await logger.perf('index_scenario', () => + esClient.index(events, { + concurrency: clientWorkers, + maxDocs: runOptions.maxDocs, + mapToIndex, + }) + ); } diff --git a/packages/elastic-apm-synthtrace/src/scripts/utils/start_live_data_upload.ts b/packages/elastic-apm-synthtrace/src/scripts/utils/start_live_data_upload.ts index ab4eee4f255b..b3e477649878 100644 --- a/packages/elastic-apm-synthtrace/src/scripts/utils/start_live_data_upload.ts +++ b/packages/elastic-apm-synthtrace/src/scripts/utils/start_live_data_upload.ts @@ -8,49 +8,36 @@ import { partition } from 'lodash'; import { getScenario } from './get_scenario'; -import { uploadEvents } from './upload_events'; import { RunOptions } from './parse_run_cli_flags'; -import { getCommonServices } from './get_common_services'; -import { ElasticsearchOutput } from '../../lib/utils/to_elasticsearch_output'; +import { ApmFields } from '../../lib/apm/apm_fields'; +import { ApmSynthtraceEsClient } from '../../lib/apm'; +import { Logger } from '../../lib/utils/create_logger'; +import { SpanArrayIterable } from '../../lib/span_iterable'; -export async function startLiveDataUpload({ - file, - start, - bucketSizeInMs, - intervalInMs, - clientWorkers, - batchSize, - target, - logLevel, - workers, - writeTarget, - scenarioOpts, -}: RunOptions & { start: number }) { - let queuedEvents: ElasticsearchOutput[] = []; - let requestedUntil: number = start; - - const { logger, client } = getCommonServices({ target, logLevel }); +export async function startLiveDataUpload( + esClient: ApmSynthtraceEsClient, + logger: Logger, + runOptions: RunOptions, + start: Date +) { + const file = runOptions.file; const scenario = await getScenario({ file, logger }); - const { generate } = await scenario({ - batchSize, - bucketSizeInMs, - clientWorkers, - file, - intervalInMs, - logLevel, - target, - workers, - writeTarget, - scenarioOpts, - }); + const { generate, mapToIndex } = await scenario(runOptions); + + let queuedEvents: ApmFields[] = []; + let requestedUntil: Date = start; - function uploadNextBatch() { - const end = new Date().getTime(); + async function uploadNextBatch() { + const end = new Date(); if (end > requestedUntil) { const bucketFrom = requestedUntil; - const bucketTo = requestedUntil + bucketSizeInMs; - const nextEvents = generate({ from: bucketFrom, to: bucketTo }); + const bucketTo = new Date(requestedUntil.getTime() + runOptions.bucketSizeInMs); + // TODO this materializes into an array, assumption is that the live buffer will fit in memory + const nextEvents = logger.perf('execute_scenario', () => + generate({ from: bucketFrom, to: bucketTo }).toArray() + ); + logger.debug( `Requesting ${new Date(bucketFrom).toISOString()} to ${new Date( bucketTo @@ -62,23 +49,27 @@ export async function startLiveDataUpload({ const [eventsToUpload, eventsToRemainInQueue] = partition( queuedEvents, - (event) => event.timestamp <= end + (event) => event['@timestamp'] !== undefined && event['@timestamp'] <= end.getTime() ); logger.info(`Uploading until ${new Date(end).toISOString()}, events: ${eventsToUpload.length}`); queuedEvents = eventsToRemainInQueue; - uploadEvents({ - events: eventsToUpload, - clientWorkers, - batchSize, - logger, - client, - }); + await logger.perf('index_live_scenario', () => + esClient.index(new SpanArrayIterable(eventsToUpload), { + concurrency: runOptions.clientWorkers, + maxDocs: runOptions.maxDocs, + mapToIndex, + }) + ); } - setInterval(uploadNextBatch, intervalInMs); - - uploadNextBatch(); + do { + await uploadNextBatch(); + await delay(runOptions.intervalInMs); + } while (true); +} +async function delay(ms: number) { + return await new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/packages/elastic-apm-synthtrace/src/scripts/utils/upload_events.ts b/packages/elastic-apm-synthtrace/src/scripts/utils/upload_events.ts deleted file mode 100644 index d68a1b88132b..000000000000 --- a/packages/elastic-apm-synthtrace/src/scripts/utils/upload_events.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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'; -import { chunk } from 'lodash'; -import pLimit from 'p-limit'; -import { inspect } from 'util'; -import { ElasticsearchOutput } from '../../lib/utils/to_elasticsearch_output'; -import { Logger } from '../../lib/utils/create_logger'; - -export function uploadEvents({ - events, - client, - clientWorkers, - batchSize, - logger, -}: { - events: ElasticsearchOutput[]; - client: Client; - clientWorkers: number; - batchSize: number; - logger: Logger; -}) { - const fn = pLimit(clientWorkers); - - const batches = chunk(events, batchSize); - - if (!batches.length) { - return; - } - - logger.debug(`Uploading ${events.length} in ${batches.length} batches`); - - const time = new Date().getTime(); - - return Promise.all( - batches.map((batch) => - fn(() => { - return logger.perf('bulk_upload', () => - client.bulk({ - refresh: false, - body: batch.flatMap((doc) => { - return [{ index: { _index: doc._index } }, doc._source]; - }), - }) - ); - }) - ) - ).then((results) => { - const errors = results - .flatMap((result) => result.items) - .filter((item) => !!item.index?.error) - .map((item) => item.index?.error); - - if (errors.length) { - logger.error(inspect(errors.slice(0, 10), { depth: null })); - throw new Error('Failed to upload some items'); - } - - logger.debug(`Uploaded ${events.length} in ${new Date().getTime() - time}ms`); - }); -} diff --git a/packages/elastic-apm-synthtrace/src/scripts/utils/upload_next_batch.ts b/packages/elastic-apm-synthtrace/src/scripts/utils/upload_next_batch.ts deleted file mode 100644 index 973cbc2266cb..000000000000 --- a/packages/elastic-apm-synthtrace/src/scripts/utils/upload_next_batch.ts +++ /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. - */ - -// add this to workerExample.js file. -import { Client } from '@elastic/elasticsearch'; -import { workerData } from 'worker_threads'; -import { getScenario } from './get_scenario'; -import { createLogger, LogLevel } from '../../lib/utils/create_logger'; -import { uploadEvents } from './upload_events'; - -export interface WorkerData { - bucketFrom: number; - bucketTo: number; - file: string; - scenarioOpts: Record | undefined; - logLevel: LogLevel; - clientWorkers: number; - batchSize: number; - intervalInMs: number; - bucketSizeInMs: number; - target: string; - workers: number; - writeTarget?: string; -} - -const { - bucketFrom, - bucketTo, - file, - logLevel, - clientWorkers, - batchSize, - intervalInMs, - bucketSizeInMs, - workers, - target, - writeTarget, - scenarioOpts, -} = workerData as WorkerData; - -async function uploadNextBatch() { - if (bucketFrom === bucketTo) { - return; - } - - const logger = createLogger(logLevel); - const client = new Client({ - node: target, - }); - - const scenario = await logger.perf('get_scenario', () => getScenario({ file, logger })); - - const { generate } = await scenario({ - intervalInMs, - bucketSizeInMs, - logLevel, - file, - clientWorkers, - batchSize, - target, - workers, - writeTarget, - scenarioOpts, - }); - - const events = logger.perf('execute_scenario', () => - generate({ from: bucketFrom, to: bucketTo }) - ); - - return uploadEvents({ - events, - client, - clientWorkers, - batchSize, - logger, - }); -} - -uploadNextBatch() - .then(() => { - process.exit(0); - }) - .catch((error) => { - // eslint-disable-next-line - console.log(error); - // make sure error shows up in console before process is killed - setTimeout(() => { - process.exit(1); - }, 100); - }); diff --git a/packages/elastic-apm-synthtrace/src/test/apm_events_to_elasticsearch_output.test.ts b/packages/elastic-apm-synthtrace/src/test/apm_events_to_elasticsearch_output.test.ts index b8d030255892..defdcc73cf6c 100644 --- a/packages/elastic-apm-synthtrace/src/test/apm_events_to_elasticsearch_output.test.ts +++ b/packages/elastic-apm-synthtrace/src/test/apm_events_to_elasticsearch_output.test.ts @@ -6,15 +6,8 @@ * Side Public License, v 1. */ -import { apmEventsToElasticsearchOutput } from '../lib/apm/utils/apm_events_to_elasticsearch_output'; import { ApmFields } from '../lib/apm/apm_fields'; - -const writeTargets = { - transaction: 'apm-8.0.0-transaction', - span: 'apm-8.0.0-span', - metric: 'apm-8.0.0-metric', - error: 'apm-8.0.0-error', -}; +import { StreamProcessor } from '../lib/stream_processor'; describe('output apm events to elasticsearch', () => { let event: ApmFields; @@ -29,32 +22,31 @@ describe('output apm events to elasticsearch', () => { }); it('properly formats @timestamp', () => { - const doc = apmEventsToElasticsearchOutput({ events: [event], writeTargets })[0] as any; - - expect(doc._source['@timestamp']).toEqual('2020-12-31T23:00:00.000Z'); + const doc = StreamProcessor.toDocument(event); + expect(doc['@timestamp']).toEqual('2020-12-31T23:00:00.000Z'); }); it('formats a nested object', () => { - const doc = apmEventsToElasticsearchOutput({ events: [event], writeTargets })[0] as any; + const doc = StreamProcessor.toDocument(event); - expect(doc._source.processor).toEqual({ + expect(doc.processor).toEqual({ event: 'transaction', name: 'transaction', }); }); it('formats all fields consistently', () => { - const doc = apmEventsToElasticsearchOutput({ events: [event], writeTargets })[0] as any; + const doc = StreamProcessor.toDocument(event); - expect(doc._source).toMatchInlineSnapshot(` + expect(doc).toMatchInlineSnapshot(` Object { "@timestamp": "2020-12-31T23:00:00.000Z", "ecs": Object { "version": "1.4", }, "observer": Object { - "version": "7.16.0", - "version_major": 7, + "version": "8.0.0", + "version_major": 8, }, "processor": Object { "event": "transaction", diff --git a/packages/elastic-apm-synthtrace/src/test/scenarios/01_simple_trace.test.ts b/packages/elastic-apm-synthtrace/src/test/scenarios/01_simple_trace.test.ts index a78f1ec987bc..ce42dfc2a8e6 100644 --- a/packages/elastic-apm-synthtrace/src/test/scenarios/01_simple_trace.test.ts +++ b/packages/elastic-apm-synthtrace/src/test/scenarios/01_simple_trace.test.ts @@ -17,14 +17,14 @@ describe('simple trace', () => { const javaInstance = javaService.instance('instance-1'); const range = timerange( - new Date('2021-01-01T00:00:00.000Z').getTime(), - new Date('2021-01-01T00:15:00.000Z').getTime() + new Date('2021-01-01T00:00:00.000Z'), + new Date('2021-01-01T00:15:00.000Z') ); events = range .interval('1m') .rate(1) - .flatMap((timestamp) => + .spans((timestamp) => javaInstance .transaction('GET /api/product/list') .duration(1000) @@ -38,7 +38,8 @@ describe('simple trace', () => { .timestamp(timestamp + 50) ) .serialize() - ); + ) + .toArray(); }); it('generates the same data every time', () => { diff --git a/packages/elastic-apm-synthtrace/src/test/scenarios/02_transaction_metrics.test.ts b/packages/elastic-apm-synthtrace/src/test/scenarios/02_transaction_metrics.test.ts index d074bcbf6c1f..f5a99b3f389d 100644 --- a/packages/elastic-apm-synthtrace/src/test/scenarios/02_transaction_metrics.test.ts +++ b/packages/elastic-apm-synthtrace/src/test/scenarios/02_transaction_metrics.test.ts @@ -8,7 +8,8 @@ import { apm } from '../../lib/apm'; import { timerange } from '../../lib/timerange'; -import { getTransactionMetrics } from '../../lib/apm/utils/get_transaction_metrics'; +import { getTransactionMetrics } from '../../lib/apm/processors/get_transaction_metrics'; +import { StreamProcessor } from '../../lib/stream_processor'; describe('transaction metrics', () => { let events: Array>; @@ -18,36 +19,29 @@ describe('transaction metrics', () => { const javaInstance = javaService.instance('instance-1'); const range = timerange( - new Date('2021-01-01T00:00:00.000Z').getTime(), - new Date('2021-01-01T00:15:00.000Z').getTime() + new Date('2021-01-01T00:00:00.000Z'), + new Date('2021-01-01T00:15:00.000Z') ); - events = getTransactionMetrics( - range - .interval('1m') - .rate(25) - .flatMap((timestamp) => - javaInstance - .transaction('GET /api/product/list') - .duration(1000) - .success() - .timestamp(timestamp) - .serialize() - ) - .concat( - range - .interval('1m') - .rate(50) - .flatMap((timestamp) => - javaInstance - .transaction('GET /api/product/list') - .duration(1000) - .failure() - .timestamp(timestamp) - .serialize() - ) - ) - ); + const span = (timestamp: number) => + javaInstance.transaction('GET /api/product/list').duration(1000).timestamp(timestamp); + + const processor = new StreamProcessor({ + processors: [getTransactionMetrics], + flushInterval: '15m', + }); + events = processor + .streamToArray( + range + .interval('1m') + .rate(25) + .spans((timestamp) => span(timestamp).success().serialize()), + range + .interval('1m') + .rate(50) + .spans((timestamp) => span(timestamp).failure().serialize()) + ) + .filter((fields) => fields['metricset.name'] === 'transaction'); }); it('generates the right amount of transaction metrics', () => { diff --git a/packages/elastic-apm-synthtrace/src/test/scenarios/03_span_destination_metrics.test.ts b/packages/elastic-apm-synthtrace/src/test/scenarios/03_span_destination_metrics.test.ts index fe4734c65739..b81dea452206 100644 --- a/packages/elastic-apm-synthtrace/src/test/scenarios/03_span_destination_metrics.test.ts +++ b/packages/elastic-apm-synthtrace/src/test/scenarios/03_span_destination_metrics.test.ts @@ -8,7 +8,8 @@ import { apm } from '../../lib/apm'; import { timerange } from '../../lib/timerange'; -import { getSpanDestinationMetrics } from '../../lib/apm/utils/get_span_destination_metrics'; +import { getSpanDestinationMetrics } from '../../lib/apm/processors/get_span_destination_metrics'; +import { StreamProcessor } from '../../lib/stream_processor'; describe('span destination metrics', () => { let events: Array>; @@ -18,57 +19,57 @@ describe('span destination metrics', () => { const javaInstance = javaService.instance('instance-1'); const range = timerange( - new Date('2021-01-01T00:00:00.000Z').getTime(), - new Date('2021-01-01T00:15:00.000Z').getTime() - ); - - events = getSpanDestinationMetrics( - range - .interval('1m') - .rate(25) - .flatMap((timestamp) => - javaInstance - .transaction('GET /api/product/list') - .duration(1000) - .success() - .timestamp(timestamp) - .children( - javaInstance - .span('GET apm-*/_search', 'db', 'elasticsearch') - .timestamp(timestamp) - .duration(1000) - .destination('elasticsearch') - .success() - ) - .serialize() - ) - .concat( - range - .interval('1m') - .rate(50) - .flatMap((timestamp) => - javaInstance - .transaction('GET /api/product/list') - .duration(1000) - .failure() - .timestamp(timestamp) - .children( - javaInstance - .span('GET apm-*/_search', 'db', 'elasticsearch') - .timestamp(timestamp) - .duration(1000) - .destination('elasticsearch') - .failure(), - javaInstance - .span('custom_operation', 'app') - .timestamp(timestamp) - .duration(500) - .success() - ) - .serialize() - ) - ) + new Date('2021-01-01T00:00:00.000Z'), + new Date('2021-01-01T00:15:00.000Z') ); + const processor = new StreamProcessor({ processors: [getSpanDestinationMetrics] }); + events = processor + .streamToArray( + range + .interval('1m') + .rate(25) + .spans((timestamp) => + javaInstance + .transaction('GET /api/product/list') + .duration(1000) + .success() + .timestamp(timestamp) + .children( + javaInstance + .span('GET apm-*/_search', 'db', 'elasticsearch') + .timestamp(timestamp) + .duration(1000) + .destination('elasticsearch') + .success() + ) + .serialize() + ), + range + .interval('1m') + .rate(50) + .spans((timestamp) => + javaInstance + .transaction('GET /api/product/list') + .duration(1000) + .failure() + .timestamp(timestamp) + .children( + javaInstance + .span('GET apm-*/_search', 'db', 'elasticsearch') + .timestamp(timestamp) + .duration(1000) + .destination('elasticsearch') + .failure(), + javaInstance + .span('custom_operation', 'app') + .timestamp(timestamp) + .duration(500) + .success() + ) + .serialize() + ) + ) + .filter((fields) => fields['metricset.name'] === 'span_destination'); }); it('generates the right amount of span metrics', () => { diff --git a/packages/elastic-apm-synthtrace/src/test/scenarios/04_breakdown_metrics.test.ts b/packages/elastic-apm-synthtrace/src/test/scenarios/04_breakdown_metrics.test.ts index 817f0aad9f5e..45f956834d76 100644 --- a/packages/elastic-apm-synthtrace/src/test/scenarios/04_breakdown_metrics.test.ts +++ b/packages/elastic-apm-synthtrace/src/test/scenarios/04_breakdown_metrics.test.ts @@ -8,8 +8,9 @@ import { sumBy } from 'lodash'; import { apm } from '../../lib/apm'; import { timerange } from '../../lib/timerange'; -import { getBreakdownMetrics } from '../../lib/apm/utils/get_breakdown_metrics'; +import { getBreakdownMetrics } from '../../lib/apm/processors/get_breakdown_metrics'; import { ApmFields } from '../../lib/apm/apm_fields'; +import { StreamProcessor } from '../../lib/stream_processor'; describe('breakdown metrics', () => { let events: ApmFields[]; @@ -24,51 +25,58 @@ describe('breakdown metrics', () => { const javaService = apm.service('opbeans-java', 'production', 'java'); const javaInstance = javaService.instance('instance-1'); - const start = new Date('2021-01-01T00:00:00.000Z').getTime(); - - const range = timerange(start, start + INTERVALS * 30 * 1000); - - events = getBreakdownMetrics([ - ...range - .interval('30s') - .rate(LIST_RATE) - .flatMap((timestamp) => - javaInstance - .transaction('GET /api/product/list') - .timestamp(timestamp) - .duration(1000) - .children( - javaInstance - .span('GET apm-*/_search', 'db', 'elasticsearch') - .timestamp(timestamp + 150) - .duration(500), - javaInstance.span('GET foo', 'db', 'redis').timestamp(timestamp).duration(100) - ) - .serialize() - ), - ...range - .interval('30s') - .rate(ID_RATE) - .flatMap((timestamp) => - javaInstance - .transaction('GET /api/product/:id') - .timestamp(timestamp) - .duration(1000) - .children( - javaInstance - .span('GET apm-*/_search', 'db', 'elasticsearch') - .duration(500) - .timestamp(timestamp + 100) - .children( - javaInstance - .span('bar', 'external', 'http') - .timestamp(timestamp + 200) - .duration(100) - ) - ) - .serialize() - ), - ]).filter((event) => event['processor.event'] === 'metric'); + const start = new Date('2021-01-01T00:00:00.000Z'); + + const range = timerange(start, new Date(start.getTime() + INTERVALS * 30 * 1000)); + + const listSpans = range + .interval('30s') + .rate(LIST_RATE) + .spans((timestamp) => + javaInstance + .transaction('GET /api/product/list') + .timestamp(timestamp) + .duration(1000) + .children( + javaInstance + .span('GET apm-*/_search', 'db', 'elasticsearch') + .timestamp(timestamp + 150) + .duration(500), + javaInstance.span('GET foo', 'db', 'redis').timestamp(timestamp).duration(100) + ) + .serialize() + ); + + const productPageSpans = range + .interval('30s') + .rate(ID_RATE) + .spans((timestamp) => + javaInstance + .transaction('GET /api/product/:id') + .timestamp(timestamp) + .duration(1000) + .children( + javaInstance + .span('GET apm-*/_search', 'db', 'elasticsearch') + .duration(500) + .timestamp(timestamp + 100) + .children( + javaInstance + .span('bar', 'external', 'http') + .timestamp(timestamp + 200) + .duration(100) + ) + ) + .serialize() + ); + + const processor = new StreamProcessor({ + processors: [getBreakdownMetrics], + flushInterval: '15m', + }); + events = processor + .streamToArray(listSpans, productPageSpans) + .filter((event) => event['processor.event'] === 'metric'); }); it('generates the right amount of breakdown metrics', () => { diff --git a/packages/elastic-eslint-config-kibana/typescript.js b/packages/elastic-eslint-config-kibana/typescript.js index 3ada725cb180..c0c91bd5cd91 100644 --- a/packages/elastic-eslint-config-kibana/typescript.js +++ b/packages/elastic-eslint-config-kibana/typescript.js @@ -168,6 +168,22 @@ module.exports = { selector: 'enum', format: ['PascalCase', 'UPPER_CASE', 'camelCase'], }, + // https://typescript-eslint.io/rules/naming-convention/#ignore-properties-that-require-quotes + // restore check behavior before https://github.com/typescript-eslint/typescript-eslint/pull/4582 + { + selector: [ + 'classProperty', + 'objectLiteralProperty', + 'typeProperty', + 'classMethod', + 'objectLiteralMethod', + 'typeMethod', + 'accessor', + 'enumMember' + ], + format: null, + modifiers: ['requiresQuotes'] + } ], '@typescript-eslint/explicit-member-accessibility': ['error', { diff --git a/packages/kbn-babel-code-parser/package.json b/packages/kbn-babel-code-parser/package.json index 7018cb3f8815..7ff913087f5b 100755 --- a/packages/kbn-babel-code-parser/package.json +++ b/packages/kbn-babel-code-parser/package.json @@ -8,5 +8,8 @@ "repository": { "type": "git", "url": "https://github.com/elastic/kibana/tree/main/packages/kbn-babel-code-parser" + }, + "kibana": { + "devOnly": true } } diff --git a/packages/kbn-babel-preset/node_preset.js b/packages/kbn-babel-preset/node_preset.js index 1c74d6771633..b69c59f5e2cc 100644 --- a/packages/kbn-babel-preset/node_preset.js +++ b/packages/kbn-babel-preset/node_preset.js @@ -31,7 +31,7 @@ module.exports = (_, options = {}) => { // Because of that we should use for that value the same version we install // in the package.json in order to have the same polyfills between the environment // and the tests - corejs: '3.2.1', + corejs: '3.21.1', bugfixes: true, ...(options['@babel/preset-env'] || {}), diff --git a/packages/kbn-babel-preset/webpack_preset.js b/packages/kbn-babel-preset/webpack_preset.js index ea49c406d50f..7bf1f518012a 100644 --- a/packages/kbn-babel-preset/webpack_preset.js +++ b/packages/kbn-babel-preset/webpack_preset.js @@ -18,7 +18,7 @@ module.exports = () => { modules: false, // Please read the explanation for this // in node_preset.js - corejs: '3.2.1', + corejs: '3.21.1', bugfixes: true, }, ], @@ -55,7 +55,7 @@ module.exports = () => { [ require.resolve('@emotion/babel-preset-css-prop'), { - labelFormat: '[local]', + labelFormat: '[filename]--[local]', }, ], ], diff --git a/packages/kbn-bazel-packages/BUILD.bazel b/packages/kbn-bazel-packages/BUILD.bazel new file mode 100644 index 000000000000..08ffbe24ba2a --- /dev/null +++ b/packages/kbn-bazel-packages/BUILD.bazel @@ -0,0 +1,120 @@ +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-bazel-packages" +PKG_REQUIRE_NAME = "@kbn/bazel-packages" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +# 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-utils", + "//packages/kbn-std", + "@npm//globby", +] + +# 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: +# eg. "@npm//@types/babel__core" +TYPES_DEPS = [ + "//packages/kbn-utils:npm_module_types", + "//packages/kbn-std:npm_module_types", + "@npm//globby", +] + +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-bazel-packages/README.md b/packages/kbn-bazel-packages/README.md new file mode 100644 index 000000000000..fa34cd7facb8 --- /dev/null +++ b/packages/kbn-bazel-packages/README.md @@ -0,0 +1,3 @@ +# @kbn/bazel-packages + +APIs for dealing with bazel packages in the Kibana repo diff --git a/packages/kbn-bazel-packages/jest.config.js b/packages/kbn-bazel-packages/jest.config.js new file mode 100644 index 000000000000..c3fd73c73ca8 --- /dev/null +++ b/packages/kbn-bazel-packages/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-bazel-packages'], +}; diff --git a/packages/kbn-bazel-packages/package.json b/packages/kbn-bazel-packages/package.json new file mode 100644 index 000000000000..085f0bb4510f --- /dev/null +++ b/packages/kbn-bazel-packages/package.json @@ -0,0 +1,10 @@ +{ + "name": "@kbn/bazel-packages", + "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-bazel-packages/src/bazel_package.test.ts b/packages/kbn-bazel-packages/src/bazel_package.test.ts new file mode 100644 index 000000000000..884cf0e646ba --- /dev/null +++ b/packages/kbn-bazel-packages/src/bazel_package.test.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 Fs from 'fs'; +import Path from 'path'; + +import { BazelPackage } from './bazel_package'; + +const OWN_BAZEL_BUILD_FILE = Fs.readFileSync(Path.resolve(__dirname, '../BUILD.bazel'), 'utf8'); + +describe('hasBuildRule()', () => { + it('returns true if there is a rule with the name "build"', () => { + const pkg = new BazelPackage('foo', {}, OWN_BAZEL_BUILD_FILE); + expect(pkg.hasBuildRule()).toBe(true); + }); + + it('returns false if there is no rule with name "build"', () => { + const pkg = new BazelPackage('foo', {}, ``); + expect(pkg.hasBuildRule()).toBe(false); + }); +}); + +describe('hasBuildTypesRule()', () => { + it('returns true if there is a rule with the name "build_types"', () => { + const pkg = new BazelPackage('foo', {}, OWN_BAZEL_BUILD_FILE); + expect(pkg.hasBuildTypesRule()).toBe(true); + }); + + it('returns false if there is no rule with name "build_types"', () => { + const pkg = new BazelPackage('foo', {}, ``); + expect(pkg.hasBuildTypesRule()).toBe(false); + }); +}); diff --git a/packages/kbn-bazel-packages/src/bazel_package.ts b/packages/kbn-bazel-packages/src/bazel_package.ts new file mode 100644 index 000000000000..28170cb68a5d --- /dev/null +++ b/packages/kbn-bazel-packages/src/bazel_package.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 Path from 'path'; +import Fsp from 'fs/promises'; +import { REPO_ROOT } from '@kbn/utils'; + +const BUILD_RULE_NAME = /(^|\s)name\s*=\s*"build"/; +const BUILD_TYPES_RULE_NAME = /(^|\s)name\s*=\s*"build_types"/; + +export class BazelPackage { + static async fromDir(dir: string) { + let pkg; + try { + pkg = JSON.parse(await Fsp.readFile(Path.resolve(dir, 'package.json'), 'utf8')); + } catch (error) { + throw new Error(`unable to parse package.json in [${dir}]: ${error.message}`); + } + + let buildBazelContent; + if (pkg.name !== '@kbn/pm') { + try { + buildBazelContent = await Fsp.readFile(Path.resolve(dir, 'BUILD.bazel'), 'utf8'); + } catch (error) { + throw new Error(`unable to read BUILD.bazel file in [${dir}]: ${error.message}`); + } + } + + return new BazelPackage(Path.relative(REPO_ROOT, dir), pkg, buildBazelContent); + } + + constructor( + public readonly repoRelativeDir: string, + public readonly pkg: any, + public readonly buildBazelContent?: string + ) {} + + hasBuildRule() { + return !!(this.buildBazelContent && BUILD_RULE_NAME.test(this.buildBazelContent)); + } + + hasBuildTypesRule() { + return !!(this.buildBazelContent && BUILD_TYPES_RULE_NAME.test(this.buildBazelContent)); + } +} diff --git a/packages/kbn-bazel-packages/src/discover_packages.ts b/packages/kbn-bazel-packages/src/discover_packages.ts new file mode 100644 index 000000000000..0c84c388c7c0 --- /dev/null +++ b/packages/kbn-bazel-packages/src/discover_packages.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 Path from 'path'; + +import globby from 'globby'; +import { REPO_ROOT } from '@kbn/utils'; +import { asyncMapWithLimit } from '@kbn/std'; + +import { BazelPackage } from './bazel_package'; + +/** + * Search the local Kibana repo for bazel packages and return an array of BazelPackage objects + * representing each package found. + */ +export async function discoverBazelPackages() { + const packageJsons = globby.sync('*/package.json', { + cwd: Path.resolve(REPO_ROOT, 'packages'), + absolute: true, + }); + + return await asyncMapWithLimit( + packageJsons.sort((a, b) => a.localeCompare(b)), + 10, + (path) => BazelPackage.fromDir(Path.dirname(path)) + ); +} diff --git a/packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.test.ts b/packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.test.ts new file mode 100644 index 000000000000..2d9fd2ed48ad --- /dev/null +++ b/packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { generatePackagesBuildBazelFile } from './generate_packages_build_bazel_file'; + +import { BazelPackage } from './bazel_package'; + +it('produces a valid BUILD.bazel file', () => { + const packages = [ + new BazelPackage( + 'foo', + {}, + ` + rule( + name = "build" + ) + rule( + name = "build_types" + ) + ` + ), + new BazelPackage( + 'bar', + {}, + ` + rule( + name= "build_types" + ) + ` + ), + new BazelPackage( + 'bar', + {}, + ` + rule( + name ="build" + ) + ` + ), + new BazelPackage('bar', {}), + ]; + + expect(generatePackagesBuildBazelFile(packages)).toMatchInlineSnapshot(` + "################ + ################ + ## This file is automatically generated, to create a new package use \`node scripts/generate package --help\` + ################ + ################ + + # It will build all declared code packages + filegroup( + name = \\"build_pkg_code\\", + srcs = [ + \\"//foo:build\\", + \\"//bar:build\\", + ], + ) + + # It will build all declared package types + filegroup( + name = \\"build_pkg_types\\", + srcs = [ + \\"//foo:build_types\\", + \\"//bar:build_types\\", + ], + ) + + # Grouping target to call all underlying packages build + # targets so we can build them all at once + # It will auto build all declared code packages and types packages + filegroup( + name = \\"build\\", + srcs = [ + \\":build_pkg_code\\", + \\":build_pkg_types\\" + ], + ) + " + `); +}); diff --git a/packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.ts b/packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.ts new file mode 100644 index 000000000000..d1dd3561ed39 --- /dev/null +++ b/packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { BazelPackage } from './bazel_package'; + +export function generatePackagesBuildBazelFile(packages: BazelPackage[]) { + return `################ +################ +## This file is automatically generated, to create a new package use \`node scripts/generate package --help\` +################ +################ + +# It will build all declared code packages +filegroup( + name = "build_pkg_code", + srcs = [ +${packages + .flatMap((p) => (p.hasBuildRule() ? ` "//${p.repoRelativeDir}:build",` : [])) + .join('\n')} + ], +) + +# It will build all declared package types +filegroup( + name = "build_pkg_types", + srcs = [ +${packages + .flatMap((p) => (p.hasBuildTypesRule() ? ` "//${p.repoRelativeDir}:build_types",` : [])) + .join('\n')} + ], +) + +# Grouping target to call all underlying packages build +# targets so we can build them all at once +# It will auto build all declared code packages and types packages +filegroup( + name = "build", + srcs = [ + ":build_pkg_code", + ":build_pkg_types" + ], +) +`; +} diff --git a/packages/kbn-bazel-packages/src/index.ts b/packages/kbn-bazel-packages/src/index.ts new file mode 100644 index 000000000000..7e73fcd0a63e --- /dev/null +++ b/packages/kbn-bazel-packages/src/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 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 * from './discover_packages'; +export type { BazelPackage } from './bazel_package'; +export * from './generate_packages_build_bazel_file'; diff --git a/packages/kbn-bazel-packages/tsconfig.json b/packages/kbn-bazel-packages/tsconfig.json new file mode 100644 index 000000000000..a8cfc2cceb08 --- /dev/null +++ b/packages/kbn-bazel-packages/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-crypto/BUILD.bazel b/packages/kbn-crypto/BUILD.bazel index de8c97ed3b71..09c5fbb47e3a 100644 --- a/packages/kbn-crypto/BUILD.bazel +++ b/packages/kbn-crypto/BUILD.bazel @@ -61,6 +61,7 @@ ts_project( srcs = SRCS, deps = TYPES_DEPS, declaration = True, + declaration_map = True, emit_declaration_only = True, out_dir = "target_types", root_dir = "src", diff --git a/packages/kbn-crypto/tsconfig.json b/packages/kbn-crypto/tsconfig.json index 272363e976ba..fc929cba6868 100644 --- a/packages/kbn-crypto/tsconfig.json +++ b/packages/kbn-crypto/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.bazel.json", "compilerOptions": { "declaration": true, + "declarationMap": true, "emitDeclarationOnly": true, "outDir": "./target_types", "rootDir": "src", diff --git a/packages/kbn-dev-utils/BUILD.bazel b/packages/kbn-dev-utils/BUILD.bazel index 7be527c65a06..5f31a8ba0748 100644 --- a/packages/kbn-dev-utils/BUILD.bazel +++ b/packages/kbn-dev-utils/BUILD.bazel @@ -38,13 +38,14 @@ NPM_MODULE_EXTRA_FILES = [ "README.md", ":certs", "ci_stats_reporter/package.json", + "sort_package_json/package.json", "stdio/package.json", "tooling_log/package.json" ] RUNTIME_DEPS = [ - "//packages/kbn-utils", "//packages/kbn-std", + "//packages/kbn-utils", "@npm//@babel/core", "@npm//axios", "@npm//chalk", @@ -54,11 +55,14 @@ RUNTIME_DEPS = [ "@npm//exit-hook", "@npm//getopts", "@npm//globby", + "@npm//jest-diff", "@npm//load-json-file", "@npm//markdown-it", "@npm//normalize-path", "@npm//prettier", "@npm//rxjs", + "@npm//strip-ansi", + "@npm//sort-package-json", "@npm//tar", "@npm//tree-kill", "@npm//vinyl", @@ -66,8 +70,8 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ - "//packages/kbn-utils:npm_module_types", "//packages/kbn-std:npm_module_types", + "//packages/kbn-utils:npm_module_types", "@npm//@babel/parser", "@npm//@babel/types", "@npm//@types/babel__core", @@ -88,7 +92,10 @@ TYPES_DEPS = [ "@npm//execa", "@npm//exit-hook", "@npm//getopts", + "@npm//jest-diff", "@npm//rxjs", + "@npm//sort-package-json", + "@npm//strip-ansi", "@npm//tree-kill", ] diff --git a/packages/kbn-dev-utils/sort_package_json/package.json b/packages/kbn-dev-utils/sort_package_json/package.json new file mode 100644 index 000000000000..e075ec436de3 --- /dev/null +++ b/packages/kbn-dev-utils/sort_package_json/package.json @@ -0,0 +1,4 @@ +{ + "main": "../target_node/sort_package_json", + "types": "../target_types/sort_package_json" +} \ No newline at end of file diff --git a/packages/kbn-dev-utils/src/diff_strings.test.ts b/packages/kbn-dev-utils/src/diff_strings.test.ts new file mode 100644 index 000000000000..411004c8412e --- /dev/null +++ b/packages/kbn-dev-utils/src/diff_strings.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright 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 { diffStrings } from './diff_strings'; + +const json = (x: any) => JSON.stringify(x, null, 2); + +describe('diffStrings()', () => { + it('returns undefined if values are equal', () => { + expect(diffStrings('1', '1')).toBe(undefined); + expect(diffStrings(json(['1', '2', { a: 'b' }]), json(['1', '2', { a: 'b' }]))).toBe(undefined); + expect( + diffStrings( + json({ + a: '1', + b: '2', + }), + json({ + a: '1', + b: '2', + }) + ) + ).toBe(undefined); + }); + + it('returns a diff if the values are different', () => { + const diff = diffStrings(json(['1', '2', { a: 'b' }]), json(['1', '2', { b: 'a' }])); + + expect(diff).toMatchInlineSnapshot(` + "- Expected + + Received + +  [ +  \\"1\\", +  \\"2\\", +  { + - \\"a\\": \\"b\\" + + \\"b\\": \\"a\\" +  } +  ]" + `); + + const diff2 = diffStrings( + json({ + a: '1', + b: '1', + }), + json({ + b: '2', + a: '2', + }) + ); + + expect(diff2).toMatchInlineSnapshot(` + "- Expected + + Received + +  { + - \\"a\\": \\"1\\", + - \\"b\\": \\"1\\" + + \\"b\\": \\"2\\", + + \\"a\\": \\"2\\" +  }" + `); + }); + + it('formats large diffs to focus on the changed lines', () => { + const diff = diffStrings( + json({ + a: ['1', '1', '1', '1', '1', '1', '1', '2', '1', '1', '1', '1', '1', '1', '1', '1', '1'], + }), + json({ + b: ['1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '2', '1', '1', '1', '1'], + }) + ); + + expect(diff).toMatchInlineSnapshot(` + "- Expected + + Received + +  { + - \\"a\\": [ + + \\"b\\": [ +  \\"1\\", +  \\"1\\", +  ... +  \\"1\\", +  \\"1\\", + - \\"2\\", +  \\"1\\", +  \\"1\\", +  ... +  \\"1\\", +  \\"1\\", + + \\"2\\", +  \\"1\\", +  \\"1\\", +  ..." + `); + }); +}); diff --git a/packages/kbn-dev-utils/src/diff_strings.ts b/packages/kbn-dev-utils/src/diff_strings.ts new file mode 100644 index 000000000000..11b7e574c756 --- /dev/null +++ b/packages/kbn-dev-utils/src/diff_strings.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 jestDiff from 'jest-diff'; +import stripAnsi from 'strip-ansi'; +import Chalk from 'chalk'; + +function reformatJestDiff(diff: string) { + const diffLines = diff.split('\n'); + + if ( + diffLines.length < 4 || + stripAnsi(diffLines[0]) !== '- Expected' || + stripAnsi(diffLines[1]) !== '+ Received' + ) { + throw new Error(`unexpected diff format: ${diff}`); + } + + const outputLines = [diffLines.shift(), diffLines.shift(), diffLines.shift()]; + + /** + * buffer which contains between 0 and 5 lines from the diff which aren't additions or + * deletions. The first three are the first three lines seen since the buffer was cleared + * and the last two lines are the last two lines seen. + * + * When flushContext() is called we write the first two lines to output, an elipses if there + * are five lines, and then the last two lines. + * + * At the very end we will write the last two lines of context if they're defined + */ + const contextBuffer: string[] = []; + + /** + * Convert a line to an empty line with elipses placed where the text on that line starts + */ + const toElipses = (line: string) => { + return stripAnsi(line).replace(/^(\s*).*/, '$1...'); + }; + + while (diffLines.length) { + const line = diffLines.shift()!; + const plainLine = stripAnsi(line); + if (plainLine.startsWith('+ ') || plainLine.startsWith('- ')) { + // write contextBuffer to the outputLines + if (contextBuffer.length) { + outputLines.push( + ...contextBuffer.slice(0, 2), + ...(contextBuffer.length === 5 + ? [Chalk.dim(toElipses(contextBuffer[2])), ...contextBuffer.slice(3, 5)] + : contextBuffer.slice(2, 4)) + ); + + contextBuffer.length = 0; + } + + // add this line to the outputLines + outputLines.push(line); + } else { + // update the contextBuffer with this line which doesn't represent a change + if (contextBuffer.length === 5) { + contextBuffer[3] = contextBuffer[4]; + contextBuffer[4] = line; + } else { + contextBuffer.push(line); + } + } + } + + if (contextBuffer.length) { + outputLines.push( + ...contextBuffer.slice(0, 2), + ...(contextBuffer.length > 2 ? [Chalk.dim(toElipses(contextBuffer[2]))] : []) + ); + } + + return outputLines.join('\n'); +} + +/** + * Produces a diff string which is nicely formatted to show the differences between two strings. This will + * be a multi-line string so it's generally a good idea to include a `\n` before this first line of the diff + * if you are concatenating it with another message. + */ +export function diffStrings(expected: string, received: string) { + const diff = jestDiff(expected, received); + + if (!diff || stripAnsi(diff) === 'Compared values have no visual difference.') { + return undefined; + } + + return reformatJestDiff(diff); +} diff --git a/packages/kbn-dev-utils/src/index.ts b/packages/kbn-dev-utils/src/index.ts index 9b207ad9e996..db96db46bde7 100644 --- a/packages/kbn-dev-utils/src/index.ts +++ b/packages/kbn-dev-utils/src/index.ts @@ -32,3 +32,5 @@ export * from './streams'; export * from './babel'; export * from './extract'; export * from './vscode_config'; +export * from './sort_package_json'; +export * from './diff_strings'; diff --git a/packages/kbn-dev-utils/src/sort_package_json.ts b/packages/kbn-dev-utils/src/sort_package_json.ts new file mode 100644 index 000000000000..9873244eb367 --- /dev/null +++ b/packages/kbn-dev-utils/src/sort_package_json.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 sorter from 'sort-package-json'; + +export function sortPackageJson(json: string) { + return ( + JSON.stringify( + sorter(JSON.parse(json), { + // top level keys in the order they were written when this was implemented + sortOrder: [ + 'name', + 'description', + 'keywords', + 'private', + 'version', + 'branch', + 'main', + 'browser', + 'types', + 'tsdocMetadata', + 'build', + 'homepage', + 'bugs', + 'license', + 'kibana', + 'author', + 'scripts', + 'repository', + 'engines', + 'resolutions', + ], + }), + null, + 2 + ) + '\n' + ); +} diff --git a/packages/kbn-dev-utils/src/tooling_log/tooling_log.ts b/packages/kbn-dev-utils/src/tooling_log/tooling_log.ts index 84e9159dfcd4..e9b5ab04a739 100644 --- a/packages/kbn-dev-utils/src/tooling_log/tooling_log.ts +++ b/packages/kbn-dev-utils/src/tooling_log/tooling_log.ts @@ -62,7 +62,10 @@ export class ToolingLog { * @param delta the number of spaces to increase/decrease the indentation * @param block a function to run and reset any indentation changes after */ - public indent(delta = 0, block?: () => Promise) { + public indent(delta: number): undefined; + public indent(delta: number, block: () => Promise): Promise; + public indent(delta: number, block: () => T): T; + public indent(delta = 0, block?: () => T | Promise) { const originalWidth = this.indentWidth$.getValue(); this.indentWidth$.next(Math.max(originalWidth + delta, 0)); if (!block) { diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index d73760b280d4..03948af63791 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -196,6 +196,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { std_dev: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-extendedstats-aggregation.html`, sum: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-sum-aggregation.html`, top_hits: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-top-hits-aggregation.html`, + top_metrics: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-top-metrics.html`, }, runtimeFields: { overview: `${ELASTICSEARCH_DOCS}runtime.html`, diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx index 9d3b2f5d3cf9..ab80f1f02d0a 100644 --- a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx @@ -4,7 +4,7 @@ slug: /kibana-dev-docs/api/pluginA title: "pluginA" image: https://source.unsplash.com/400x175/?github summary: API docs for the pluginA plugin -date: 2020-11-16 +date: 2022-02-14 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'pluginA'] warning: This document is auto-generated and is meant to be viewed inside our experimental, new docs system. Reach out in #docs-engineering for more info. --- diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx index 6d7f42982b89..e9873f822301 100644 --- a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx @@ -4,7 +4,7 @@ slug: /kibana-dev-docs/api/pluginA-foo title: "pluginA.foo" image: https://source.unsplash.com/400x175/?github summary: API docs for the pluginA.foo plugin -date: 2020-11-16 +date: 2022-02-14 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'pluginA.foo'] warning: This document is auto-generated and is meant to be viewed inside our experimental, new docs system. Reach out in #docs-engineering for more info. --- diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_b.mdx b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_b.mdx index c86fbed82c23..1671cd7a529d 100644 --- a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_b.mdx +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_b.mdx @@ -4,7 +4,7 @@ slug: /kibana-dev-docs/api/pluginB title: "pluginB" image: https://source.unsplash.com/400x175/?github summary: API docs for the pluginB plugin -date: 2020-11-16 +date: 2022-02-14 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'pluginB'] warning: This document is auto-generated and is meant to be viewed inside our experimental, new docs system. Reach out in #docs-engineering for more info. --- diff --git a/packages/kbn-es/src/cli_commands/build_snapshots.js b/packages/kbn-es/src/cli_commands/build_snapshots.js index d6ea76faf2cf..070f11b8b5f8 100644 --- a/packages/kbn-es/src/cli_commands/build_snapshots.js +++ b/packages/kbn-es/src/cli_commands/build_snapshots.js @@ -42,32 +42,31 @@ exports.run = async (defaults = {}) => { for (const license of ['oss', 'trial']) { for (const platform of ['darwin', 'win32', 'linux']) { log.info('Building', platform, license === 'trial' ? 'default' : 'oss', 'snapshot'); - log.indent(4); + await log.indent(4, async () => { + const snapshotPath = await buildSnapshot({ + license, + sourcePath: options.sourcePath, + log, + platform, + }); - const snapshotPath = await buildSnapshot({ - license, - sourcePath: options.sourcePath, - log, - platform, - }); - - const filename = basename(snapshotPath); - const outputPath = resolve(outputDir, filename); - const hash = createHash('sha512'); - await pipelineAsync( - Fs.createReadStream(snapshotPath), - new Transform({ - transform(chunk, _, cb) { - hash.update(chunk); - cb(undefined, chunk); - }, - }), - Fs.createWriteStream(outputPath) - ); + const filename = basename(snapshotPath); + const outputPath = resolve(outputDir, filename); + const hash = createHash('sha512'); + await pipelineAsync( + Fs.createReadStream(snapshotPath), + new Transform({ + transform(chunk, _, cb) { + hash.update(chunk); + cb(undefined, chunk); + }, + }), + Fs.createWriteStream(outputPath) + ); - Fs.writeFileSync(`${outputPath}.sha512`, `${hash.digest('hex')} ${filename}`); - log.success('snapshot and shasum written to', outputPath); - log.indent(-4); + Fs.writeFileSync(`${outputPath}.sha512`, `${hash.digest('hex')} ${filename}`); + log.success('snapshot and shasum written to', outputPath); + }); } } }; diff --git a/packages/kbn-es/src/cli_commands/snapshot.js b/packages/kbn-es/src/cli_commands/snapshot.js index 1c902796a0a0..095ce3cb0429 100644 --- a/packages/kbn-es/src/cli_commands/snapshot.js +++ b/packages/kbn-es/src/cli_commands/snapshot.js @@ -33,6 +33,8 @@ exports.help = (defaults = {}) => { --use-cached Skips cache verification and use cached ES snapshot. --skip-ready-check Disable the ready check, --ready-timeout Customize the ready check timeout, in seconds or "Xm" format, defaults to 1m + --plugins Comma seperated list of Elasticsearch plugins to install + --secure-files Comma seperated list of secure_setting_name=/path pairs Example: @@ -58,6 +60,7 @@ exports.run = async (defaults = {}) => { useCached: 'use-cached', skipReadyCheck: 'skip-ready-check', readyTimeout: 'ready-timeout', + secureFiles: 'secure-files', }, string: ['version', 'ready-timeout'], @@ -76,6 +79,13 @@ exports.run = async (defaults = {}) => { if (options.dataArchive) { await cluster.extractDataDirectory(installPath, options.dataArchive); } + if (options.plugins) { + await cluster.installPlugins(installPath, options.plugins, options); + } + if (options.secureFiles) { + const pairs = options.secureFiles.split(',').map((kv) => kv.split('=').map((v) => v.trim())); + await cluster.configureKeystoreWithSecureSettingsFiles(installPath, pairs); + } reportTime(installStartTime, 'installed', { success: true, diff --git a/packages/kbn-es/src/cli_commands/source.js b/packages/kbn-es/src/cli_commands/source.js index c16e89e2c7f3..d1f8e02b5568 100644 --- a/packages/kbn-es/src/cli_commands/source.js +++ b/packages/kbn-es/src/cli_commands/source.js @@ -27,6 +27,8 @@ exports.help = (defaults = {}) => { --password Sets password for elastic user [default: ${password}] --password.[user] Sets password for native realm user [default: ${password}] --ssl Sets up SSL on Elasticsearch + --plugins Comma seperated list of Elasticsearch plugins to install + --secure-files Comma seperated list of secure_setting_name=/path pairs -E Additional key=value settings to pass to Elasticsearch --skip-ready-check Disable the ready check, --ready-timeout Customize the ready check timeout, in seconds or "Xm" format, defaults to 1m @@ -47,6 +49,7 @@ exports.run = async (defaults = {}) => { dataArchive: 'data-archive', skipReadyCheck: 'skip-ready-check', readyTimeout: 'ready-timeout', + secureFiles: 'secure-files', esArgs: 'E', }, @@ -62,6 +65,13 @@ exports.run = async (defaults = {}) => { if (options.dataArchive) { await cluster.extractDataDirectory(installPath, options.dataArchive); } + if (options.plugins) { + await cluster.installPlugins(installPath, options.plugins, options); + } + if (options.secureFiles) { + const pairs = options.secureFiles.split(',').map((kv) => kv.split('=').map((v) => v.trim())); + await cluster.configureKeystoreWithSecureSettingsFiles(installPath, pairs); + } await cluster.run(installPath, { ...options, diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index 22ff9ae3c0cd..a6faffc2cfcd 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -12,7 +12,7 @@ const chalk = require('chalk'); const path = require('path'); const { Client } = require('@elastic/elasticsearch'); const { downloadSnapshot, installSnapshot, installSource, installArchive } = require('./install'); -const { ES_BIN } = require('./paths'); +const { ES_BIN, ES_PLUGIN_BIN, ES_KEYSTORE_BIN } = require('./paths'); const { log: defaultLog, parseEsLog, @@ -59,13 +59,10 @@ exports.Cluster = class Cluster { */ async installSource(options = {}) { this._log.info(chalk.bold('Installing from source')); - this._log.indent(4); - - const { installPath } = await installSource({ log: this._log, ...options }); - - this._log.indent(-4); - - return { installPath }; + return await this._log.indent(4, async () => { + const { installPath } = await installSource({ log: this._log, ...options }); + return { installPath }; + }); } /** @@ -78,16 +75,14 @@ exports.Cluster = class Cluster { */ async downloadSnapshot(options = {}) { this._log.info(chalk.bold('Downloading snapshot')); - this._log.indent(4); + return await this._log.indent(4, async () => { + const { installPath } = await downloadSnapshot({ + log: this._log, + ...options, + }); - const { installPath } = await downloadSnapshot({ - log: this._log, - ...options, + return { installPath }; }); - - this._log.indent(-4); - - return { installPath }; } /** @@ -100,16 +95,14 @@ exports.Cluster = class Cluster { */ async installSnapshot(options = {}) { this._log.info(chalk.bold('Installing from snapshot')); - this._log.indent(4); + return await this._log.indent(4, async () => { + const { installPath } = await installSnapshot({ + log: this._log, + ...options, + }); - const { installPath } = await installSnapshot({ - log: this._log, - ...options, + return { installPath }; }); - - this._log.indent(-4); - - return { installPath }; } /** @@ -122,16 +115,14 @@ exports.Cluster = class Cluster { */ async installArchive(path, options = {}) { this._log.info(chalk.bold('Installing from an archive')); - this._log.indent(4); + return await this._log.indent(4, async () => { + const { installPath } = await installArchive(path, { + log: this._log, + ...options, + }); - const { installPath } = await installArchive(path, { - log: this._log, - ...options, + return { installPath }; }); - - this._log.indent(-4); - - return { installPath }; } /** @@ -144,21 +135,55 @@ exports.Cluster = class Cluster { */ async extractDataDirectory(installPath, archivePath, extractDirName = 'data') { this._log.info(chalk.bold(`Extracting data directory`)); - this._log.indent(4); - - // stripComponents=1 excludes the root directory as that is how our archives are - // structured. This works in our favor as we can explicitly extract into the data dir - const extractPath = path.resolve(installPath, extractDirName); - this._log.info(`Data archive: ${archivePath}`); - this._log.info(`Extract path: ${extractPath}`); - - await extract({ - archivePath, - targetDir: extractPath, - stripComponents: 1, + await this._log.indent(4, async () => { + // stripComponents=1 excludes the root directory as that is how our archives are + // structured. This works in our favor as we can explicitly extract into the data dir + const extractPath = path.resolve(installPath, extractDirName); + this._log.info(`Data archive: ${archivePath}`); + this._log.info(`Extract path: ${extractPath}`); + + await extract({ + archivePath, + targetDir: extractPath, + stripComponents: 1, + }); }); + } + + /** + * Starts ES and returns resolved promise once started + * + * @param {String} installPath + * @param {String} plugins - comma separated list of plugins to install + * @param {Object} options + * @returns {Promise} + */ + async installPlugins(installPath, plugins, options) { + const esJavaOpts = this.javaOptions(options); + for (const plugin of plugins.split(',')) { + await execa(ES_PLUGIN_BIN, ['install', plugin.trim()], { + cwd: installPath, + env: { + JAVA_HOME: '', // By default, we want to always unset JAVA_HOME so that the bundled JDK will be used + ES_JAVA_OPTS: esJavaOpts.trim(), + }, + }); + } + } - this._log.indent(-4); + async configureKeystoreWithSecureSettingsFiles(installPath, secureSettingsFiles) { + const env = { JAVA_HOME: '' }; + for (const [secureSettingName, secureSettingFile] of secureSettingsFiles) { + this._log.info( + `setting secure setting %s to %s`, + chalk.bold(secureSettingName), + chalk.bold(secureSettingFile) + ); + await execa(ES_KEYSTORE_BIN, ['add-file', secureSettingName, secureSettingFile], { + cwd: installPath, + env, + }); + } } /** @@ -169,24 +194,27 @@ exports.Cluster = class Cluster { * @returns {Promise} */ async start(installPath, options = {}) { - this._exec(installPath, options); - - await Promise.race([ - // wait for native realm to be setup and es to be started - Promise.all([ - first(this._process.stdout, (data) => { - if (/started/.test(data)) { - return true; - } - }), - this._setupPromise, - ]), + // _exec indents and we wait for our own end condition, so reset the indent level to it's current state after we're done waiting + await this._log.indent(0, async () => { + this._exec(installPath, options); + + await Promise.race([ + // wait for native realm to be setup and es to be started + Promise.all([ + first(this._process.stdout, (data) => { + if (/started/.test(data)) { + return true; + } + }), + this._setupPromise, + ]), - // await the outcome of the process in case it exits before starting - this._outcome.then(() => { - throw createCliError('ES exited without starting'); - }), - ]); + // await the outcome of the process in case it exits before starting + this._outcome.then(() => { + throw createCliError('ES exited without starting'); + }), + ]); + }); } /** @@ -197,16 +225,19 @@ exports.Cluster = class Cluster { * @returns {Promise} */ async run(installPath, options = {}) { - this._exec(installPath, options); + // _exec indents and we wait for our own end condition, so reset the indent level to it's current state after we're done waiting + await this._log.indent(0, async () => { + this._exec(installPath, options); + + // log native realm setup errors so they aren't uncaught + this._setupPromise.catch((error) => { + this._log.error(error); + this.stop(); + }); - // log native realm setup errors so they aren't uncaught - this._setupPromise.catch((error) => { - this._log.error(error); - this.stop(); + // await the final outcome of the process + await this._outcome; }); - - // await the final outcome of the process - await this._outcome; } /** @@ -285,19 +316,9 @@ exports.Cluster = class Cluster { ); this._log.info('%s %s', ES_BIN, args.join(' ')); + const esJavaOpts = this.javaOptions(options); - let esJavaOpts = `${options.esJavaOpts || ''} ${process.env.ES_JAVA_OPTS || ''}`; - - // ES now automatically sets heap size to 50% of the machine's available memory - // so we need to set it to a smaller size for local dev and CI - // especially because we currently run many instances of ES on the same machine during CI - // inital and max must be the same, so we only need to check the max - if (!esJavaOpts.includes('Xmx')) { - // 1536m === 1.5g - esJavaOpts += ' -Xms1536m -Xmx1536m'; - } - - this._log.info('ES_JAVA_OPTS: %s', esJavaOpts.trim()); + this._log.info('ES_JAVA_OPTS: %s', esJavaOpts); this._process = execa(ES_BIN, args, { cwd: installPath, @@ -305,7 +326,7 @@ exports.Cluster = class Cluster { ...(installPath ? { ES_TMPDIR: path.resolve(installPath, 'ES_TMPDIR') } : {}), ...process.env, JAVA_HOME: '', // By default, we want to always unset JAVA_HOME so that the bundled JDK will be used - ES_JAVA_OPTS: esJavaOpts.trim(), + ES_JAVA_OPTS: esJavaOpts, }, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -434,4 +455,18 @@ exports.Cluster = class Cluster { } } } + + javaOptions(options) { + let esJavaOpts = `${options.esJavaOpts || ''} ${process.env.ES_JAVA_OPTS || ''}`; + + // ES now automatically sets heap size to 50% of the machine's available memory + // so we need to set it to a smaller size for local dev and CI + // especially because we currently run many instances of ES on the same machine during CI + // inital and max must be the same, so we only need to check the max + if (!esJavaOpts.includes('Xmx')) { + // 1536m === 1.5g + esJavaOpts += ' -Xms1536m -Xmx1536m'; + } + return esJavaOpts.trim(); + } }; diff --git a/packages/kbn-es/src/paths.ts b/packages/kbn-es/src/paths.ts index c1b859af4e1f..1d909f523302 100644 --- a/packages/kbn-es/src/paths.ts +++ b/packages/kbn-es/src/paths.ts @@ -19,6 +19,7 @@ export const BASE_PATH = Path.resolve(tempDir, 'kbn-es'); export const GRADLE_BIN = maybeUseBat('./gradlew'); export const ES_BIN = maybeUseBat('bin/elasticsearch'); +export const ES_PLUGIN_BIN = maybeUseBat('bin/elasticsearch-plugin'); export const ES_CONFIG = 'config/elasticsearch.yml'; export const ES_KEYSTORE_BIN = maybeUseBat('./bin/elasticsearch-keystore'); diff --git a/packages/kbn-flot-charts/lib/jquery_flot.js b/packages/kbn-flot-charts/lib/jquery_flot.js index 43db1cc3d93d..5252356279e5 100644 --- a/packages/kbn-flot-charts/lib/jquery_flot.js +++ b/packages/kbn-flot-charts/lib/jquery_flot.js @@ -351,7 +351,7 @@ Licensed under the MIT license. if (info == null) { - var element = $("
").html(text) + var element = $("
").text(text) .css({ position: "absolute", 'max-width': width, diff --git a/packages/kbn-generate/BUILD.bazel b/packages/kbn-generate/BUILD.bazel new file mode 100644 index 000000000000..8eda65869a45 --- /dev/null +++ b/packages/kbn-generate/BUILD.bazel @@ -0,0 +1,110 @@ +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_BASE_NAME = "kbn-generate" +PKG_REQUIRE_NAME = "@kbn/generate" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*" + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = glob(["templates/**/*"]) + [ + "package.json", +] + +RUNTIME_DEPS = [ + "//packages/kbn-dev-utils", + "//packages/kbn-bazel-packages", + "//packages/kbn-utils", + "@npm//ejs", + "@npm//normalize-path", +] + +TYPES_DEPS = [ + "//packages/kbn-dev-utils:npm_module_types", + "//packages/kbn-bazel-packages:npm_module_types", + "//packages/kbn-utils:npm_module_types", + "@npm//ejs", + "@npm//normalize-path", + "@npm//@types/ejs", +] + +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_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +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-generate/README.md b/packages/kbn-generate/README.md new file mode 100644 index 000000000000..8257d61ade72 --- /dev/null +++ b/packages/kbn-generate/README.md @@ -0,0 +1,7 @@ +# @kbn/generate + +Provides a CLI for generating different components within Kibana + +## `node scripts/generate package` + +Generate a Kibana package, run `node scripts/generate package --help` for details about options. \ No newline at end of file diff --git a/packages/kbn-generate/jest.config.js b/packages/kbn-generate/jest.config.js new file mode 100644 index 000000000000..b72f891cfb1a --- /dev/null +++ b/packages/kbn-generate/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-generate'], +}; diff --git a/packages/kbn-generate/package.json b/packages/kbn-generate/package.json new file mode 100644 index 000000000000..aacf8c6aab81 --- /dev/null +++ b/packages/kbn-generate/package.json @@ -0,0 +1,10 @@ +{ + "name": "@kbn/generate", + "version": "1.0.0", + "private": true, + "license": "SSPL-1.0 OR Elastic License 2.0", + "main": "./target_node/index.js", + "kibana": { + "devOnly": true + } +} \ No newline at end of file diff --git a/packages/kbn-generate/src/cli.ts b/packages/kbn-generate/src/cli.ts new file mode 100644 index 000000000000..9ade4f68366f --- /dev/null +++ b/packages/kbn-generate/src/cli.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { RunWithCommands } from '@kbn/dev-utils'; + +import { Render } from './lib/render'; +import { ContextExtensions } from './generate_command'; + +import { PackageCommand } from './commands/package_command'; + +/** + * Runs the generate CLI. Called by `node scripts/generate` and not intended for use outside of that script + */ +export function runGenerateCli() { + new RunWithCommands( + { + description: 'Run generators for different components in Kibana', + extendContext(context) { + return { + render: new Render(context.log), + }; + }, + }, + [PackageCommand] + ).execute(); +} diff --git a/packages/kbn-generate/src/commands/package_command.ts b/packages/kbn-generate/src/commands/package_command.ts new file mode 100644 index 000000000000..284b3b96a030 --- /dev/null +++ b/packages/kbn-generate/src/commands/package_command.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 Fsp from 'fs/promises'; +import Path from 'path'; + +import normalizePath from 'normalize-path'; +import globby from 'globby'; + +import { REPO_ROOT } from '@kbn/utils'; +import { discoverBazelPackages, generatePackagesBuildBazelFile } from '@kbn/bazel-packages'; +import { createFailError, createFlagError, isFailError, sortPackageJson } from '@kbn/dev-utils'; + +import { ROOT_PKG_DIR, PKG_TEMPLATE_DIR } from '../paths'; +import type { GenerateCommand } from '../generate_command'; + +export const PackageCommand: GenerateCommand = { + name: 'package', + description: 'Generate a basic package', + usage: 'node scripts/generate package [name]', + flags: { + boolean: ['web', 'force', 'dev'], + string: ['dir'], + help: ` + --dev Generate a package which is intended for dev-only use and can access things like devDependencies + --web Build webpack-compatible version of sources for this package. If your package is intended to be + used in the browser and Node.js then you need to opt-into these sources being created. + --dir Directory where this package will live, defaults to [./packages] + --force If the packageDir already exists, delete it before generation + `, + }, + async run({ log, flags, render }) { + const [name] = flags._; + if (!name) { + throw createFlagError(`missing package name`); + } + if (!name.startsWith('@kbn/')) { + throw createFlagError(`package name must start with @kbn/`); + } + + const typePkgName = `@types/${name.slice(1).replace('/', '__')}`; + const web = !!flags.web; + const dev = !!flags.dev; + + const containingDir = flags.dir ? Path.resolve(`${flags.dir}`) : ROOT_PKG_DIR; + const packageDir = Path.resolve(containingDir, name.slice(1).replace('/', '-')); + const repoRelativeDir = normalizePath(Path.relative(REPO_ROOT, packageDir)); + + try { + await Fsp.readdir(packageDir); + if (!!flags.force) { + await Fsp.rm(packageDir, { recursive: true }); + log.warning('deleted existing package at', packageDir); + } else { + throw createFailError( + `Package dir [${packageDir}] already exists, either choose a new package name, or pass --force to delete the package and regenerate it` + ); + } + } catch (error) { + if (isFailError(error)) { + throw error; + } + } + + const templateFiles = await globby('**/*', { + cwd: PKG_TEMPLATE_DIR, + absolute: false, + dot: true, + onlyFiles: true, + }); + + if (!templateFiles.length) { + throw new Error('unable to find package template files'); + } + + await Fsp.mkdir(packageDir, { recursive: true }); + + for (const rel of templateFiles) { + const destDir = Path.resolve(packageDir, Path.dirname(rel)); + + await Fsp.mkdir(destDir, { recursive: true }); + + if (Path.basename(rel) === '.empty') { + log.debug('created dir', destDir); + // ignore .empty files in the template, just create the directory + continue; + } + + const ejs = !!rel.endsWith('.ejs'); + const src = Path.resolve(PKG_TEMPLATE_DIR, rel); + const dest = Path.resolve(packageDir, ejs ? rel.slice(0, -4) : rel); + + if (!ejs) { + // read+write rather than `Fsp.copyFile` so that permissions of bazel-out are not copied to target + await Fsp.writeFile(dest, await Fsp.readFile(src)); + log.debug('copied', rel); + continue; + } + + await render.toFile(src, dest, { + pkg: { + name, + web, + dev, + directoryName: Path.basename(repoRelativeDir), + repoRelativeDir, + }, + }); + } + + log.info('Wrote plugin files to', packageDir); + + const packageJsonPath = Path.resolve(REPO_ROOT, 'package.json'); + const packageJson = JSON.parse(await Fsp.readFile(packageJsonPath, 'utf8')); + + const [addDeps, removeDeps] = dev + ? [packageJson.devDependencies, packageJson.dependencies] + : [packageJson.dependencies, packageJson.devDependencies]; + + addDeps[name] = `link:bazel-bin/${repoRelativeDir}`; + addDeps[typePkgName] = `link:bazel-bin/${repoRelativeDir}/npm_module_types`; + delete removeDeps[name]; + delete removeDeps[typePkgName]; + + await Fsp.writeFile(packageJsonPath, sortPackageJson(JSON.stringify(packageJson))); + log.info('Updated package.json file'); + + await Fsp.writeFile( + Path.resolve(REPO_ROOT, 'packages/BUILD.bazel'), + generatePackagesBuildBazelFile(await discoverBazelPackages()) + ); + log.info('Updated packages/BUILD.bazel'); + + log.success(`Generated ${name}! Please bootstrap to make sure it works.`); + }, +}; diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js b/packages/kbn-generate/src/generate_command.ts similarity index 61% rename from packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js rename to packages/kbn-generate/src/generate_command.ts index b68a5115553f..180fcee890c4 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js +++ b/packages/kbn-generate/src/generate_command.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -export default async function ({ readConfigFile }) { - const config4 = await readConfigFile(require.resolve('./config.4')); - return { - testFiles: ['baz'], - screenshots: { - ...config4.get('screenshots'), - }, - }; +import { Command } from '@kbn/dev-utils'; + +import { Render } from './lib/render'; + +export interface ContextExtensions { + render: Render; } + +export type GenerateCommand = Command; diff --git a/packages/kbn-generate/src/index.ts b/packages/kbn-generate/src/index.ts new file mode 100644 index 000000000000..7ee97ec9aa05 --- /dev/null +++ b/packages/kbn-generate/src/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 * from './cli'; diff --git a/packages/kbn-generate/src/lib/render.ts b/packages/kbn-generate/src/lib/render.ts new file mode 100644 index 000000000000..23595dbbe43d --- /dev/null +++ b/packages/kbn-generate/src/lib/render.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 Path from 'path'; +import Fsp from 'fs/promises'; + +import Ejs from 'ejs'; +import normalizePath from 'normalize-path'; +import { ToolingLog, sortPackageJson } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; + +export type Vars = Record; +export interface RenderContext extends Vars { + /** + * convert any serializable value into prett-printed JSON + */ + json(arg: any): string; + /** + * convert string values into valid and pretty JS + */ + js(arg: string): string; + /** + * create a normalized relative path from the generated files location to a repo-relative path + */ + relativePathTo(rootRelativePath: string): string; +} + +export class Render { + jsonHelper: RenderContext['json'] = (arg) => JSON.stringify(arg, null, 2); + jsHelper: RenderContext['js'] = (arg) => { + if (typeof arg !== 'string') { + throw new Error('js() only supports strings right now'); + } + + const hasSingle = arg.includes(`'`); + const hasBacktick = arg.includes('`'); + + if (!hasSingle) { + return `'${arg}'`; + } + + if (!hasBacktick) { + return `\`${arg}\``; + } + + return `'${arg.replaceAll(`'`, `\\'`)}'`; + }; + + constructor(private readonly log: ToolingLog) {} + + /** + * Render an ejs template to a string + */ + async toString(templatePath: string, destPath: string, vars: Vars) { + const context: RenderContext = { + ...vars, + + // helpers + json: this.jsonHelper, + js: this.jsHelper, + relativePathTo: (rootRelativePath: string) => + normalizePath( + Path.relative(Path.dirname(destPath), Path.resolve(REPO_ROOT, rootRelativePath)) + ), + }; + + this.log.debug('Rendering', templatePath, 'with context', context); + const content = await Ejs.renderFile(templatePath, context); + return Path.basename(destPath) === 'package.json' ? sortPackageJson(content) : content; + } + + /** + * Render an ejs template to a file + */ + async toFile(templatePath: string, destPath: string, vars: Vars) { + const content = await this.toString(templatePath, destPath, vars); + this.log.debug('Writing to', destPath); + return await Fsp.writeFile(destPath, content); + } +} diff --git a/packages/kbn-generate/src/paths.ts b/packages/kbn-generate/src/paths.ts new file mode 100644 index 000000000000..c26c82a39f8a --- /dev/null +++ b/packages/kbn-generate/src/paths.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; + +import { REPO_ROOT } from '@kbn/utils'; + +export const ROOT_PKG_DIR = Path.resolve(REPO_ROOT, 'packages'); +export const TEMPLATE_DIR = Path.resolve(__dirname, '../templates'); +export const PKG_TEMPLATE_DIR = Path.resolve(TEMPLATE_DIR, 'package'); diff --git a/packages/kbn-generate/templates/package/BUILD.bazel.ejs b/packages/kbn-generate/templates/package/BUILD.bazel.ejs new file mode 100644 index 000000000000..1e7a198f6d9b --- /dev/null +++ b/packages/kbn-generate/templates/package/BUILD.bazel.ejs @@ -0,0 +1,121 @@ +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 = <%- json(pkg.directoryName) %> +PKG_REQUIRE_NAME = <%- json(pkg.name) %> + +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 = [ +] + +# 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", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) +<% if (pkg.web) { %> +jsts_transpiler( + name = "target_web", + srcs = SRCS, + build_pkg_name = package_name(), + web = True, +) +<% } %> +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 + <%- pkg.web ? '[":target_node", ":target_web"]' : '[":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-generate/templates/package/README.md.ejs b/packages/kbn-generate/templates/package/README.md.ejs new file mode 100644 index 000000000000..769f536648a6 --- /dev/null +++ b/packages/kbn-generate/templates/package/README.md.ejs @@ -0,0 +1,3 @@ +# <%- pkg.name %> + +Empty package generated by @kbn/generate diff --git a/packages/kbn-generate/templates/package/jest.config.js.ejs b/packages/kbn-generate/templates/package/jest.config.js.ejs new file mode 100644 index 000000000000..1846d6a8f96f --- /dev/null +++ b/packages/kbn-generate/templates/package/jest.config.js.ejs @@ -0,0 +1,5 @@ +module.exports = { + preset: <%- js(pkg.web ? '@kbn/test' : '@kbn/test/jest_node') %>, + rootDir: '../..', + roots: [<%- js(`/${pkg.repoRelativeDir}`) %>], +}; diff --git a/packages/kbn-generate/templates/package/package.json.ejs b/packages/kbn-generate/templates/package/package.json.ejs new file mode 100644 index 000000000000..93b2813daffa --- /dev/null +++ b/packages/kbn-generate/templates/package/package.json.ejs @@ -0,0 +1,15 @@ +{ + "name": <%- json(pkg.name) %>, + "version": "1.0.0", + "private": true, + "license": "SSPL-1.0 OR Elastic License 2.0", + "main": "./target_node/index.js" + <%_ if (pkg.web) { %>, + "browser": "./target_web/index.js" + <%_ } %> + <% if (pkg.dev) { %>, + "kibana": { + "devOnly": true + } + <% } %> +} diff --git a/packages/kbn-generate/templates/package/src/index.ts b/packages/kbn-generate/templates/package/src/index.ts new file mode 100644 index 000000000000..7657a38ed92e --- /dev/null +++ b/packages/kbn-generate/templates/package/src/index.ts @@ -0,0 +1,3 @@ +export function foo() { + return 'hello world'; +} diff --git a/packages/kbn-generate/templates/package/tsconfig.json.ejs b/packages/kbn-generate/templates/package/tsconfig.json.ejs new file mode 100644 index 000000000000..55edb60925e3 --- /dev/null +++ b/packages/kbn-generate/templates/package/tsconfig.json.ejs @@ -0,0 +1,17 @@ +{ + "extends": "<%- relativePathTo("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-generate/tsconfig.json b/packages/kbn-generate/tsconfig.json new file mode 100644 index 000000000000..a8cfc2cceb08 --- /dev/null +++ b/packages/kbn-generate/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-optimizer/BUILD.bazel b/packages/kbn-optimizer/BUILD.bazel index 680ca0e58b8e..057e00066ddf 100644 --- a/packages/kbn-optimizer/BUILD.bazel +++ b/packages/kbn-optimizer/BUILD.bazel @@ -46,7 +46,6 @@ RUNTIME_DEPS = [ "@npm//dedent", "@npm//del", "@npm//execa", - "@npm//jest-diff", "@npm//json-stable-stringify", "@npm//js-yaml", "@npm//lmdb-store", @@ -76,7 +75,6 @@ TYPES_DEPS = [ "@npm//cpy", "@npm//del", "@npm//execa", - "@npm//jest-diff", "@npm//lmdb-store", "@npm//pirates", "@npm//rxjs", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 3a999272c3a4..4df5d02e010d 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -41,7 +41,7 @@ pageLoadAssetSize: monitoring: 80000 navigation: 37269 newsfeed: 42228 - observability: 89709 + observability: 95000 painlessLab: 179748 remoteClusters: 51327 rollup: 97204 @@ -121,4 +121,6 @@ pageLoadAssetSize: expressionPartitionVis: 26338 sharedUX: 16225 ux: 20784 + sessionView: 77750 cloudSecurityPosture: 19109 + visTypeGauge: 24113 \ No newline at end of file diff --git a/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index a7d8a5092763..4d3d17c5242c 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -3,5 +3,8 @@ "version": "1.0.0", "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", - "main": "./target_node/index.js" + "main": "./target_node/index.js", + "kibana": { + "devOnly": true + } } \ No newline at end of file diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index ddcdd980153f..52a183bc04b4 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -214,14 +214,7 @@ exports[`prepares assets for distribution: bar bundle 1`] = ` function (e, n, t) { \\"use strict\\"; var r, - o = function () { - return ( - void 0 === r && - (r = Boolean(window && document && document.all && !window.atob)), - r - ); - }, - i = (function () { + o = (function () { var e = {}; return function (n) { if (void 0 === e[n]) { @@ -240,37 +233,37 @@ exports[`prepares assets for distribution: bar bundle 1`] = ` return e[n]; }; })(), - a = []; + i = []; function c(e) { - for (var n = -1, t = 0; t < a.length; t++) - if (a[t].identifier === e) { + for (var n = -1, t = 0; t < i.length; t++) + if (i[t].identifier === e) { n = t; break; } return n; } - function u(e, n) { + function a(e, n) { for (var t = {}, r = [], o = 0; o < e.length; o++) { - var i = e[o], - u = n.base ? i[0] + n.base : i[0], + var a = e[o], + u = n.base ? a[0] + n.base : a[0], s = t[u] || 0, f = \\"\\".concat(u, \\" \\").concat(s); t[u] = s + 1; var l = c(f), - d = { css: i[1], media: i[2], sourceMap: i[3] }; + d = { css: a[1], media: a[2], sourceMap: a[3] }; -1 !== l - ? (a[l].references++, a[l].updater(d)) - : a.push({ identifier: f, updater: h(d, n), references: 1 }), + ? (i[l].references++, i[l].updater(d)) + : i.push({ identifier: f, updater: v(d, n), references: 1 }), r.push(f); } return r; } - function s(e) { + function u(e) { var n = document.createElement(\\"style\\"), r = e.attributes || {}; if (void 0 === r.nonce) { - var o = t.nc; - o && (r.nonce = o); + var i = t.nc; + i && (r.nonce = i); } if ( (Object.keys(r).forEach(function (e) { @@ -280,36 +273,36 @@ exports[`prepares assets for distribution: bar bundle 1`] = ` ) e.insert(n); else { - var a = i(e.insert || \\"head\\"); - if (!a) + var c = o(e.insert || \\"head\\"); + if (!c) throw new Error( \\"Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid.\\" ); - a.appendChild(n); + c.appendChild(n); } return n; } - var f, - l = - ((f = []), + var s, + f = + ((s = []), function (e, n) { - return (f[e] = n), f.filter(Boolean).join(\\"\\\\n\\"); + return (s[e] = n), s.filter(Boolean).join(\\"\\\\n\\"); }); - function d(e, n, t, r) { + function l(e, n, t, r) { var o = t ? \\"\\" : r.media ? \\"@media \\".concat(r.media, \\" {\\").concat(r.css, \\"}\\") : r.css; - if (e.styleSheet) e.styleSheet.cssText = l(n, o); + if (e.styleSheet) e.styleSheet.cssText = f(n, o); else { var i = document.createTextNode(o), - a = e.childNodes; - a[n] && e.removeChild(a[n]), - a.length ? e.insertBefore(i, a[n]) : e.appendChild(i); + c = e.childNodes; + c[n] && e.removeChild(c[n]), + c.length ? e.insertBefore(i, c[n]) : e.appendChild(i); } } - function p(e, n, t) { + function d(e, n, t) { var r = t.css, o = t.media, i = t.sourceMap; @@ -329,18 +322,18 @@ exports[`prepares assets for distribution: bar bundle 1`] = ` e.appendChild(document.createTextNode(r)); } } - var v = null, + var p = null, b = 0; - function h(e, n) { + function v(e, n) { var t, r, o; if (n.singleton) { var i = b++; - (t = v || (v = s(n))), - (r = d.bind(null, t, i, !1)), - (o = d.bind(null, t, i, !0)); + (t = p || (p = u(n))), + (r = l.bind(null, t, i, !1)), + (o = l.bind(null, t, i, !0)); } else - (t = s(n)), - (r = p.bind(null, t, n)), + (t = u(n)), + (r = d.bind(null, t, n)), (o = function () { !(function (e) { if (null === e.parentNode) return !1; @@ -365,8 +358,11 @@ exports[`prepares assets for distribution: bar bundle 1`] = ` e.exports = function (e, n) { (n = n || {}).singleton || \\"boolean\\" == typeof n.singleton || - (n.singleton = o()); - var t = u((e = e || []), n); + (n.singleton = + (void 0 === r && + (r = Boolean(window && document && document.all && !window.atob)), + r)); + var t = a((e = e || []), n); return function (e) { if ( ((e = e || []), @@ -374,13 +370,13 @@ exports[`prepares assets for distribution: bar bundle 1`] = ` ) { for (var r = 0; r < t.length; r++) { var o = c(t[r]); - a[o].references--; + i[o].references--; } - for (var i = u(e, n), s = 0; s < t.length; s++) { + for (var u = a(e, n), s = 0; s < t.length; s++) { var f = c(t[s]); - 0 === a[f].references && (a[f].updater(), a.splice(f, 1)); + 0 === i[f].references && (i[f].updater(), i.splice(f, 1)); } - t = i; + t = u; } }; }; @@ -393,27 +389,29 @@ exports[`prepares assets for distribution: bar bundle 1`] = ` (n.toString = function () { return this.map(function (n) { var t = (function (e, n) { - var t = e[1] || \\"\\", - r = e[3]; - if (!r) return t; + var t, + r, + o, + i = e[1] || \\"\\", + c = e[3]; + if (!c) return i; if (n && \\"function\\" == typeof btoa) { - var o = - ((a = r), - (c = btoa(unescape(encodeURIComponent(JSON.stringify(a))))), - (u = + var a = + ((t = c), + (r = btoa(unescape(encodeURIComponent(JSON.stringify(t))))), + (o = \\"sourceMappingURL=data:application/json;charset=utf-8;base64,\\".concat( - c + r )), - \\"/*# \\".concat(u, \\" */\\")), - i = r.sources.map(function (e) { + \\"/*# \\".concat(o, \\" */\\")), + u = c.sources.map(function (e) { return \\"/*# sourceURL=\\" - .concat(r.sourceRoot || \\"\\") + .concat(c.sourceRoot || \\"\\") .concat(e, \\" */\\"); }); - return [t].concat(i).concat([o]).join(\\"\\\\n\\"); + return [i].concat(u).concat([a]).join(\\"\\\\n\\"); } - var a, c, u; - return [t].join(\\"\\\\n\\"); + return [i].join(\\"\\\\n\\"); })(n, e); return n[2] ? \\"@media \\".concat(n[2], \\" {\\").concat(t, \\"}\\") : t; }).join(\\"\\"); @@ -423,11 +421,11 @@ exports[`prepares assets for distribution: bar bundle 1`] = ` var o = {}; if (r) for (var i = 0; i < this.length; i++) { - var a = this[i][0]; - null != a && (o[a] = !0); + var c = this[i][0]; + null != c && (o[c] = !0); } - for (var c = 0; c < e.length; c++) { - var u = [].concat(e[c]); + for (var a = 0; a < e.length; a++) { + var u = [].concat(e[a]); (r && o[u[0]]) || (t && (u[2] @@ -464,9 +462,7 @@ exports[`prepares assets for distribution: bar bundle 1`] = ` o = t(7); \\"string\\" == typeof (o = o.__esModule ? o.default : o) && (o = [[e.i, o, \\"\\"]]); - var i = { insert: \\"head\\", singleton: !1 }; - r(o, i); - e.exports = o.locals || {}; + r(o, { insert: \\"head\\", singleton: !1 }), (e.exports = o.locals || {}); }, function (e, n, t) { (n = t(1)(!1)).push([ @@ -481,9 +477,7 @@ exports[`prepares assets for distribution: bar bundle 1`] = ` o = t(9); \\"string\\" == typeof (o = o.__esModule ? o.default : o) && (o = [[e.i, o, \\"\\"]]); - var i = { insert: \\"head\\", singleton: !1 }; - r(o, i); - e.exports = o.locals || {}; + r(o, { insert: \\"head\\", singleton: !1 }), (e.exports = o.locals || {}); }, function (e, n, t) { (n = t(1)(!1)).push([ @@ -506,9 +500,7 @@ exports[`prepares assets for distribution: bar bundle 1`] = ` o = t(12); \\"string\\" == typeof (o = o.__esModule ? o.default : o) && (o = [[e.i, o, \\"\\"]]); - var i = { insert: \\"head\\", singleton: !1 }; - r(o, i); - e.exports = o.locals || {}; + r(o, { insert: \\"head\\", singleton: !1 }), (e.exports = o.locals || {}); }, function (e, n, t) { (n = t(1)(!1)).push([e.i, \\"body{color:green}\\\\n\\", \\"\\"]), (e.exports = n); @@ -518,9 +510,7 @@ exports[`prepares assets for distribution: bar bundle 1`] = ` o = t(14); \\"string\\" == typeof (o = o.__esModule ? o.default : o) && (o = [[e.i, o, \\"\\"]]); - var i = { insert: \\"head\\", singleton: !1 }; - r(o, i); - e.exports = o.locals || {}; + r(o, { insert: \\"head\\", singleton: !1 }), (e.exports = o.locals || {}); }, function (e, n, t) { (n = t(1)(!1)).push([e.i, \\"body{color:green}\\\\n\\", \\"\\"]), (e.exports = n); @@ -533,8 +523,9 @@ exports[`prepares assets for distribution: bar bundle 1`] = ` }), t.d(n, \\"fooLibFn\\", function () { return r.fooLibFn; - }); - t(5), t(10); + }), + t(5), + t(10); var r = t(2); function o() { return \\"bar\\"; diff --git a/packages/kbn-optimizer/src/log_optimizer_state.ts b/packages/kbn-optimizer/src/log_optimizer_state.ts index 517e3bbfa513..060f05a445eb 100644 --- a/packages/kbn-optimizer/src/log_optimizer_state.ts +++ b/packages/kbn-optimizer/src/log_optimizer_state.ts @@ -95,16 +95,16 @@ export function logOptimizerState(log: ToolingLog, config: OptimizerConfig) { if (state.phase === 'issue') { log.error(`webpack compile errors`); - log.indent(4); - for (const b of state.compilerStates) { - if (b.type === 'compiler issue') { - log.error(`[${b.bundleId}] build`); - log.indent(4); - log.error(b.failure); - log.indent(-4); + log.indent(4, () => { + for (const b of state.compilerStates) { + if (b.type === 'compiler issue') { + log.error(`[${b.bundleId}] build`); + log.indent(4, () => { + log.error(b.failure); + }); + } } - } - log.indent(-4); + }); return; } diff --git a/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts b/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts index 335a4fd7f74c..728736284947 100644 --- a/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts +++ b/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts @@ -8,11 +8,10 @@ import Path from 'path'; -import jestDiff from 'jest-diff'; import { REPO_ROOT } from '@kbn/utils'; import { createAbsolutePathSerializer } from '@kbn/dev-utils'; -import { reformatJestDiff, getOptimizerCacheKey, diffCacheKey } from './cache_keys'; +import { getOptimizerCacheKey } from './cache_keys'; import { OptimizerConfig } from './optimizer_config'; jest.mock('./get_changes.ts', () => ({ @@ -101,98 +100,3 @@ describe('getOptimizerCacheKey()', () => { `); }); }); - -describe('diffCacheKey()', () => { - it('returns undefined if values are equal', () => { - expect(diffCacheKey('1', '1')).toBe(undefined); - expect(diffCacheKey(1, 1)).toBe(undefined); - expect(diffCacheKey(['1', '2', { a: 'b' }], ['1', '2', { a: 'b' }])).toBe(undefined); - expect( - diffCacheKey( - { - a: '1', - b: '2', - }, - { - b: '2', - a: '1', - } - ) - ).toBe(undefined); - }); - - it('returns a diff if the values are different', () => { - expect(diffCacheKey(['1', '2', { a: 'b' }], ['1', '2', { b: 'a' }])).toMatchInlineSnapshot(` - "- Expected - + Received - -  [ -  \\"1\\", -  \\"2\\", -  { - - \\"a\\": \\"b\\" - + \\"b\\": \\"a\\" -  } -  ]" - `); - expect( - diffCacheKey( - { - a: '1', - b: '1', - }, - { - b: '2', - a: '2', - } - ) - ).toMatchInlineSnapshot(` - "- Expected - + Received - -  { - - \\"a\\": \\"1\\", - - \\"b\\": \\"1\\" - + \\"a\\": \\"2\\", - + \\"b\\": \\"2\\" -  }" - `); - }); -}); - -describe('reformatJestDiff()', () => { - it('reformats large jestDiff output to focus on the changed lines', () => { - const diff = jestDiff( - { - a: ['1', '1', '1', '1', '1', '1', '1', '2', '1', '1', '1', '1', '1', '1', '1', '1', '1'], - }, - { - b: ['1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '2', '1', '1', '1', '1'], - } - ); - - expect(reformatJestDiff(diff)).toMatchInlineSnapshot(` - "- Expected - + Received - -  Object { - - \\"a\\": Array [ - + \\"b\\": Array [ -  \\"1\\", -  \\"1\\", -  ... -  \\"1\\", -  \\"1\\", - - \\"2\\", -  \\"1\\", -  \\"1\\", -  ... -  \\"1\\", -  \\"1\\", - + \\"2\\", -  \\"1\\", -  \\"1\\", -  ..." - `); - }); -}); diff --git a/packages/kbn-optimizer/src/optimizer/cache_keys.ts b/packages/kbn-optimizer/src/optimizer/cache_keys.ts index da06b76327a8..a30dbe27dff0 100644 --- a/packages/kbn-optimizer/src/optimizer/cache_keys.ts +++ b/packages/kbn-optimizer/src/optimizer/cache_keys.ts @@ -9,12 +9,10 @@ import Path from 'path'; import Fs from 'fs'; -import Chalk from 'chalk'; import execa from 'execa'; import { REPO_ROOT } from '@kbn/utils'; -import stripAnsi from 'strip-ansi'; +import { diffStrings } from '@kbn/dev-utils'; -import jestDiff from 'jest-diff'; import jsonStable from 'json-stable-stringify'; import { ascending, CacheableWorkerConfig } from '../common'; @@ -36,78 +34,7 @@ export function diffCacheKey(expected?: unknown, actual?: unknown) { return; } - return reformatJestDiff(jestDiff(expectedJson, actualJson)); -} - -export function reformatJestDiff(diff: string | null) { - const diffLines = diff?.split('\n') || []; - - if ( - diffLines.length < 4 || - stripAnsi(diffLines[0]) !== '- Expected' || - stripAnsi(diffLines[1]) !== '+ Received' - ) { - throw new Error(`unexpected diff format: ${diff}`); - } - - const outputLines = [diffLines.shift(), diffLines.shift(), diffLines.shift()]; - - /** - * buffer which contains between 0 and 5 lines from the diff which aren't additions or - * deletions. The first three are the first three lines seen since the buffer was cleared - * and the last two lines are the last two lines seen. - * - * When flushContext() is called we write the first two lines to output, an elipses if there - * are five lines, and then the last two lines. - * - * At the very end we will write the last two lines of context if they're defined - */ - const contextBuffer: string[] = []; - - /** - * Convert a line to an empty line with elipses placed where the text on that line starts - */ - const toElipses = (line: string) => { - return stripAnsi(line).replace(/^(\s*).*/, '$1...'); - }; - - while (diffLines.length) { - const line = diffLines.shift()!; - const plainLine = stripAnsi(line); - if (plainLine.startsWith('+ ') || plainLine.startsWith('- ')) { - // write contextBuffer to the outputLines - if (contextBuffer.length) { - outputLines.push( - ...contextBuffer.slice(0, 2), - ...(contextBuffer.length === 5 - ? [Chalk.dim(toElipses(contextBuffer[2])), ...contextBuffer.slice(3, 5)] - : contextBuffer.slice(2, 4)) - ); - - contextBuffer.length = 0; - } - - // add this line to the outputLines - outputLines.push(line); - } else { - // update the contextBuffer with this line which doesn't represent a change - if (contextBuffer.length === 5) { - contextBuffer[3] = contextBuffer[4]; - contextBuffer[4] = line; - } else { - contextBuffer.push(line); - } - } - } - - if (contextBuffer.length) { - outputLines.push( - ...contextBuffer.slice(0, 2), - ...(contextBuffer.length > 2 ? [Chalk.dim(toElipses(contextBuffer[2]))] : []) - ); - } - - return outputLines.join('\n'); + return diffStrings(expectedJson, actualJson); } export interface OptimizerCacheKey { diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index 9454456a35c9..fe4b04548244 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -266,6 +266,9 @@ export function getWebpackConfig(bundle: Bundle, bundleRefs: BundleRefs, worker: filename: '[path].br', test: /\.(js|css)$/, cache: false, + compressionOptions: { + level: 11, + }, }), new CompressionPlugin({ algorithm: 'gzip', @@ -283,7 +286,7 @@ export function getWebpackConfig(bundle: Bundle, bundleRefs: BundleRefs, worker: extractComments: false, parallel: false, terserOptions: { - compress: true, + compress: { passes: 2 }, keep_classnames: true, mangle: true, }, diff --git a/packages/kbn-plugin-generator/package.json b/packages/kbn-plugin-generator/package.json index 28b7e849ab3c..0b6c11709b90 100644 --- a/packages/kbn-plugin-generator/package.json +++ b/packages/kbn-plugin-generator/package.json @@ -3,5 +3,8 @@ "version": "1.0.0", "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", - "main": "target_node/index.js" + "main": "target_node/index.js", + "kibana": { + "devOnly": true + } } \ No newline at end of file diff --git a/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts b/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts index 71a3fbe60371..2d7664aa1332 100644 --- a/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts +++ b/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts @@ -43,8 +43,14 @@ it('builds a generated plugin into a viable archive', async () => { all: true, } ); + const filterLogs = (logs: string | undefined) => { + return logs + ?.split('\n') + .filter((l) => !l.includes('failed to reach ci-stats service')) + .join('\n'); + }; - expect(generateProc.all).toMatchInlineSnapshot(` + expect(filterLogs(generateProc.all)).toMatchInlineSnapshot(` " succ 🎉 Your plugin has been created in plugins/foo_test_plugin @@ -60,12 +66,7 @@ it('builds a generated plugin into a viable archive', async () => { } ); - expect( - buildProc.all - ?.split('\n') - .filter((l) => !l.includes('failed to reach ci-stats service')) - .join('\n') - ).toMatchInlineSnapshot(` + expect(filterLogs(buildProc.all)).toMatchInlineSnapshot(` " info deleting the build and target directories info running @kbn/optimizer │ info initialized, 0 bundles cached diff --git a/packages/kbn-plugin-helpers/src/tasks/optimize.ts b/packages/kbn-plugin-helpers/src/tasks/optimize.ts index c0f984eb03fc..ee05fa3d3354 100644 --- a/packages/kbn-plugin-helpers/src/tasks/optimize.ts +++ b/packages/kbn-plugin-helpers/src/tasks/optimize.ts @@ -23,26 +23,25 @@ export async function optimize({ log, plugin, sourceDir, buildDir }: BuildContex } log.info('running @kbn/optimizer'); - log.indent(2); - - // build bundles into target - const config = OptimizerConfig.create({ - repoRoot: REPO_ROOT, - pluginPaths: [sourceDir], - cache: false, - dist: true, - filter: [plugin.manifest.id], + await log.indent(2, async () => { + // build bundles into target + const config = OptimizerConfig.create({ + repoRoot: REPO_ROOT, + pluginPaths: [sourceDir], + cache: false, + dist: true, + filter: [plugin.manifest.id], + }); + + const target = Path.resolve(sourceDir, 'target'); + + await runOptimizer(config).pipe(logOptimizerState(log, config)).toPromise(); + + // clean up unnecessary files + Fs.unlinkSync(Path.resolve(target, 'public/metrics.json')); + Fs.unlinkSync(Path.resolve(target, 'public/.kbn-optimizer-cache')); + + // move target into buildDir + await asyncRename(target, Path.resolve(buildDir, 'target')); }); - - const target = Path.resolve(sourceDir, 'target'); - - await runOptimizer(config).pipe(logOptimizerState(log, config)).toPromise(); - - // clean up unnecessary files - Fs.unlinkSync(Path.resolve(target, 'public/metrics.json')); - Fs.unlinkSync(Path.resolve(target, 'public/.kbn-optimizer-cache')); - - // move target into buildDir - await asyncRename(target, Path.resolve(buildDir, 'target')); - log.indent(-2); } diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 647d42181959..6c64d0bd2ca0 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -8817,12 +8817,11 @@ exports.ToolingLogCollectingWriter = ToolingLogCollectingWriter; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "commands", function() { return commands; }); /* harmony import */ var _bootstrap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(130); -/* harmony import */ var _build__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(528); -/* harmony import */ var _clean__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(529); -/* harmony import */ var _reset__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(553); -/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(554); -/* harmony import */ var _watch__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(556); -/* harmony import */ var _patch_native_modules__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(557); +/* harmony import */ var _build__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(529); +/* harmony import */ var _clean__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(530); +/* harmony import */ var _reset__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(554); +/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(555); +/* harmony import */ var _watch__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(557); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -8836,15 +8835,13 @@ __webpack_require__.r(__webpack_exports__); - const commands = { bootstrap: _bootstrap__WEBPACK_IMPORTED_MODULE_0__["BootstrapCommand"], build: _build__WEBPACK_IMPORTED_MODULE_1__["BuildCommand"], clean: _clean__WEBPACK_IMPORTED_MODULE_2__["CleanCommand"], reset: _reset__WEBPACK_IMPORTED_MODULE_3__["ResetCommand"], run: _run__WEBPACK_IMPORTED_MODULE_4__["RunCommand"], - watch: _watch__WEBPACK_IMPORTED_MODULE_5__["WatchCommand"], - patch_native_modules: _patch_native_modules__WEBPACK_IMPORTED_MODULE_6__["PatchNativeModulesCommand"] + watch: _watch__WEBPACK_IMPORTED_MODULE_5__["WatchCommand"] }; /***/ }), @@ -8864,9 +8861,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(340); /* harmony import */ var _utils_yarn_lock__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(408); /* harmony import */ var _utils_sort_package_json__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(411); -/* harmony import */ var _utils_validate_dependencies__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(419); -/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(421); -/* harmony import */ var _utils_bazel_setup_remote_cache__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(527); +/* harmony import */ var _utils_validate_dependencies__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(420); +/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(422); +/* harmony import */ var _utils_bazel_setup_remote_cache__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(528); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } @@ -8911,10 +8908,24 @@ const BootstrapCommand = { const kibanaProjectPath = ((_projects$get = projects.get('kibana')) === null || _projects$get === void 0 ? void 0 : _projects$get.path) || ''; const runOffline = (options === null || options === void 0 ? void 0 : options.offline) === true; const reporter = _kbn_dev_utils_ci_stats_reporter__WEBPACK_IMPORTED_MODULE_1__["CiStatsReporter"].fromEnv(_utils_log__WEBPACK_IMPORTED_MODULE_2__["log"]); - const timings = []; // Force install is set in case a flag is passed or + const timings = []; + + const time = async (id, body) => { + const start = Date.now(); + + try { + return await body(); + } finally { + timings.push({ + id, + ms: Date.now() - start + }); + } + }; // Force install is set in case a flag is passed or // if the `.yarn-integrity` file is not found which // will be indicated by the return of yarnIntegrityFileExists. + const forceInstall = !!options && options['force-install'] === true || !(await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_9__["yarnIntegrityFileExists"])(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(kibanaProjectPath, 'node_modules'))); // Ensure we have a `node_modules/.yarn-integrity` file as we depend on it // for bazel to know it has to re-install the node_modules after a reset or a clean @@ -8933,20 +8944,14 @@ const BootstrapCommand = { // if (forceInstall) { - const forceInstallStartTime = Date.now(); - await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_9__["runBazel"])(['run', '@nodejs//:yarn'], runOffline); - timings.push({ - id: 'force install dependencies', - ms: Date.now() - forceInstallStartTime + await time('force install dependencies', async () => { + await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_9__["runBazel"])(['run', '@nodejs//:yarn'], runOffline); }); } // build packages - const packageStartTime = Date.now(); - await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_9__["runBazel"])(['build', '//packages:build', '--show_result=1'], runOffline); - timings.push({ - id: 'build packages', - ms: Date.now() - packageStartTime + await time('build packages', async () => { + await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_9__["runBazel"])(['build', '//packages:build', '--show_result=1'], runOffline); }); // Install monorepo npm dependencies outside of the Bazel managed ones for (const batch of batchedNonBazelProjects) { @@ -8968,25 +8973,33 @@ const BootstrapCommand = { } } - await Object(_utils_sort_package_json__WEBPACK_IMPORTED_MODULE_7__["sortPackageJson"])(kbn); - const yarnLock = await Object(_utils_yarn_lock__WEBPACK_IMPORTED_MODULE_6__["readYarnLock"])(kbn); + await time('sort package json', async () => { + await Object(_utils_sort_package_json__WEBPACK_IMPORTED_MODULE_7__["sortPackageJson"])(kbn); + }); + const yarnLock = await time('read yarn.lock', async () => await Object(_utils_yarn_lock__WEBPACK_IMPORTED_MODULE_6__["readYarnLock"])(kbn)); if (options.validate) { - await Object(_utils_validate_dependencies__WEBPACK_IMPORTED_MODULE_8__["validateDependencies"])(kbn, yarnLock); + await time('validate dependencies', async () => { + await Object(_utils_validate_dependencies__WEBPACK_IMPORTED_MODULE_8__["validateDependencies"])(kbn, yarnLock); + }); } // Assure all kbn projects with bin defined scripts // copy those scripts into the top level node_modules folder // // NOTE: We don't probably need this anymore, is actually not being used - await Object(_utils_link_project_executables__WEBPACK_IMPORTED_MODULE_4__["linkProjectExecutables"])(projects, projectGraph); // Update vscode settings - - await Object(_utils_child_process__WEBPACK_IMPORTED_MODULE_3__["spawnStreaming"])(process.execPath, ['scripts/update_vscode_config'], { - cwd: kbn.getAbsolute(), - env: process.env - }, { - prefix: '[vscode]', - debug: false + await time('link project executables', async () => { + await Object(_utils_link_project_executables__WEBPACK_IMPORTED_MODULE_4__["linkProjectExecutables"])(projects, projectGraph); + }); + await time('update vscode config', async () => { + // Update vscode settings + await Object(_utils_child_process__WEBPACK_IMPORTED_MODULE_3__["spawnStreaming"])(process.execPath, ['scripts/update_vscode_config'], { + cwd: kbn.getAbsolute(), + env: process.env + }, { + prefix: '[vscode]', + debug: false + }); }); // send timings await reporter.timings({ @@ -19770,10 +19783,10 @@ exports.realpath = function realpath(p, cache, cb) { module.exports = minimatch minimatch.Minimatch = Minimatch -var path = { sep: '/' } -try { - path = __webpack_require__(4) -} catch (er) {} +var path = (function () { try { return __webpack_require__(4) } catch (e) {}}()) || { + sep: '/' +} +minimatch.sep = path.sep var GLOBSTAR = minimatch.GLOBSTAR = Minimatch.GLOBSTAR = {} var expand = __webpack_require__(248) @@ -19825,43 +19838,64 @@ function filter (pattern, options) { } function ext (a, b) { - a = a || {} b = b || {} var t = {} - Object.keys(b).forEach(function (k) { - t[k] = b[k] - }) Object.keys(a).forEach(function (k) { t[k] = a[k] }) + Object.keys(b).forEach(function (k) { + t[k] = b[k] + }) return t } minimatch.defaults = function (def) { - if (!def || !Object.keys(def).length) return minimatch + if (!def || typeof def !== 'object' || !Object.keys(def).length) { + return minimatch + } var orig = minimatch var m = function minimatch (p, pattern, options) { - return orig.minimatch(p, pattern, ext(def, options)) + return orig(p, pattern, ext(def, options)) } m.Minimatch = function Minimatch (pattern, options) { return new orig.Minimatch(pattern, ext(def, options)) } + m.Minimatch.defaults = function defaults (options) { + return orig.defaults(ext(def, options)).Minimatch + } + + m.filter = function filter (pattern, options) { + return orig.filter(pattern, ext(def, options)) + } + + m.defaults = function defaults (options) { + return orig.defaults(ext(def, options)) + } + + m.makeRe = function makeRe (pattern, options) { + return orig.makeRe(pattern, ext(def, options)) + } + + m.braceExpand = function braceExpand (pattern, options) { + return orig.braceExpand(pattern, ext(def, options)) + } + + m.match = function (list, pattern, options) { + return orig.match(list, pattern, ext(def, options)) + } return m } Minimatch.defaults = function (def) { - if (!def || !Object.keys(def).length) return Minimatch return minimatch.defaults(def).Minimatch } function minimatch (p, pattern, options) { - if (typeof pattern !== 'string') { - throw new TypeError('glob pattern string required') - } + assertValidPattern(pattern) if (!options) options = {} @@ -19870,9 +19904,6 @@ function minimatch (p, pattern, options) { return false } - // "" only matches "" - if (pattern.trim() === '') return p === '' - return new Minimatch(pattern, options).match(p) } @@ -19881,15 +19912,14 @@ function Minimatch (pattern, options) { return new Minimatch(pattern, options) } - if (typeof pattern !== 'string') { - throw new TypeError('glob pattern string required') - } + assertValidPattern(pattern) if (!options) options = {} + pattern = pattern.trim() // windows support: need to use /, not \ - if (path.sep !== '/') { + if (!options.allowWindowsEscape && path.sep !== '/') { pattern = pattern.split(path.sep).join('/') } @@ -19900,6 +19930,7 @@ function Minimatch (pattern, options) { this.negate = false this.comment = false this.empty = false + this.partial = !!options.partial // make the set of regexps etc. this.make() @@ -19909,9 +19940,6 @@ Minimatch.prototype.debug = function () {} Minimatch.prototype.make = make function make () { - // don't do it more than once. - if (this._made) return - var pattern = this.pattern var options = this.options @@ -19931,7 +19959,7 @@ function make () { // step 2: expand braces var set = this.globSet = this.braceExpand() - if (options.debug) this.debug = console.error + if (options.debug) this.debug = function debug() { console.error.apply(console, arguments) } this.debug(this.pattern, set) @@ -20011,12 +20039,11 @@ function braceExpand (pattern, options) { pattern = typeof pattern === 'undefined' ? this.pattern : pattern - if (typeof pattern === 'undefined') { - throw new TypeError('undefined pattern') - } + assertValidPattern(pattern) - if (options.nobrace || - !pattern.match(/\{.*\}/)) { + // Thanks to Yeting Li for + // improving this regexp to avoid a ReDOS vulnerability. + if (options.nobrace || !/\{(?:(?!\{).)*\}/.test(pattern)) { // shortcut. no need to expand. return [pattern] } @@ -20024,6 +20051,17 @@ function braceExpand (pattern, options) { return expand(pattern) } +var MAX_PATTERN_LENGTH = 1024 * 64 +var assertValidPattern = function (pattern) { + if (typeof pattern !== 'string') { + throw new TypeError('invalid pattern') + } + + if (pattern.length > MAX_PATTERN_LENGTH) { + throw new TypeError('pattern is too long') + } +} + // parse a component of the expanded set. // At this point, no pattern may contain "/" in it // so we're going to return a 2d array, where each entry is the full @@ -20038,14 +20076,17 @@ function braceExpand (pattern, options) { Minimatch.prototype.parse = parse var SUBPARSE = {} function parse (pattern, isSub) { - if (pattern.length > 1024 * 64) { - throw new TypeError('pattern is too long') - } + assertValidPattern(pattern) var options = this.options // shortcuts - if (!options.noglobstar && pattern === '**') return GLOBSTAR + if (pattern === '**') { + if (!options.noglobstar) + return GLOBSTAR + else + pattern = '*' + } if (pattern === '') return '' var re = '' @@ -20101,10 +20142,12 @@ function parse (pattern, isSub) { } switch (c) { - case '/': + /* istanbul ignore next */ + case '/': { // completely not allowed, even escaped. // Should already be path-split by now. return false + } case '\\': clearStateChar() @@ -20223,25 +20266,23 @@ function parse (pattern, isSub) { // handle the case where we left a class open. // "[z-a]" is valid, equivalent to "\[z-a\]" - if (inClass) { - // split where the last [ was, make sure we don't have - // an invalid re. if so, re-walk the contents of the - // would-be class to re-translate any characters that - // were passed through as-is - // TODO: It would probably be faster to determine this - // without a try/catch and a new RegExp, but it's tricky - // to do safely. For now, this is safe and works. - var cs = pattern.substring(classStart + 1, i) - try { - RegExp('[' + cs + ']') - } catch (er) { - // not a valid class! - var sp = this.parse(cs, SUBPARSE) - re = re.substr(0, reClassStart) + '\\[' + sp[0] + '\\]' - hasMagic = hasMagic || sp[1] - inClass = false - continue - } + // split where the last [ was, make sure we don't have + // an invalid re. if so, re-walk the contents of the + // would-be class to re-translate any characters that + // were passed through as-is + // TODO: It would probably be faster to determine this + // without a try/catch and a new RegExp, but it's tricky + // to do safely. For now, this is safe and works. + var cs = pattern.substring(classStart + 1, i) + try { + RegExp('[' + cs + ']') + } catch (er) { + // not a valid class! + var sp = this.parse(cs, SUBPARSE) + re = re.substr(0, reClassStart) + '\\[' + sp[0] + '\\]' + hasMagic = hasMagic || sp[1] + inClass = false + continue } // finish up the class. @@ -20325,9 +20366,7 @@ function parse (pattern, isSub) { // something that could conceivably capture a dot var addPatternStart = false switch (re.charAt(0)) { - case '.': - case '[': - case '(': addPatternStart = true + case '[': case '.': case '(': addPatternStart = true } // Hack to work around lack of negative lookbehind in JS @@ -20389,7 +20428,7 @@ function parse (pattern, isSub) { var flags = options.nocase ? 'i' : '' try { var regExp = new RegExp('^' + re + '$', flags) - } catch (er) { + } catch (er) /* istanbul ignore next - should be impossible */ { // If it was an invalid regular expression, then it can't match // anything. This trick looks for a character after the end of // the string, which is of course impossible, except in multi-line @@ -20447,7 +20486,7 @@ function makeRe () { try { this.regexp = new RegExp(re, flags) - } catch (ex) { + } catch (ex) /* istanbul ignore next - should be impossible */ { this.regexp = false } return this.regexp @@ -20465,8 +20504,8 @@ minimatch.match = function (list, pattern, options) { return list } -Minimatch.prototype.match = match -function match (f, partial) { +Minimatch.prototype.match = function match (f, partial) { + if (typeof partial === 'undefined') partial = this.partial this.debug('match', f, this.pattern) // short-circuit in the case of busted things. // comments, etc. @@ -20548,6 +20587,7 @@ Minimatch.prototype.matchOne = function (file, pattern, partial) { // should be impossible. // some invalid regexp stuff in the set. + /* istanbul ignore if */ if (p === false) return false if (p === GLOBSTAR) { @@ -20621,6 +20661,7 @@ Minimatch.prototype.matchOne = function (file, pattern, partial) { // no match was found. // However, in partial mode, we can't say this is necessarily over. // If there's more *pattern* left, then + /* istanbul ignore if */ if (partial) { // ran out of file this.debug('\n>>> no match, partial?', file, fr, pattern, pr) @@ -20634,11 +20675,7 @@ Minimatch.prototype.matchOne = function (file, pattern, partial) { // patterns with magic have been turned into regexps. var hit if (typeof p === 'string') { - if (options.nocase) { - hit = f.toLowerCase() === p.toLowerCase() - } else { - hit = f === p - } + hit = f === p this.debug('string match', p, f, hit) } else { hit = f.match(p) @@ -20669,16 +20706,16 @@ Minimatch.prototype.matchOne = function (file, pattern, partial) { // this is ok if we're doing the match as part of // a glob fs traversal. return partial - } else if (pi === pl) { + } else /* istanbul ignore else */ if (pi === pl) { // ran out of pattern, still have file left. // this is only acceptable if we're on the very last // empty segment of a file with a trailing slash. // a/* should match a/b/ - var emptyFileEnd = (fi === fl - 1) && (file[fi] === '') - return emptyFileEnd + return (fi === fl - 1) && (file[fi] === '') } // should be unreachable. + /* istanbul ignore next */ throw new Error('wtf?') } @@ -31422,7 +31459,7 @@ function getChalk(options) { } function highlight(code, options = {}) { - if (shouldHighlight(options)) { + if (code !== "" && shouldHighlight(options)) { const chalk = getChalk(options); const defs = getDefs(chalk); return highlightTokens(defs, code); @@ -31470,16 +31507,16 @@ exports.matchToToken = function(match) { Object.defineProperty(exports, "__esModule", { value: true }); -Object.defineProperty(exports, "isIdentifierName", { +Object.defineProperty(exports, "isIdentifierChar", { enumerable: true, get: function () { - return _identifier.isIdentifierName; + return _identifier.isIdentifierChar; } }); -Object.defineProperty(exports, "isIdentifierChar", { +Object.defineProperty(exports, "isIdentifierName", { enumerable: true, get: function () { - return _identifier.isIdentifierChar; + return _identifier.isIdentifierName; } }); Object.defineProperty(exports, "isIdentifierStart", { @@ -31488,6 +31525,12 @@ Object.defineProperty(exports, "isIdentifierStart", { return _identifier.isIdentifierStart; } }); +Object.defineProperty(exports, "isKeyword", { + enumerable: true, + get: function () { + return _keyword.isKeyword; + } +}); Object.defineProperty(exports, "isReservedWord", { enumerable: true, get: function () { @@ -31512,12 +31555,6 @@ Object.defineProperty(exports, "isStrictReservedWord", { return _keyword.isStrictReservedWord; } }); -Object.defineProperty(exports, "isKeyword", { - enumerable: true, - get: function () { - return _keyword.isKeyword; - } -}); var _identifier = __webpack_require__(354); @@ -31533,9 +31570,9 @@ var _keyword = __webpack_require__(355); Object.defineProperty(exports, "__esModule", { value: true }); -exports.isIdentifierStart = isIdentifierStart; exports.isIdentifierChar = isIdentifierChar; exports.isIdentifierName = isIdentifierName; +exports.isIdentifierStart = isIdentifierStart; let nonASCIIidentifierStartChars = "\xaa\xb5\xba\xc0-\xd6\xd8-\xf6\xf8-\u02c1\u02c6-\u02d1\u02e0-\u02e4\u02ec\u02ee\u0370-\u0374\u0376\u0377\u037a-\u037d\u037f\u0386\u0388-\u038a\u038c\u038e-\u03a1\u03a3-\u03f5\u03f7-\u0481\u048a-\u052f\u0531-\u0556\u0559\u0560-\u0588\u05d0-\u05ea\u05ef-\u05f2\u0620-\u064a\u066e\u066f\u0671-\u06d3\u06d5\u06e5\u06e6\u06ee\u06ef\u06fa-\u06fc\u06ff\u0710\u0712-\u072f\u074d-\u07a5\u07b1\u07ca-\u07ea\u07f4\u07f5\u07fa\u0800-\u0815\u081a\u0824\u0828\u0840-\u0858\u0860-\u086a\u0870-\u0887\u0889-\u088e\u08a0-\u08c9\u0904-\u0939\u093d\u0950\u0958-\u0961\u0971-\u0980\u0985-\u098c\u098f\u0990\u0993-\u09a8\u09aa-\u09b0\u09b2\u09b6-\u09b9\u09bd\u09ce\u09dc\u09dd\u09df-\u09e1\u09f0\u09f1\u09fc\u0a05-\u0a0a\u0a0f\u0a10\u0a13-\u0a28\u0a2a-\u0a30\u0a32\u0a33\u0a35\u0a36\u0a38\u0a39\u0a59-\u0a5c\u0a5e\u0a72-\u0a74\u0a85-\u0a8d\u0a8f-\u0a91\u0a93-\u0aa8\u0aaa-\u0ab0\u0ab2\u0ab3\u0ab5-\u0ab9\u0abd\u0ad0\u0ae0\u0ae1\u0af9\u0b05-\u0b0c\u0b0f\u0b10\u0b13-\u0b28\u0b2a-\u0b30\u0b32\u0b33\u0b35-\u0b39\u0b3d\u0b5c\u0b5d\u0b5f-\u0b61\u0b71\u0b83\u0b85-\u0b8a\u0b8e-\u0b90\u0b92-\u0b95\u0b99\u0b9a\u0b9c\u0b9e\u0b9f\u0ba3\u0ba4\u0ba8-\u0baa\u0bae-\u0bb9\u0bd0\u0c05-\u0c0c\u0c0e-\u0c10\u0c12-\u0c28\u0c2a-\u0c39\u0c3d\u0c58-\u0c5a\u0c5d\u0c60\u0c61\u0c80\u0c85-\u0c8c\u0c8e-\u0c90\u0c92-\u0ca8\u0caa-\u0cb3\u0cb5-\u0cb9\u0cbd\u0cdd\u0cde\u0ce0\u0ce1\u0cf1\u0cf2\u0d04-\u0d0c\u0d0e-\u0d10\u0d12-\u0d3a\u0d3d\u0d4e\u0d54-\u0d56\u0d5f-\u0d61\u0d7a-\u0d7f\u0d85-\u0d96\u0d9a-\u0db1\u0db3-\u0dbb\u0dbd\u0dc0-\u0dc6\u0e01-\u0e30\u0e32\u0e33\u0e40-\u0e46\u0e81\u0e82\u0e84\u0e86-\u0e8a\u0e8c-\u0ea3\u0ea5\u0ea7-\u0eb0\u0eb2\u0eb3\u0ebd\u0ec0-\u0ec4\u0ec6\u0edc-\u0edf\u0f00\u0f40-\u0f47\u0f49-\u0f6c\u0f88-\u0f8c\u1000-\u102a\u103f\u1050-\u1055\u105a-\u105d\u1061\u1065\u1066\u106e-\u1070\u1075-\u1081\u108e\u10a0-\u10c5\u10c7\u10cd\u10d0-\u10fa\u10fc-\u1248\u124a-\u124d\u1250-\u1256\u1258\u125a-\u125d\u1260-\u1288\u128a-\u128d\u1290-\u12b0\u12b2-\u12b5\u12b8-\u12be\u12c0\u12c2-\u12c5\u12c8-\u12d6\u12d8-\u1310\u1312-\u1315\u1318-\u135a\u1380-\u138f\u13a0-\u13f5\u13f8-\u13fd\u1401-\u166c\u166f-\u167f\u1681-\u169a\u16a0-\u16ea\u16ee-\u16f8\u1700-\u1711\u171f-\u1731\u1740-\u1751\u1760-\u176c\u176e-\u1770\u1780-\u17b3\u17d7\u17dc\u1820-\u1878\u1880-\u18a8\u18aa\u18b0-\u18f5\u1900-\u191e\u1950-\u196d\u1970-\u1974\u1980-\u19ab\u19b0-\u19c9\u1a00-\u1a16\u1a20-\u1a54\u1aa7\u1b05-\u1b33\u1b45-\u1b4c\u1b83-\u1ba0\u1bae\u1baf\u1bba-\u1be5\u1c00-\u1c23\u1c4d-\u1c4f\u1c5a-\u1c7d\u1c80-\u1c88\u1c90-\u1cba\u1cbd-\u1cbf\u1ce9-\u1cec\u1cee-\u1cf3\u1cf5\u1cf6\u1cfa\u1d00-\u1dbf\u1e00-\u1f15\u1f18-\u1f1d\u1f20-\u1f45\u1f48-\u1f4d\u1f50-\u1f57\u1f59\u1f5b\u1f5d\u1f5f-\u1f7d\u1f80-\u1fb4\u1fb6-\u1fbc\u1fbe\u1fc2-\u1fc4\u1fc6-\u1fcc\u1fd0-\u1fd3\u1fd6-\u1fdb\u1fe0-\u1fec\u1ff2-\u1ff4\u1ff6-\u1ffc\u2071\u207f\u2090-\u209c\u2102\u2107\u210a-\u2113\u2115\u2118-\u211d\u2124\u2126\u2128\u212a-\u2139\u213c-\u213f\u2145-\u2149\u214e\u2160-\u2188\u2c00-\u2ce4\u2ceb-\u2cee\u2cf2\u2cf3\u2d00-\u2d25\u2d27\u2d2d\u2d30-\u2d67\u2d6f\u2d80-\u2d96\u2da0-\u2da6\u2da8-\u2dae\u2db0-\u2db6\u2db8-\u2dbe\u2dc0-\u2dc6\u2dc8-\u2dce\u2dd0-\u2dd6\u2dd8-\u2dde\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303c\u3041-\u3096\u309b-\u309f\u30a1-\u30fa\u30fc-\u30ff\u3105-\u312f\u3131-\u318e\u31a0-\u31bf\u31f0-\u31ff\u3400-\u4dbf\u4e00-\ua48c\ua4d0-\ua4fd\ua500-\ua60c\ua610-\ua61f\ua62a\ua62b\ua640-\ua66e\ua67f-\ua69d\ua6a0-\ua6ef\ua717-\ua71f\ua722-\ua788\ua78b-\ua7ca\ua7d0\ua7d1\ua7d3\ua7d5-\ua7d9\ua7f2-\ua801\ua803-\ua805\ua807-\ua80a\ua80c-\ua822\ua840-\ua873\ua882-\ua8b3\ua8f2-\ua8f7\ua8fb\ua8fd\ua8fe\ua90a-\ua925\ua930-\ua946\ua960-\ua97c\ua984-\ua9b2\ua9cf\ua9e0-\ua9e4\ua9e6-\ua9ef\ua9fa-\ua9fe\uaa00-\uaa28\uaa40-\uaa42\uaa44-\uaa4b\uaa60-\uaa76\uaa7a\uaa7e-\uaaaf\uaab1\uaab5\uaab6\uaab9-\uaabd\uaac0\uaac2\uaadb-\uaadd\uaae0-\uaaea\uaaf2-\uaaf4\uab01-\uab06\uab09-\uab0e\uab11-\uab16\uab20-\uab26\uab28-\uab2e\uab30-\uab5a\uab5c-\uab69\uab70-\uabe2\uac00-\ud7a3\ud7b0-\ud7c6\ud7cb-\ud7fb\uf900-\ufa6d\ufa70-\ufad9\ufb00-\ufb06\ufb13-\ufb17\ufb1d\ufb1f-\ufb28\ufb2a-\ufb36\ufb38-\ufb3c\ufb3e\ufb40\ufb41\ufb43\ufb44\ufb46-\ufbb1\ufbd3-\ufd3d\ufd50-\ufd8f\ufd92-\ufdc7\ufdf0-\ufdfb\ufe70-\ufe74\ufe76-\ufefc\uff21-\uff3a\uff41-\uff5a\uff66-\uffbe\uffc2-\uffc7\uffca-\uffcf\uffd2-\uffd7\uffda-\uffdc"; let nonASCIIidentifierChars = "\u200c\u200d\xb7\u0300-\u036f\u0387\u0483-\u0487\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7\u0610-\u061a\u064b-\u0669\u0670\u06d6-\u06dc\u06df-\u06e4\u06e7\u06e8\u06ea-\u06ed\u06f0-\u06f9\u0711\u0730-\u074a\u07a6-\u07b0\u07c0-\u07c9\u07eb-\u07f3\u07fd\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0859-\u085b\u0898-\u089f\u08ca-\u08e1\u08e3-\u0903\u093a-\u093c\u093e-\u094f\u0951-\u0957\u0962\u0963\u0966-\u096f\u0981-\u0983\u09bc\u09be-\u09c4\u09c7\u09c8\u09cb-\u09cd\u09d7\u09e2\u09e3\u09e6-\u09ef\u09fe\u0a01-\u0a03\u0a3c\u0a3e-\u0a42\u0a47\u0a48\u0a4b-\u0a4d\u0a51\u0a66-\u0a71\u0a75\u0a81-\u0a83\u0abc\u0abe-\u0ac5\u0ac7-\u0ac9\u0acb-\u0acd\u0ae2\u0ae3\u0ae6-\u0aef\u0afa-\u0aff\u0b01-\u0b03\u0b3c\u0b3e-\u0b44\u0b47\u0b48\u0b4b-\u0b4d\u0b55-\u0b57\u0b62\u0b63\u0b66-\u0b6f\u0b82\u0bbe-\u0bc2\u0bc6-\u0bc8\u0bca-\u0bcd\u0bd7\u0be6-\u0bef\u0c00-\u0c04\u0c3c\u0c3e-\u0c44\u0c46-\u0c48\u0c4a-\u0c4d\u0c55\u0c56\u0c62\u0c63\u0c66-\u0c6f\u0c81-\u0c83\u0cbc\u0cbe-\u0cc4\u0cc6-\u0cc8\u0cca-\u0ccd\u0cd5\u0cd6\u0ce2\u0ce3\u0ce6-\u0cef\u0d00-\u0d03\u0d3b\u0d3c\u0d3e-\u0d44\u0d46-\u0d48\u0d4a-\u0d4d\u0d57\u0d62\u0d63\u0d66-\u0d6f\u0d81-\u0d83\u0dca\u0dcf-\u0dd4\u0dd6\u0dd8-\u0ddf\u0de6-\u0def\u0df2\u0df3\u0e31\u0e34-\u0e3a\u0e47-\u0e4e\u0e50-\u0e59\u0eb1\u0eb4-\u0ebc\u0ec8-\u0ecd\u0ed0-\u0ed9\u0f18\u0f19\u0f20-\u0f29\u0f35\u0f37\u0f39\u0f3e\u0f3f\u0f71-\u0f84\u0f86\u0f87\u0f8d-\u0f97\u0f99-\u0fbc\u0fc6\u102b-\u103e\u1040-\u1049\u1056-\u1059\u105e-\u1060\u1062-\u1064\u1067-\u106d\u1071-\u1074\u1082-\u108d\u108f-\u109d\u135d-\u135f\u1369-\u1371\u1712-\u1715\u1732-\u1734\u1752\u1753\u1772\u1773\u17b4-\u17d3\u17dd\u17e0-\u17e9\u180b-\u180d\u180f-\u1819\u18a9\u1920-\u192b\u1930-\u193b\u1946-\u194f\u19d0-\u19da\u1a17-\u1a1b\u1a55-\u1a5e\u1a60-\u1a7c\u1a7f-\u1a89\u1a90-\u1a99\u1ab0-\u1abd\u1abf-\u1ace\u1b00-\u1b04\u1b34-\u1b44\u1b50-\u1b59\u1b6b-\u1b73\u1b80-\u1b82\u1ba1-\u1bad\u1bb0-\u1bb9\u1be6-\u1bf3\u1c24-\u1c37\u1c40-\u1c49\u1c50-\u1c59\u1cd0-\u1cd2\u1cd4-\u1ce8\u1ced\u1cf4\u1cf7-\u1cf9\u1dc0-\u1dff\u203f\u2040\u2054\u20d0-\u20dc\u20e1\u20e5-\u20f0\u2cef-\u2cf1\u2d7f\u2de0-\u2dff\u302a-\u302f\u3099\u309a\ua620-\ua629\ua66f\ua674-\ua67d\ua69e\ua69f\ua6f0\ua6f1\ua802\ua806\ua80b\ua823-\ua827\ua82c\ua880\ua881\ua8b4-\ua8c5\ua8d0-\ua8d9\ua8e0-\ua8f1\ua8ff-\ua909\ua926-\ua92d\ua947-\ua953\ua980-\ua983\ua9b3-\ua9c0\ua9d0-\ua9d9\ua9e5\ua9f0-\ua9f9\uaa29-\uaa36\uaa43\uaa4c\uaa4d\uaa50-\uaa59\uaa7b-\uaa7d\uaab0\uaab2-\uaab4\uaab7\uaab8\uaabe\uaabf\uaac1\uaaeb-\uaaef\uaaf5\uaaf6\uabe3-\uabea\uabec\uabed\uabf0-\uabf9\ufb1e\ufe00-\ufe0f\ufe20-\ufe2f\ufe33\ufe34\ufe4d-\ufe4f\uff10-\uff19\uff3f"; const nonASCIIidentifierStart = new RegExp("[" + nonASCIIidentifierStartChars + "]"); @@ -31623,11 +31660,11 @@ function isIdentifierName(name) { Object.defineProperty(exports, "__esModule", { value: true }); +exports.isKeyword = isKeyword; exports.isReservedWord = isReservedWord; -exports.isStrictReservedWord = isStrictReservedWord; exports.isStrictBindOnlyReservedWord = isStrictBindOnlyReservedWord; exports.isStrictBindReservedWord = isStrictBindReservedWord; -exports.isKeyword = isKeyword; +exports.isStrictReservedWord = isStrictReservedWord; const reservedWords = { keyword: ["break", "case", "catch", "continue", "debugger", "default", "do", "else", "finally", "for", "function", "if", "return", "switch", "throw", "try", "var", "const", "while", "with", "new", "this", "super", "class", "extends", "export", "import", "null", "true", "false", "in", "instanceof", "typeof", "void", "delete"], strict: ["implements", "interface", "let", "package", "private", "protected", "public", "static", "yield"], @@ -41163,7 +41200,11 @@ async function installInDir(directory, extraArgs = []) { // given time (e.g. to avoid conflicts). await Object(_child_process__WEBPACK_IMPORTED_MODULE_0__["spawn"])(YARN_EXEC, options, { - cwd: directory + cwd: directory, + env: { + SASS_BINARY_SITE: 'https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/node-sass', + RE2_DOWNLOAD_MIRROR: 'https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/node-re2' + } }); } /** @@ -51597,8 +51638,8 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "sortPackageJson", function() { return sortPackageJson; }); /* harmony import */ var fs_promises__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(412); /* harmony import */ var fs_promises__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(fs_promises__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var sort_package_json__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(413); -/* harmony import */ var sort_package_json__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(sort_package_json__WEBPACK_IMPORTED_MODULE_1__); +/* harmony import */ var _kbn_dev_utils_sort_package_json__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(413); +/* harmony import */ var _kbn_dev_utils_sort_package_json__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_kbn_dev_utils_sort_package_json__WEBPACK_IMPORTED_MODULE_1__); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -51610,11 +51651,7 @@ __webpack_require__.r(__webpack_exports__); async function sortPackageJson(kbn) { const packageJsonPath = kbn.getAbsolute('package.json'); - const packageJson = await fs_promises__WEBPACK_IMPORTED_MODULE_0___default.a.readFile(packageJsonPath, 'utf-8'); - await fs_promises__WEBPACK_IMPORTED_MODULE_0___default.a.writeFile(packageJsonPath, JSON.stringify(sort_package_json__WEBPACK_IMPORTED_MODULE_1___default()(JSON.parse(packageJson), { - // top level keys in the order they were written when this was implemented - sortOrder: ['name', 'description', 'keywords', 'private', 'version', 'branch', 'types', 'tsdocMetadata', 'build', 'homepage', 'bugs', 'kibana', 'author', 'scripts', 'repository', 'engines', 'resolutions'] - }), null, 2) + '\n'); + await fs_promises__WEBPACK_IMPORTED_MODULE_0___default.a.writeFile(packageJsonPath, Object(_kbn_dev_utils_sort_package_json__WEBPACK_IMPORTED_MODULE_1__["sortPackageJson"])(await fs_promises__WEBPACK_IMPORTED_MODULE_0___default.a.readFile(packageJsonPath, 'utf-8'))); } /***/ }), @@ -51627,11 +51664,41 @@ module.exports = require("fs/promises"); /* 413 */ /***/ (function(module, exports, __webpack_require__) { -const sortObjectKeys = __webpack_require__(414) -const detectIndent = __webpack_require__(415) -const detectNewline = __webpack_require__(416).graceful -const gitHooks = __webpack_require__(417) -const isPlainObject = __webpack_require__(418) +"use strict"; + + +var _interopRequireDefault = __webpack_require__(7); + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.sortPackageJson = sortPackageJson; + +var _sortPackageJson = _interopRequireDefault(__webpack_require__(414)); + +/* + * Copyright 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. + */ +function sortPackageJson(json) { + return JSON.stringify((0, _sortPackageJson.default)(JSON.parse(json), { + // top level keys in the order they were written when this was implemented + sortOrder: ['name', 'description', 'keywords', 'private', 'version', 'branch', 'main', 'browser', 'types', 'tsdocMetadata', 'build', 'homepage', 'bugs', 'license', 'kibana', 'author', 'scripts', 'repository', 'engines', 'resolutions'] + }), null, 2) + '\n'; +} + +/***/ }), +/* 414 */ +/***/ (function(module, exports, __webpack_require__) { + +const sortObjectKeys = __webpack_require__(415) +const detectIndent = __webpack_require__(416) +const detectNewline = __webpack_require__(417).graceful +const gitHooks = __webpack_require__(418) +const isPlainObject = __webpack_require__(419) const hasOwnProperty = (object, property) => Object.prototype.hasOwnProperty.call(object, property) @@ -51990,7 +52057,7 @@ module.exports.default = sortPackageJson /***/ }), -/* 414 */ +/* 415 */ /***/ (function(module, exports) { module.exports = function sortObjectByKeyNameList(object, sortWith) { @@ -52014,7 +52081,7 @@ module.exports = function sortObjectByKeyNameList(object, sortWith) { /***/ }), -/* 415 */ +/* 416 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -52181,7 +52248,7 @@ module.exports = string => { /***/ }), -/* 416 */ +/* 417 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -52209,13 +52276,13 @@ module.exports.graceful = string => (typeof string === 'string' && detectNewline /***/ }), -/* 417 */ +/* 418 */ /***/ (function(module) { module.exports = JSON.parse("[\"applypatch-msg\",\"pre-applypatch\",\"post-applypatch\",\"pre-commit\",\"pre-merge-commit\",\"prepare-commit-msg\",\"commit-msg\",\"post-commit\",\"pre-rebase\",\"post-checkout\",\"post-merge\",\"pre-push\",\"pre-receive\",\"update\",\"post-receive\",\"post-update\",\"push-to-checkout\",\"pre-auto-gc\",\"post-rewrite\",\"sendemail-validate\",\"fsmonitor-watchman\",\"p4-pre-submit\",\"post-index-change\"]"); /***/ }), -/* 418 */ +/* 419 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -52232,7 +52299,7 @@ module.exports = value => { /***/ }), -/* 419 */ +/* 420 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -52249,7 +52316,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _fs__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(231); /* harmony import */ var _log__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(220); /* harmony import */ var _package_json__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(343); -/* harmony import */ var _projects_tree__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(420); +/* harmony import */ var _projects_tree__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(421); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -52430,7 +52497,7 @@ function getDevOnlyProductionDepsTree(kbn, projectName) { } /***/ }), -/* 420 */ +/* 421 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -52572,27 +52639,27 @@ function addProjectToTree(tree, pathParts, project) { } /***/ }), -/* 421 */ +/* 422 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); -/* harmony import */ var _yarn_integrity__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(422); +/* harmony import */ var _yarn_integrity__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(423); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "yarnIntegrityFileExists", function() { return _yarn_integrity__WEBPACK_IMPORTED_MODULE_0__["yarnIntegrityFileExists"]; }); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ensureYarnIntegrityFileExists", function() { return _yarn_integrity__WEBPACK_IMPORTED_MODULE_0__["ensureYarnIntegrityFileExists"]; }); -/* harmony import */ var _get_cache_folders__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(423); +/* harmony import */ var _get_cache_folders__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(424); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "getBazelDiskCacheFolder", function() { return _get_cache_folders__WEBPACK_IMPORTED_MODULE_1__["getBazelDiskCacheFolder"]; }); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "getBazelRepositoryCacheFolder", function() { return _get_cache_folders__WEBPACK_IMPORTED_MODULE_1__["getBazelRepositoryCacheFolder"]; }); -/* harmony import */ var _install_tools__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(424); +/* harmony import */ var _install_tools__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(425); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "isBazelBinAvailable", function() { return _install_tools__WEBPACK_IMPORTED_MODULE_2__["isBazelBinAvailable"]; }); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "installBazelTools", function() { return _install_tools__WEBPACK_IMPORTED_MODULE_2__["installBazelTools"]; }); -/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(425); +/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(426); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "runBazel", function() { return _run__WEBPACK_IMPORTED_MODULE_3__["runBazel"]; }); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "runIBazel", function() { return _run__WEBPACK_IMPORTED_MODULE_3__["runIBazel"]; }); @@ -52610,7 +52677,7 @@ __webpack_require__.r(__webpack_exports__); /***/ }), -/* 422 */ +/* 423 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -52657,7 +52724,7 @@ async function ensureYarnIntegrityFileExists(nodeModulesPath) { } /***/ }), -/* 423 */ +/* 424 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -52694,7 +52761,7 @@ async function getBazelRepositoryCacheFolder() { } /***/ }), -/* 424 */ +/* 425 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -52813,7 +52880,7 @@ async function installBazelTools(repoRootPath) { } /***/ }), -/* 425 */ +/* 426 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -52823,8 +52890,8 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(114); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(chalk__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var rxjs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9); -/* harmony import */ var rxjs_operators__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(426); -/* harmony import */ var _kbn_dev_utils_stdio__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(524); +/* harmony import */ var rxjs_operators__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(427); +/* harmony import */ var _kbn_dev_utils_stdio__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(525); /* harmony import */ var _kbn_dev_utils_stdio__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(_kbn_dev_utils_stdio__WEBPACK_IMPORTED_MODULE_3__); /* harmony import */ var _child_process__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(221); /* harmony import */ var _log__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(220); @@ -52890,141 +52957,141 @@ async function runIBazel(bazelArgs, offline = false, runOpts = {}) { } /***/ }), -/* 426 */ +/* 427 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); -/* harmony import */ var _internal_operators_audit__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(427); +/* harmony import */ var _internal_operators_audit__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(428); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "audit", function() { return _internal_operators_audit__WEBPACK_IMPORTED_MODULE_0__["audit"]; }); -/* harmony import */ var _internal_operators_auditTime__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(428); +/* harmony import */ var _internal_operators_auditTime__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(429); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "auditTime", function() { return _internal_operators_auditTime__WEBPACK_IMPORTED_MODULE_1__["auditTime"]; }); -/* harmony import */ var _internal_operators_buffer__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(429); +/* harmony import */ var _internal_operators_buffer__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(430); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buffer", function() { return _internal_operators_buffer__WEBPACK_IMPORTED_MODULE_2__["buffer"]; }); -/* harmony import */ var _internal_operators_bufferCount__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(430); +/* harmony import */ var _internal_operators_bufferCount__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(431); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bufferCount", function() { return _internal_operators_bufferCount__WEBPACK_IMPORTED_MODULE_3__["bufferCount"]; }); -/* harmony import */ var _internal_operators_bufferTime__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(431); +/* harmony import */ var _internal_operators_bufferTime__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(432); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bufferTime", function() { return _internal_operators_bufferTime__WEBPACK_IMPORTED_MODULE_4__["bufferTime"]; }); -/* harmony import */ var _internal_operators_bufferToggle__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(432); +/* harmony import */ var _internal_operators_bufferToggle__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(433); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bufferToggle", function() { return _internal_operators_bufferToggle__WEBPACK_IMPORTED_MODULE_5__["bufferToggle"]; }); -/* harmony import */ var _internal_operators_bufferWhen__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(433); +/* harmony import */ var _internal_operators_bufferWhen__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(434); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bufferWhen", function() { return _internal_operators_bufferWhen__WEBPACK_IMPORTED_MODULE_6__["bufferWhen"]; }); -/* harmony import */ var _internal_operators_catchError__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(434); +/* harmony import */ var _internal_operators_catchError__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(435); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "catchError", function() { return _internal_operators_catchError__WEBPACK_IMPORTED_MODULE_7__["catchError"]; }); -/* harmony import */ var _internal_operators_combineAll__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(435); +/* harmony import */ var _internal_operators_combineAll__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(436); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "combineAll", function() { return _internal_operators_combineAll__WEBPACK_IMPORTED_MODULE_8__["combineAll"]; }); -/* harmony import */ var _internal_operators_combineLatest__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(436); +/* harmony import */ var _internal_operators_combineLatest__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(437); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "combineLatest", function() { return _internal_operators_combineLatest__WEBPACK_IMPORTED_MODULE_9__["combineLatest"]; }); -/* harmony import */ var _internal_operators_concat__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(437); +/* harmony import */ var _internal_operators_concat__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(438); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "concat", function() { return _internal_operators_concat__WEBPACK_IMPORTED_MODULE_10__["concat"]; }); /* harmony import */ var _internal_operators_concatAll__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(81); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "concatAll", function() { return _internal_operators_concatAll__WEBPACK_IMPORTED_MODULE_11__["concatAll"]; }); -/* harmony import */ var _internal_operators_concatMap__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(438); +/* harmony import */ var _internal_operators_concatMap__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(439); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "concatMap", function() { return _internal_operators_concatMap__WEBPACK_IMPORTED_MODULE_12__["concatMap"]; }); -/* harmony import */ var _internal_operators_concatMapTo__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(439); +/* harmony import */ var _internal_operators_concatMapTo__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(440); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "concatMapTo", function() { return _internal_operators_concatMapTo__WEBPACK_IMPORTED_MODULE_13__["concatMapTo"]; }); -/* harmony import */ var _internal_operators_count__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(440); +/* harmony import */ var _internal_operators_count__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(441); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "count", function() { return _internal_operators_count__WEBPACK_IMPORTED_MODULE_14__["count"]; }); -/* harmony import */ var _internal_operators_debounce__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(441); +/* harmony import */ var _internal_operators_debounce__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(442); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "debounce", function() { return _internal_operators_debounce__WEBPACK_IMPORTED_MODULE_15__["debounce"]; }); -/* harmony import */ var _internal_operators_debounceTime__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(442); +/* harmony import */ var _internal_operators_debounceTime__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(443); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "debounceTime", function() { return _internal_operators_debounceTime__WEBPACK_IMPORTED_MODULE_16__["debounceTime"]; }); -/* harmony import */ var _internal_operators_defaultIfEmpty__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(443); +/* harmony import */ var _internal_operators_defaultIfEmpty__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(444); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "defaultIfEmpty", function() { return _internal_operators_defaultIfEmpty__WEBPACK_IMPORTED_MODULE_17__["defaultIfEmpty"]; }); -/* harmony import */ var _internal_operators_delay__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(444); +/* harmony import */ var _internal_operators_delay__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(445); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "delay", function() { return _internal_operators_delay__WEBPACK_IMPORTED_MODULE_18__["delay"]; }); -/* harmony import */ var _internal_operators_delayWhen__WEBPACK_IMPORTED_MODULE_19__ = __webpack_require__(446); +/* harmony import */ var _internal_operators_delayWhen__WEBPACK_IMPORTED_MODULE_19__ = __webpack_require__(447); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "delayWhen", function() { return _internal_operators_delayWhen__WEBPACK_IMPORTED_MODULE_19__["delayWhen"]; }); -/* harmony import */ var _internal_operators_dematerialize__WEBPACK_IMPORTED_MODULE_20__ = __webpack_require__(447); +/* harmony import */ var _internal_operators_dematerialize__WEBPACK_IMPORTED_MODULE_20__ = __webpack_require__(448); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "dematerialize", function() { return _internal_operators_dematerialize__WEBPACK_IMPORTED_MODULE_20__["dematerialize"]; }); -/* harmony import */ var _internal_operators_distinct__WEBPACK_IMPORTED_MODULE_21__ = __webpack_require__(448); +/* harmony import */ var _internal_operators_distinct__WEBPACK_IMPORTED_MODULE_21__ = __webpack_require__(449); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "distinct", function() { return _internal_operators_distinct__WEBPACK_IMPORTED_MODULE_21__["distinct"]; }); -/* harmony import */ var _internal_operators_distinctUntilChanged__WEBPACK_IMPORTED_MODULE_22__ = __webpack_require__(449); +/* harmony import */ var _internal_operators_distinctUntilChanged__WEBPACK_IMPORTED_MODULE_22__ = __webpack_require__(450); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "distinctUntilChanged", function() { return _internal_operators_distinctUntilChanged__WEBPACK_IMPORTED_MODULE_22__["distinctUntilChanged"]; }); -/* harmony import */ var _internal_operators_distinctUntilKeyChanged__WEBPACK_IMPORTED_MODULE_23__ = __webpack_require__(450); +/* harmony import */ var _internal_operators_distinctUntilKeyChanged__WEBPACK_IMPORTED_MODULE_23__ = __webpack_require__(451); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "distinctUntilKeyChanged", function() { return _internal_operators_distinctUntilKeyChanged__WEBPACK_IMPORTED_MODULE_23__["distinctUntilKeyChanged"]; }); -/* harmony import */ var _internal_operators_elementAt__WEBPACK_IMPORTED_MODULE_24__ = __webpack_require__(451); +/* harmony import */ var _internal_operators_elementAt__WEBPACK_IMPORTED_MODULE_24__ = __webpack_require__(452); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "elementAt", function() { return _internal_operators_elementAt__WEBPACK_IMPORTED_MODULE_24__["elementAt"]; }); -/* harmony import */ var _internal_operators_endWith__WEBPACK_IMPORTED_MODULE_25__ = __webpack_require__(454); +/* harmony import */ var _internal_operators_endWith__WEBPACK_IMPORTED_MODULE_25__ = __webpack_require__(455); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "endWith", function() { return _internal_operators_endWith__WEBPACK_IMPORTED_MODULE_25__["endWith"]; }); -/* harmony import */ var _internal_operators_every__WEBPACK_IMPORTED_MODULE_26__ = __webpack_require__(455); +/* harmony import */ var _internal_operators_every__WEBPACK_IMPORTED_MODULE_26__ = __webpack_require__(456); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "every", function() { return _internal_operators_every__WEBPACK_IMPORTED_MODULE_26__["every"]; }); -/* harmony import */ var _internal_operators_exhaust__WEBPACK_IMPORTED_MODULE_27__ = __webpack_require__(456); +/* harmony import */ var _internal_operators_exhaust__WEBPACK_IMPORTED_MODULE_27__ = __webpack_require__(457); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "exhaust", function() { return _internal_operators_exhaust__WEBPACK_IMPORTED_MODULE_27__["exhaust"]; }); -/* harmony import */ var _internal_operators_exhaustMap__WEBPACK_IMPORTED_MODULE_28__ = __webpack_require__(457); +/* harmony import */ var _internal_operators_exhaustMap__WEBPACK_IMPORTED_MODULE_28__ = __webpack_require__(458); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "exhaustMap", function() { return _internal_operators_exhaustMap__WEBPACK_IMPORTED_MODULE_28__["exhaustMap"]; }); -/* harmony import */ var _internal_operators_expand__WEBPACK_IMPORTED_MODULE_29__ = __webpack_require__(458); +/* harmony import */ var _internal_operators_expand__WEBPACK_IMPORTED_MODULE_29__ = __webpack_require__(459); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "expand", function() { return _internal_operators_expand__WEBPACK_IMPORTED_MODULE_29__["expand"]; }); /* harmony import */ var _internal_operators_filter__WEBPACK_IMPORTED_MODULE_30__ = __webpack_require__(106); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "filter", function() { return _internal_operators_filter__WEBPACK_IMPORTED_MODULE_30__["filter"]; }); -/* harmony import */ var _internal_operators_finalize__WEBPACK_IMPORTED_MODULE_31__ = __webpack_require__(459); +/* harmony import */ var _internal_operators_finalize__WEBPACK_IMPORTED_MODULE_31__ = __webpack_require__(460); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "finalize", function() { return _internal_operators_finalize__WEBPACK_IMPORTED_MODULE_31__["finalize"]; }); -/* harmony import */ var _internal_operators_find__WEBPACK_IMPORTED_MODULE_32__ = __webpack_require__(460); +/* harmony import */ var _internal_operators_find__WEBPACK_IMPORTED_MODULE_32__ = __webpack_require__(461); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "find", function() { return _internal_operators_find__WEBPACK_IMPORTED_MODULE_32__["find"]; }); -/* harmony import */ var _internal_operators_findIndex__WEBPACK_IMPORTED_MODULE_33__ = __webpack_require__(461); +/* harmony import */ var _internal_operators_findIndex__WEBPACK_IMPORTED_MODULE_33__ = __webpack_require__(462); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "findIndex", function() { return _internal_operators_findIndex__WEBPACK_IMPORTED_MODULE_33__["findIndex"]; }); -/* harmony import */ var _internal_operators_first__WEBPACK_IMPORTED_MODULE_34__ = __webpack_require__(462); +/* harmony import */ var _internal_operators_first__WEBPACK_IMPORTED_MODULE_34__ = __webpack_require__(463); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "first", function() { return _internal_operators_first__WEBPACK_IMPORTED_MODULE_34__["first"]; }); /* harmony import */ var _internal_operators_groupBy__WEBPACK_IMPORTED_MODULE_35__ = __webpack_require__(32); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "groupBy", function() { return _internal_operators_groupBy__WEBPACK_IMPORTED_MODULE_35__["groupBy"]; }); -/* harmony import */ var _internal_operators_ignoreElements__WEBPACK_IMPORTED_MODULE_36__ = __webpack_require__(463); +/* harmony import */ var _internal_operators_ignoreElements__WEBPACK_IMPORTED_MODULE_36__ = __webpack_require__(464); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ignoreElements", function() { return _internal_operators_ignoreElements__WEBPACK_IMPORTED_MODULE_36__["ignoreElements"]; }); -/* harmony import */ var _internal_operators_isEmpty__WEBPACK_IMPORTED_MODULE_37__ = __webpack_require__(464); +/* harmony import */ var _internal_operators_isEmpty__WEBPACK_IMPORTED_MODULE_37__ = __webpack_require__(465); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "isEmpty", function() { return _internal_operators_isEmpty__WEBPACK_IMPORTED_MODULE_37__["isEmpty"]; }); -/* harmony import */ var _internal_operators_last__WEBPACK_IMPORTED_MODULE_38__ = __webpack_require__(465); +/* harmony import */ var _internal_operators_last__WEBPACK_IMPORTED_MODULE_38__ = __webpack_require__(466); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "last", function() { return _internal_operators_last__WEBPACK_IMPORTED_MODULE_38__["last"]; }); /* harmony import */ var _internal_operators_map__WEBPACK_IMPORTED_MODULE_39__ = __webpack_require__(67); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "map", function() { return _internal_operators_map__WEBPACK_IMPORTED_MODULE_39__["map"]; }); -/* harmony import */ var _internal_operators_mapTo__WEBPACK_IMPORTED_MODULE_40__ = __webpack_require__(467); +/* harmony import */ var _internal_operators_mapTo__WEBPACK_IMPORTED_MODULE_40__ = __webpack_require__(468); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "mapTo", function() { return _internal_operators_mapTo__WEBPACK_IMPORTED_MODULE_40__["mapTo"]; }); -/* harmony import */ var _internal_operators_materialize__WEBPACK_IMPORTED_MODULE_41__ = __webpack_require__(468); +/* harmony import */ var _internal_operators_materialize__WEBPACK_IMPORTED_MODULE_41__ = __webpack_require__(469); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "materialize", function() { return _internal_operators_materialize__WEBPACK_IMPORTED_MODULE_41__["materialize"]; }); -/* harmony import */ var _internal_operators_max__WEBPACK_IMPORTED_MODULE_42__ = __webpack_require__(469); +/* harmony import */ var _internal_operators_max__WEBPACK_IMPORTED_MODULE_42__ = __webpack_require__(470); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "max", function() { return _internal_operators_max__WEBPACK_IMPORTED_MODULE_42__["max"]; }); -/* harmony import */ var _internal_operators_merge__WEBPACK_IMPORTED_MODULE_43__ = __webpack_require__(472); +/* harmony import */ var _internal_operators_merge__WEBPACK_IMPORTED_MODULE_43__ = __webpack_require__(473); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "merge", function() { return _internal_operators_merge__WEBPACK_IMPORTED_MODULE_43__["merge"]; }); /* harmony import */ var _internal_operators_mergeAll__WEBPACK_IMPORTED_MODULE_44__ = __webpack_require__(82); @@ -53035,175 +53102,175 @@ __webpack_require__.r(__webpack_exports__); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "flatMap", function() { return _internal_operators_mergeMap__WEBPACK_IMPORTED_MODULE_45__["flatMap"]; }); -/* harmony import */ var _internal_operators_mergeMapTo__WEBPACK_IMPORTED_MODULE_46__ = __webpack_require__(473); +/* harmony import */ var _internal_operators_mergeMapTo__WEBPACK_IMPORTED_MODULE_46__ = __webpack_require__(474); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "mergeMapTo", function() { return _internal_operators_mergeMapTo__WEBPACK_IMPORTED_MODULE_46__["mergeMapTo"]; }); -/* harmony import */ var _internal_operators_mergeScan__WEBPACK_IMPORTED_MODULE_47__ = __webpack_require__(474); +/* harmony import */ var _internal_operators_mergeScan__WEBPACK_IMPORTED_MODULE_47__ = __webpack_require__(475); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "mergeScan", function() { return _internal_operators_mergeScan__WEBPACK_IMPORTED_MODULE_47__["mergeScan"]; }); -/* harmony import */ var _internal_operators_min__WEBPACK_IMPORTED_MODULE_48__ = __webpack_require__(475); +/* harmony import */ var _internal_operators_min__WEBPACK_IMPORTED_MODULE_48__ = __webpack_require__(476); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "min", function() { return _internal_operators_min__WEBPACK_IMPORTED_MODULE_48__["min"]; }); -/* harmony import */ var _internal_operators_multicast__WEBPACK_IMPORTED_MODULE_49__ = __webpack_require__(476); +/* harmony import */ var _internal_operators_multicast__WEBPACK_IMPORTED_MODULE_49__ = __webpack_require__(477); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "multicast", function() { return _internal_operators_multicast__WEBPACK_IMPORTED_MODULE_49__["multicast"]; }); /* harmony import */ var _internal_operators_observeOn__WEBPACK_IMPORTED_MODULE_50__ = __webpack_require__(42); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "observeOn", function() { return _internal_operators_observeOn__WEBPACK_IMPORTED_MODULE_50__["observeOn"]; }); -/* harmony import */ var _internal_operators_onErrorResumeNext__WEBPACK_IMPORTED_MODULE_51__ = __webpack_require__(477); +/* harmony import */ var _internal_operators_onErrorResumeNext__WEBPACK_IMPORTED_MODULE_51__ = __webpack_require__(478); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "onErrorResumeNext", function() { return _internal_operators_onErrorResumeNext__WEBPACK_IMPORTED_MODULE_51__["onErrorResumeNext"]; }); -/* harmony import */ var _internal_operators_pairwise__WEBPACK_IMPORTED_MODULE_52__ = __webpack_require__(478); +/* harmony import */ var _internal_operators_pairwise__WEBPACK_IMPORTED_MODULE_52__ = __webpack_require__(479); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "pairwise", function() { return _internal_operators_pairwise__WEBPACK_IMPORTED_MODULE_52__["pairwise"]; }); -/* harmony import */ var _internal_operators_partition__WEBPACK_IMPORTED_MODULE_53__ = __webpack_require__(479); +/* harmony import */ var _internal_operators_partition__WEBPACK_IMPORTED_MODULE_53__ = __webpack_require__(480); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "partition", function() { return _internal_operators_partition__WEBPACK_IMPORTED_MODULE_53__["partition"]; }); -/* harmony import */ var _internal_operators_pluck__WEBPACK_IMPORTED_MODULE_54__ = __webpack_require__(480); +/* harmony import */ var _internal_operators_pluck__WEBPACK_IMPORTED_MODULE_54__ = __webpack_require__(481); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "pluck", function() { return _internal_operators_pluck__WEBPACK_IMPORTED_MODULE_54__["pluck"]; }); -/* harmony import */ var _internal_operators_publish__WEBPACK_IMPORTED_MODULE_55__ = __webpack_require__(481); +/* harmony import */ var _internal_operators_publish__WEBPACK_IMPORTED_MODULE_55__ = __webpack_require__(482); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "publish", function() { return _internal_operators_publish__WEBPACK_IMPORTED_MODULE_55__["publish"]; }); -/* harmony import */ var _internal_operators_publishBehavior__WEBPACK_IMPORTED_MODULE_56__ = __webpack_require__(482); +/* harmony import */ var _internal_operators_publishBehavior__WEBPACK_IMPORTED_MODULE_56__ = __webpack_require__(483); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "publishBehavior", function() { return _internal_operators_publishBehavior__WEBPACK_IMPORTED_MODULE_56__["publishBehavior"]; }); -/* harmony import */ var _internal_operators_publishLast__WEBPACK_IMPORTED_MODULE_57__ = __webpack_require__(483); +/* harmony import */ var _internal_operators_publishLast__WEBPACK_IMPORTED_MODULE_57__ = __webpack_require__(484); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "publishLast", function() { return _internal_operators_publishLast__WEBPACK_IMPORTED_MODULE_57__["publishLast"]; }); -/* harmony import */ var _internal_operators_publishReplay__WEBPACK_IMPORTED_MODULE_58__ = __webpack_require__(484); +/* harmony import */ var _internal_operators_publishReplay__WEBPACK_IMPORTED_MODULE_58__ = __webpack_require__(485); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "publishReplay", function() { return _internal_operators_publishReplay__WEBPACK_IMPORTED_MODULE_58__["publishReplay"]; }); -/* harmony import */ var _internal_operators_race__WEBPACK_IMPORTED_MODULE_59__ = __webpack_require__(485); +/* harmony import */ var _internal_operators_race__WEBPACK_IMPORTED_MODULE_59__ = __webpack_require__(486); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "race", function() { return _internal_operators_race__WEBPACK_IMPORTED_MODULE_59__["race"]; }); -/* harmony import */ var _internal_operators_reduce__WEBPACK_IMPORTED_MODULE_60__ = __webpack_require__(470); +/* harmony import */ var _internal_operators_reduce__WEBPACK_IMPORTED_MODULE_60__ = __webpack_require__(471); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "reduce", function() { return _internal_operators_reduce__WEBPACK_IMPORTED_MODULE_60__["reduce"]; }); -/* harmony import */ var _internal_operators_repeat__WEBPACK_IMPORTED_MODULE_61__ = __webpack_require__(486); +/* harmony import */ var _internal_operators_repeat__WEBPACK_IMPORTED_MODULE_61__ = __webpack_require__(487); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "repeat", function() { return _internal_operators_repeat__WEBPACK_IMPORTED_MODULE_61__["repeat"]; }); -/* harmony import */ var _internal_operators_repeatWhen__WEBPACK_IMPORTED_MODULE_62__ = __webpack_require__(487); +/* harmony import */ var _internal_operators_repeatWhen__WEBPACK_IMPORTED_MODULE_62__ = __webpack_require__(488); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "repeatWhen", function() { return _internal_operators_repeatWhen__WEBPACK_IMPORTED_MODULE_62__["repeatWhen"]; }); -/* harmony import */ var _internal_operators_retry__WEBPACK_IMPORTED_MODULE_63__ = __webpack_require__(488); +/* harmony import */ var _internal_operators_retry__WEBPACK_IMPORTED_MODULE_63__ = __webpack_require__(489); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "retry", function() { return _internal_operators_retry__WEBPACK_IMPORTED_MODULE_63__["retry"]; }); -/* harmony import */ var _internal_operators_retryWhen__WEBPACK_IMPORTED_MODULE_64__ = __webpack_require__(489); +/* harmony import */ var _internal_operators_retryWhen__WEBPACK_IMPORTED_MODULE_64__ = __webpack_require__(490); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "retryWhen", function() { return _internal_operators_retryWhen__WEBPACK_IMPORTED_MODULE_64__["retryWhen"]; }); /* harmony import */ var _internal_operators_refCount__WEBPACK_IMPORTED_MODULE_65__ = __webpack_require__(31); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "refCount", function() { return _internal_operators_refCount__WEBPACK_IMPORTED_MODULE_65__["refCount"]; }); -/* harmony import */ var _internal_operators_sample__WEBPACK_IMPORTED_MODULE_66__ = __webpack_require__(490); +/* harmony import */ var _internal_operators_sample__WEBPACK_IMPORTED_MODULE_66__ = __webpack_require__(491); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "sample", function() { return _internal_operators_sample__WEBPACK_IMPORTED_MODULE_66__["sample"]; }); -/* harmony import */ var _internal_operators_sampleTime__WEBPACK_IMPORTED_MODULE_67__ = __webpack_require__(491); +/* harmony import */ var _internal_operators_sampleTime__WEBPACK_IMPORTED_MODULE_67__ = __webpack_require__(492); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "sampleTime", function() { return _internal_operators_sampleTime__WEBPACK_IMPORTED_MODULE_67__["sampleTime"]; }); -/* harmony import */ var _internal_operators_scan__WEBPACK_IMPORTED_MODULE_68__ = __webpack_require__(471); +/* harmony import */ var _internal_operators_scan__WEBPACK_IMPORTED_MODULE_68__ = __webpack_require__(472); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "scan", function() { return _internal_operators_scan__WEBPACK_IMPORTED_MODULE_68__["scan"]; }); -/* harmony import */ var _internal_operators_sequenceEqual__WEBPACK_IMPORTED_MODULE_69__ = __webpack_require__(492); +/* harmony import */ var _internal_operators_sequenceEqual__WEBPACK_IMPORTED_MODULE_69__ = __webpack_require__(493); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "sequenceEqual", function() { return _internal_operators_sequenceEqual__WEBPACK_IMPORTED_MODULE_69__["sequenceEqual"]; }); -/* harmony import */ var _internal_operators_share__WEBPACK_IMPORTED_MODULE_70__ = __webpack_require__(493); +/* harmony import */ var _internal_operators_share__WEBPACK_IMPORTED_MODULE_70__ = __webpack_require__(494); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "share", function() { return _internal_operators_share__WEBPACK_IMPORTED_MODULE_70__["share"]; }); -/* harmony import */ var _internal_operators_shareReplay__WEBPACK_IMPORTED_MODULE_71__ = __webpack_require__(494); +/* harmony import */ var _internal_operators_shareReplay__WEBPACK_IMPORTED_MODULE_71__ = __webpack_require__(495); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "shareReplay", function() { return _internal_operators_shareReplay__WEBPACK_IMPORTED_MODULE_71__["shareReplay"]; }); -/* harmony import */ var _internal_operators_single__WEBPACK_IMPORTED_MODULE_72__ = __webpack_require__(495); +/* harmony import */ var _internal_operators_single__WEBPACK_IMPORTED_MODULE_72__ = __webpack_require__(496); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "single", function() { return _internal_operators_single__WEBPACK_IMPORTED_MODULE_72__["single"]; }); -/* harmony import */ var _internal_operators_skip__WEBPACK_IMPORTED_MODULE_73__ = __webpack_require__(496); +/* harmony import */ var _internal_operators_skip__WEBPACK_IMPORTED_MODULE_73__ = __webpack_require__(497); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "skip", function() { return _internal_operators_skip__WEBPACK_IMPORTED_MODULE_73__["skip"]; }); -/* harmony import */ var _internal_operators_skipLast__WEBPACK_IMPORTED_MODULE_74__ = __webpack_require__(497); +/* harmony import */ var _internal_operators_skipLast__WEBPACK_IMPORTED_MODULE_74__ = __webpack_require__(498); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "skipLast", function() { return _internal_operators_skipLast__WEBPACK_IMPORTED_MODULE_74__["skipLast"]; }); -/* harmony import */ var _internal_operators_skipUntil__WEBPACK_IMPORTED_MODULE_75__ = __webpack_require__(498); +/* harmony import */ var _internal_operators_skipUntil__WEBPACK_IMPORTED_MODULE_75__ = __webpack_require__(499); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "skipUntil", function() { return _internal_operators_skipUntil__WEBPACK_IMPORTED_MODULE_75__["skipUntil"]; }); -/* harmony import */ var _internal_operators_skipWhile__WEBPACK_IMPORTED_MODULE_76__ = __webpack_require__(499); +/* harmony import */ var _internal_operators_skipWhile__WEBPACK_IMPORTED_MODULE_76__ = __webpack_require__(500); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "skipWhile", function() { return _internal_operators_skipWhile__WEBPACK_IMPORTED_MODULE_76__["skipWhile"]; }); -/* harmony import */ var _internal_operators_startWith__WEBPACK_IMPORTED_MODULE_77__ = __webpack_require__(500); +/* harmony import */ var _internal_operators_startWith__WEBPACK_IMPORTED_MODULE_77__ = __webpack_require__(501); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "startWith", function() { return _internal_operators_startWith__WEBPACK_IMPORTED_MODULE_77__["startWith"]; }); -/* harmony import */ var _internal_operators_subscribeOn__WEBPACK_IMPORTED_MODULE_78__ = __webpack_require__(501); +/* harmony import */ var _internal_operators_subscribeOn__WEBPACK_IMPORTED_MODULE_78__ = __webpack_require__(502); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "subscribeOn", function() { return _internal_operators_subscribeOn__WEBPACK_IMPORTED_MODULE_78__["subscribeOn"]; }); -/* harmony import */ var _internal_operators_switchAll__WEBPACK_IMPORTED_MODULE_79__ = __webpack_require__(503); +/* harmony import */ var _internal_operators_switchAll__WEBPACK_IMPORTED_MODULE_79__ = __webpack_require__(504); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "switchAll", function() { return _internal_operators_switchAll__WEBPACK_IMPORTED_MODULE_79__["switchAll"]; }); -/* harmony import */ var _internal_operators_switchMap__WEBPACK_IMPORTED_MODULE_80__ = __webpack_require__(504); +/* harmony import */ var _internal_operators_switchMap__WEBPACK_IMPORTED_MODULE_80__ = __webpack_require__(505); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "switchMap", function() { return _internal_operators_switchMap__WEBPACK_IMPORTED_MODULE_80__["switchMap"]; }); -/* harmony import */ var _internal_operators_switchMapTo__WEBPACK_IMPORTED_MODULE_81__ = __webpack_require__(505); +/* harmony import */ var _internal_operators_switchMapTo__WEBPACK_IMPORTED_MODULE_81__ = __webpack_require__(506); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "switchMapTo", function() { return _internal_operators_switchMapTo__WEBPACK_IMPORTED_MODULE_81__["switchMapTo"]; }); -/* harmony import */ var _internal_operators_take__WEBPACK_IMPORTED_MODULE_82__ = __webpack_require__(453); +/* harmony import */ var _internal_operators_take__WEBPACK_IMPORTED_MODULE_82__ = __webpack_require__(454); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "take", function() { return _internal_operators_take__WEBPACK_IMPORTED_MODULE_82__["take"]; }); -/* harmony import */ var _internal_operators_takeLast__WEBPACK_IMPORTED_MODULE_83__ = __webpack_require__(466); +/* harmony import */ var _internal_operators_takeLast__WEBPACK_IMPORTED_MODULE_83__ = __webpack_require__(467); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "takeLast", function() { return _internal_operators_takeLast__WEBPACK_IMPORTED_MODULE_83__["takeLast"]; }); -/* harmony import */ var _internal_operators_takeUntil__WEBPACK_IMPORTED_MODULE_84__ = __webpack_require__(506); +/* harmony import */ var _internal_operators_takeUntil__WEBPACK_IMPORTED_MODULE_84__ = __webpack_require__(507); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "takeUntil", function() { return _internal_operators_takeUntil__WEBPACK_IMPORTED_MODULE_84__["takeUntil"]; }); -/* harmony import */ var _internal_operators_takeWhile__WEBPACK_IMPORTED_MODULE_85__ = __webpack_require__(507); +/* harmony import */ var _internal_operators_takeWhile__WEBPACK_IMPORTED_MODULE_85__ = __webpack_require__(508); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "takeWhile", function() { return _internal_operators_takeWhile__WEBPACK_IMPORTED_MODULE_85__["takeWhile"]; }); -/* harmony import */ var _internal_operators_tap__WEBPACK_IMPORTED_MODULE_86__ = __webpack_require__(508); +/* harmony import */ var _internal_operators_tap__WEBPACK_IMPORTED_MODULE_86__ = __webpack_require__(509); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "tap", function() { return _internal_operators_tap__WEBPACK_IMPORTED_MODULE_86__["tap"]; }); -/* harmony import */ var _internal_operators_throttle__WEBPACK_IMPORTED_MODULE_87__ = __webpack_require__(509); +/* harmony import */ var _internal_operators_throttle__WEBPACK_IMPORTED_MODULE_87__ = __webpack_require__(510); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "throttle", function() { return _internal_operators_throttle__WEBPACK_IMPORTED_MODULE_87__["throttle"]; }); -/* harmony import */ var _internal_operators_throttleTime__WEBPACK_IMPORTED_MODULE_88__ = __webpack_require__(510); +/* harmony import */ var _internal_operators_throttleTime__WEBPACK_IMPORTED_MODULE_88__ = __webpack_require__(511); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "throttleTime", function() { return _internal_operators_throttleTime__WEBPACK_IMPORTED_MODULE_88__["throttleTime"]; }); -/* harmony import */ var _internal_operators_throwIfEmpty__WEBPACK_IMPORTED_MODULE_89__ = __webpack_require__(452); +/* harmony import */ var _internal_operators_throwIfEmpty__WEBPACK_IMPORTED_MODULE_89__ = __webpack_require__(453); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "throwIfEmpty", function() { return _internal_operators_throwIfEmpty__WEBPACK_IMPORTED_MODULE_89__["throwIfEmpty"]; }); -/* harmony import */ var _internal_operators_timeInterval__WEBPACK_IMPORTED_MODULE_90__ = __webpack_require__(511); +/* harmony import */ var _internal_operators_timeInterval__WEBPACK_IMPORTED_MODULE_90__ = __webpack_require__(512); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "timeInterval", function() { return _internal_operators_timeInterval__WEBPACK_IMPORTED_MODULE_90__["timeInterval"]; }); -/* harmony import */ var _internal_operators_timeout__WEBPACK_IMPORTED_MODULE_91__ = __webpack_require__(512); +/* harmony import */ var _internal_operators_timeout__WEBPACK_IMPORTED_MODULE_91__ = __webpack_require__(513); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "timeout", function() { return _internal_operators_timeout__WEBPACK_IMPORTED_MODULE_91__["timeout"]; }); -/* harmony import */ var _internal_operators_timeoutWith__WEBPACK_IMPORTED_MODULE_92__ = __webpack_require__(513); +/* harmony import */ var _internal_operators_timeoutWith__WEBPACK_IMPORTED_MODULE_92__ = __webpack_require__(514); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "timeoutWith", function() { return _internal_operators_timeoutWith__WEBPACK_IMPORTED_MODULE_92__["timeoutWith"]; }); -/* harmony import */ var _internal_operators_timestamp__WEBPACK_IMPORTED_MODULE_93__ = __webpack_require__(514); +/* harmony import */ var _internal_operators_timestamp__WEBPACK_IMPORTED_MODULE_93__ = __webpack_require__(515); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "timestamp", function() { return _internal_operators_timestamp__WEBPACK_IMPORTED_MODULE_93__["timestamp"]; }); -/* harmony import */ var _internal_operators_toArray__WEBPACK_IMPORTED_MODULE_94__ = __webpack_require__(515); +/* harmony import */ var _internal_operators_toArray__WEBPACK_IMPORTED_MODULE_94__ = __webpack_require__(516); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "toArray", function() { return _internal_operators_toArray__WEBPACK_IMPORTED_MODULE_94__["toArray"]; }); -/* harmony import */ var _internal_operators_window__WEBPACK_IMPORTED_MODULE_95__ = __webpack_require__(516); +/* harmony import */ var _internal_operators_window__WEBPACK_IMPORTED_MODULE_95__ = __webpack_require__(517); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "window", function() { return _internal_operators_window__WEBPACK_IMPORTED_MODULE_95__["window"]; }); -/* harmony import */ var _internal_operators_windowCount__WEBPACK_IMPORTED_MODULE_96__ = __webpack_require__(517); +/* harmony import */ var _internal_operators_windowCount__WEBPACK_IMPORTED_MODULE_96__ = __webpack_require__(518); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "windowCount", function() { return _internal_operators_windowCount__WEBPACK_IMPORTED_MODULE_96__["windowCount"]; }); -/* harmony import */ var _internal_operators_windowTime__WEBPACK_IMPORTED_MODULE_97__ = __webpack_require__(518); +/* harmony import */ var _internal_operators_windowTime__WEBPACK_IMPORTED_MODULE_97__ = __webpack_require__(519); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "windowTime", function() { return _internal_operators_windowTime__WEBPACK_IMPORTED_MODULE_97__["windowTime"]; }); -/* harmony import */ var _internal_operators_windowToggle__WEBPACK_IMPORTED_MODULE_98__ = __webpack_require__(519); +/* harmony import */ var _internal_operators_windowToggle__WEBPACK_IMPORTED_MODULE_98__ = __webpack_require__(520); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "windowToggle", function() { return _internal_operators_windowToggle__WEBPACK_IMPORTED_MODULE_98__["windowToggle"]; }); -/* harmony import */ var _internal_operators_windowWhen__WEBPACK_IMPORTED_MODULE_99__ = __webpack_require__(520); +/* harmony import */ var _internal_operators_windowWhen__WEBPACK_IMPORTED_MODULE_99__ = __webpack_require__(521); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "windowWhen", function() { return _internal_operators_windowWhen__WEBPACK_IMPORTED_MODULE_99__["windowWhen"]; }); -/* harmony import */ var _internal_operators_withLatestFrom__WEBPACK_IMPORTED_MODULE_100__ = __webpack_require__(521); +/* harmony import */ var _internal_operators_withLatestFrom__WEBPACK_IMPORTED_MODULE_100__ = __webpack_require__(522); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "withLatestFrom", function() { return _internal_operators_withLatestFrom__WEBPACK_IMPORTED_MODULE_100__["withLatestFrom"]; }); -/* harmony import */ var _internal_operators_zip__WEBPACK_IMPORTED_MODULE_101__ = __webpack_require__(522); +/* harmony import */ var _internal_operators_zip__WEBPACK_IMPORTED_MODULE_101__ = __webpack_require__(523); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "zip", function() { return _internal_operators_zip__WEBPACK_IMPORTED_MODULE_101__["zip"]; }); -/* harmony import */ var _internal_operators_zipAll__WEBPACK_IMPORTED_MODULE_102__ = __webpack_require__(523); +/* harmony import */ var _internal_operators_zipAll__WEBPACK_IMPORTED_MODULE_102__ = __webpack_require__(524); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "zipAll", function() { return _internal_operators_zipAll__WEBPACK_IMPORTED_MODULE_102__["zipAll"]; }); /** PURE_IMPORTS_START PURE_IMPORTS_END */ @@ -53314,7 +53381,7 @@ __webpack_require__.r(__webpack_exports__); /***/ }), -/* 427 */ +/* 428 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53393,14 +53460,14 @@ var AuditSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 428 */ +/* 429 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "auditTime", function() { return auditTime; }); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(56); -/* harmony import */ var _audit__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(427); +/* harmony import */ var _audit__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(428); /* harmony import */ var _observable_timer__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(109); /** PURE_IMPORTS_START _scheduler_async,_audit,_observable_timer PURE_IMPORTS_END */ @@ -53416,7 +53483,7 @@ function auditTime(duration, scheduler) { /***/ }), -/* 429 */ +/* 430 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53463,7 +53530,7 @@ var BufferSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 430 */ +/* 431 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53564,7 +53631,7 @@ var BufferSkipCountSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 431 */ +/* 432 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53725,7 +53792,7 @@ function dispatchBufferClose(arg) { /***/ }), -/* 432 */ +/* 433 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53844,7 +53911,7 @@ var BufferToggleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 433 */ +/* 434 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53937,7 +54004,7 @@ var BufferWhenSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 434 */ +/* 435 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53997,7 +54064,7 @@ var CatchSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 435 */ +/* 436 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54013,7 +54080,7 @@ function combineAll(project) { /***/ }), -/* 436 */ +/* 437 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54045,7 +54112,7 @@ function combineLatest() { /***/ }), -/* 437 */ +/* 438 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54065,7 +54132,7 @@ function concat() { /***/ }), -/* 438 */ +/* 439 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54081,13 +54148,13 @@ function concatMap(project, resultSelector) { /***/ }), -/* 439 */ +/* 440 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "concatMapTo", function() { return concatMapTo; }); -/* harmony import */ var _concatMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(438); +/* harmony import */ var _concatMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(439); /** PURE_IMPORTS_START _concatMap PURE_IMPORTS_END */ function concatMapTo(innerObservable, resultSelector) { @@ -54097,7 +54164,7 @@ function concatMapTo(innerObservable, resultSelector) { /***/ }), -/* 440 */ +/* 441 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54162,7 +54229,7 @@ var CountSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 441 */ +/* 442 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54247,7 +54314,7 @@ var DebounceSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 442 */ +/* 443 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54323,7 +54390,7 @@ function dispatchNext(subscriber) { /***/ }), -/* 443 */ +/* 444 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54373,7 +54440,7 @@ var DefaultIfEmptySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 444 */ +/* 445 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54381,7 +54448,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "delay", function() { return delay; }); /* harmony import */ var tslib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(13); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(56); -/* harmony import */ var _util_isDate__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(445); +/* harmony import */ var _util_isDate__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(446); /* harmony import */ var _Subscriber__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(12); /* harmony import */ var _Notification__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(43); /** PURE_IMPORTS_START tslib,_scheduler_async,_util_isDate,_Subscriber,_Notification PURE_IMPORTS_END */ @@ -54480,7 +54547,7 @@ var DelayMessage = /*@__PURE__*/ (function () { /***/ }), -/* 445 */ +/* 446 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54494,7 +54561,7 @@ function isDate(value) { /***/ }), -/* 446 */ +/* 447 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54640,7 +54707,7 @@ var SubscriptionDelaySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 447 */ +/* 448 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54678,7 +54745,7 @@ var DeMaterializeSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 448 */ +/* 449 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54754,7 +54821,7 @@ var DistinctSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 449 */ +/* 450 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54825,13 +54892,13 @@ var DistinctUntilChangedSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 450 */ +/* 451 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "distinctUntilKeyChanged", function() { return distinctUntilKeyChanged; }); -/* harmony import */ var _distinctUntilChanged__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(449); +/* harmony import */ var _distinctUntilChanged__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(450); /** PURE_IMPORTS_START _distinctUntilChanged PURE_IMPORTS_END */ function distinctUntilKeyChanged(key, compare) { @@ -54841,7 +54908,7 @@ function distinctUntilKeyChanged(key, compare) { /***/ }), -/* 451 */ +/* 452 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54849,9 +54916,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "elementAt", function() { return elementAt; }); /* harmony import */ var _util_ArgumentOutOfRangeError__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(63); /* harmony import */ var _filter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(106); -/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(452); -/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(443); -/* harmony import */ var _take__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(453); +/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(453); +/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(444); +/* harmony import */ var _take__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(454); /** PURE_IMPORTS_START _util_ArgumentOutOfRangeError,_filter,_throwIfEmpty,_defaultIfEmpty,_take PURE_IMPORTS_END */ @@ -54873,7 +54940,7 @@ function elementAt(index, defaultValue) { /***/ }), -/* 452 */ +/* 453 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54939,7 +55006,7 @@ function defaultErrorFactory() { /***/ }), -/* 453 */ +/* 454 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55001,7 +55068,7 @@ var TakeSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 454 */ +/* 455 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55023,7 +55090,7 @@ function endWith() { /***/ }), -/* 455 */ +/* 456 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55085,7 +55152,7 @@ var EverySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 456 */ +/* 457 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55139,7 +55206,7 @@ var SwitchFirstSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 457 */ +/* 458 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55233,7 +55300,7 @@ var ExhaustMapSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 458 */ +/* 459 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55345,7 +55412,7 @@ var ExpandSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 459 */ +/* 460 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55383,7 +55450,7 @@ var FinallySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 460 */ +/* 461 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55455,13 +55522,13 @@ var FindValueSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 461 */ +/* 462 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "findIndex", function() { return findIndex; }); -/* harmony import */ var _operators_find__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(460); +/* harmony import */ var _operators_find__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(461); /** PURE_IMPORTS_START _operators_find PURE_IMPORTS_END */ function findIndex(predicate, thisArg) { @@ -55471,7 +55538,7 @@ function findIndex(predicate, thisArg) { /***/ }), -/* 462 */ +/* 463 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55479,9 +55546,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "first", function() { return first; }); /* harmony import */ var _util_EmptyError__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(64); /* harmony import */ var _filter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(106); -/* harmony import */ var _take__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(453); -/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(443); -/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(452); +/* harmony import */ var _take__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(454); +/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(444); +/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(453); /* harmony import */ var _util_identity__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(26); /** PURE_IMPORTS_START _util_EmptyError,_filter,_take,_defaultIfEmpty,_throwIfEmpty,_util_identity PURE_IMPORTS_END */ @@ -55498,7 +55565,7 @@ function first(predicate, defaultValue) { /***/ }), -/* 463 */ +/* 464 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55535,7 +55602,7 @@ var IgnoreElementsSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 464 */ +/* 465 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55579,7 +55646,7 @@ var IsEmptySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 465 */ +/* 466 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55587,9 +55654,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "last", function() { return last; }); /* harmony import */ var _util_EmptyError__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(64); /* harmony import */ var _filter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(106); -/* harmony import */ var _takeLast__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(466); -/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(452); -/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(443); +/* harmony import */ var _takeLast__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(467); +/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(453); +/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(444); /* harmony import */ var _util_identity__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(26); /** PURE_IMPORTS_START _util_EmptyError,_filter,_takeLast,_throwIfEmpty,_defaultIfEmpty,_util_identity PURE_IMPORTS_END */ @@ -55606,7 +55673,7 @@ function last(predicate, defaultValue) { /***/ }), -/* 466 */ +/* 467 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55683,7 +55750,7 @@ var TakeLastSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 467 */ +/* 468 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55722,7 +55789,7 @@ var MapToSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 468 */ +/* 469 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55772,13 +55839,13 @@ var MaterializeSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 469 */ +/* 470 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "max", function() { return max; }); -/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(470); +/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(471); /** PURE_IMPORTS_START _reduce PURE_IMPORTS_END */ function max(comparer) { @@ -55791,15 +55858,15 @@ function max(comparer) { /***/ }), -/* 470 */ +/* 471 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "reduce", function() { return reduce; }); -/* harmony import */ var _scan__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(471); -/* harmony import */ var _takeLast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(466); -/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(443); +/* harmony import */ var _scan__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(472); +/* harmony import */ var _takeLast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(467); +/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(444); /* harmony import */ var _util_pipe__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(25); /** PURE_IMPORTS_START _scan,_takeLast,_defaultIfEmpty,_util_pipe PURE_IMPORTS_END */ @@ -55820,7 +55887,7 @@ function reduce(accumulator, seed) { /***/ }), -/* 471 */ +/* 472 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55902,7 +55969,7 @@ var ScanSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 472 */ +/* 473 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55922,7 +55989,7 @@ function merge() { /***/ }), -/* 473 */ +/* 474 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55947,7 +56014,7 @@ function mergeMapTo(innerObservable, resultSelector, concurrent) { /***/ }), -/* 474 */ +/* 475 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56056,13 +56123,13 @@ var MergeScanSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 475 */ +/* 476 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "min", function() { return min; }); -/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(470); +/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(471); /** PURE_IMPORTS_START _reduce PURE_IMPORTS_END */ function min(comparer) { @@ -56075,7 +56142,7 @@ function min(comparer) { /***/ }), -/* 476 */ +/* 477 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56124,7 +56191,7 @@ var MulticastOperator = /*@__PURE__*/ (function () { /***/ }), -/* 477 */ +/* 478 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56214,7 +56281,7 @@ var OnErrorResumeNextSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 478 */ +/* 479 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56262,7 +56329,7 @@ var PairwiseSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 479 */ +/* 480 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56285,7 +56352,7 @@ function partition(predicate, thisArg) { /***/ }), -/* 480 */ +/* 481 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56325,14 +56392,14 @@ function plucker(props, length) { /***/ }), -/* 481 */ +/* 482 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "publish", function() { return publish; }); /* harmony import */ var _Subject__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(28); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(476); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(477); /** PURE_IMPORTS_START _Subject,_multicast PURE_IMPORTS_END */ @@ -56345,14 +56412,14 @@ function publish(selector) { /***/ }), -/* 482 */ +/* 483 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "publishBehavior", function() { return publishBehavior; }); /* harmony import */ var _BehaviorSubject__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(33); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(476); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(477); /** PURE_IMPORTS_START _BehaviorSubject,_multicast PURE_IMPORTS_END */ @@ -56363,14 +56430,14 @@ function publishBehavior(value) { /***/ }), -/* 483 */ +/* 484 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "publishLast", function() { return publishLast; }); /* harmony import */ var _AsyncSubject__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(51); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(476); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(477); /** PURE_IMPORTS_START _AsyncSubject,_multicast PURE_IMPORTS_END */ @@ -56381,14 +56448,14 @@ function publishLast() { /***/ }), -/* 484 */ +/* 485 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "publishReplay", function() { return publishReplay; }); /* harmony import */ var _ReplaySubject__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(34); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(476); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(477); /** PURE_IMPORTS_START _ReplaySubject,_multicast PURE_IMPORTS_END */ @@ -56404,7 +56471,7 @@ function publishReplay(bufferSize, windowTime, selectorOrScheduler, scheduler) { /***/ }), -/* 485 */ +/* 486 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56431,7 +56498,7 @@ function race() { /***/ }), -/* 486 */ +/* 487 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56496,7 +56563,7 @@ var RepeatSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 487 */ +/* 488 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56590,7 +56657,7 @@ var RepeatWhenSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 488 */ +/* 489 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56643,7 +56710,7 @@ var RetrySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 489 */ +/* 490 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56729,7 +56796,7 @@ var RetryWhenSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 490 */ +/* 491 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56784,7 +56851,7 @@ var SampleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 491 */ +/* 492 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56844,7 +56911,7 @@ function dispatchNotification(state) { /***/ }), -/* 492 */ +/* 493 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56967,13 +57034,13 @@ var SequenceEqualCompareToSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 493 */ +/* 494 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "share", function() { return share; }); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(476); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(477); /* harmony import */ var _refCount__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(31); /* harmony import */ var _Subject__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(28); /** PURE_IMPORTS_START _multicast,_refCount,_Subject PURE_IMPORTS_END */ @@ -56990,7 +57057,7 @@ function share() { /***/ }), -/* 494 */ +/* 495 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57059,7 +57126,7 @@ function shareReplayOperator(_a) { /***/ }), -/* 495 */ +/* 496 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57139,7 +57206,7 @@ var SingleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 496 */ +/* 497 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57181,7 +57248,7 @@ var SkipSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 497 */ +/* 498 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57243,7 +57310,7 @@ var SkipLastSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 498 */ +/* 499 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57300,7 +57367,7 @@ var SkipUntilSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 499 */ +/* 500 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57356,7 +57423,7 @@ var SkipWhileSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 500 */ +/* 501 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57385,13 +57452,13 @@ function startWith() { /***/ }), -/* 501 */ +/* 502 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "subscribeOn", function() { return subscribeOn; }); -/* harmony import */ var _observable_SubscribeOnObservable__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(502); +/* harmony import */ var _observable_SubscribeOnObservable__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(503); /** PURE_IMPORTS_START _observable_SubscribeOnObservable PURE_IMPORTS_END */ function subscribeOn(scheduler, delay) { @@ -57416,7 +57483,7 @@ var SubscribeOnOperator = /*@__PURE__*/ (function () { /***/ }), -/* 502 */ +/* 503 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57480,13 +57547,13 @@ var SubscribeOnObservable = /*@__PURE__*/ (function (_super) { /***/ }), -/* 503 */ +/* 504 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "switchAll", function() { return switchAll; }); -/* harmony import */ var _switchMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(504); +/* harmony import */ var _switchMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(505); /* harmony import */ var _util_identity__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(26); /** PURE_IMPORTS_START _switchMap,_util_identity PURE_IMPORTS_END */ @@ -57498,7 +57565,7 @@ function switchAll() { /***/ }), -/* 504 */ +/* 505 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57586,13 +57653,13 @@ var SwitchMapSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 505 */ +/* 506 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "switchMapTo", function() { return switchMapTo; }); -/* harmony import */ var _switchMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(504); +/* harmony import */ var _switchMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(505); /** PURE_IMPORTS_START _switchMap PURE_IMPORTS_END */ function switchMapTo(innerObservable, resultSelector) { @@ -57602,7 +57669,7 @@ function switchMapTo(innerObservable, resultSelector) { /***/ }), -/* 506 */ +/* 507 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57650,7 +57717,7 @@ var TakeUntilSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 507 */ +/* 508 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57718,7 +57785,7 @@ var TakeWhileSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 508 */ +/* 509 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57806,7 +57873,7 @@ var TapSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 509 */ +/* 510 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57908,7 +57975,7 @@ var ThrottleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 510 */ +/* 511 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57917,7 +57984,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var tslib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(13); /* harmony import */ var _Subscriber__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(12); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(56); -/* harmony import */ var _throttle__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(509); +/* harmony import */ var _throttle__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(510); /** PURE_IMPORTS_START tslib,_Subscriber,_scheduler_async,_throttle PURE_IMPORTS_END */ @@ -58006,7 +58073,7 @@ function dispatchNext(arg) { /***/ }), -/* 511 */ +/* 512 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58014,7 +58081,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "timeInterval", function() { return timeInterval; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TimeInterval", function() { return TimeInterval; }); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(56); -/* harmony import */ var _scan__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(471); +/* harmony import */ var _scan__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(472); /* harmony import */ var _observable_defer__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(92); /* harmony import */ var _map__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(67); /** PURE_IMPORTS_START _scheduler_async,_scan,_observable_defer,_map PURE_IMPORTS_END */ @@ -58050,7 +58117,7 @@ var TimeInterval = /*@__PURE__*/ (function () { /***/ }), -/* 512 */ +/* 513 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58058,7 +58125,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "timeout", function() { return timeout; }); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(56); /* harmony import */ var _util_TimeoutError__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(65); -/* harmony import */ var _timeoutWith__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(513); +/* harmony import */ var _timeoutWith__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(514); /* harmony import */ var _observable_throwError__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(50); /** PURE_IMPORTS_START _scheduler_async,_util_TimeoutError,_timeoutWith,_observable_throwError PURE_IMPORTS_END */ @@ -58075,7 +58142,7 @@ function timeout(due, scheduler) { /***/ }), -/* 513 */ +/* 514 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58083,7 +58150,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "timeoutWith", function() { return timeoutWith; }); /* harmony import */ var tslib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(13); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(56); -/* harmony import */ var _util_isDate__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(445); +/* harmony import */ var _util_isDate__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(446); /* harmony import */ var _innerSubscribe__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(91); /** PURE_IMPORTS_START tslib,_scheduler_async,_util_isDate,_innerSubscribe PURE_IMPORTS_END */ @@ -58154,7 +58221,7 @@ var TimeoutWithSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 514 */ +/* 515 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58184,13 +58251,13 @@ var Timestamp = /*@__PURE__*/ (function () { /***/ }), -/* 515 */ +/* 516 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "toArray", function() { return toArray; }); -/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(470); +/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(471); /** PURE_IMPORTS_START _reduce PURE_IMPORTS_END */ function toArrayReducer(arr, item, index) { @@ -58207,7 +58274,7 @@ function toArray() { /***/ }), -/* 516 */ +/* 517 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58285,7 +58352,7 @@ var WindowSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 517 */ +/* 518 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58375,7 +58442,7 @@ var WindowCountSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 518 */ +/* 519 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58545,7 +58612,7 @@ function dispatchWindowClose(state) { /***/ }), -/* 519 */ +/* 520 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58688,7 +58755,7 @@ var WindowToggleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 520 */ +/* 521 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58785,7 +58852,7 @@ var WindowSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 521 */ +/* 522 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58880,7 +58947,7 @@ var WithLatestFromSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 522 */ +/* 523 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58902,7 +58969,7 @@ function zip() { /***/ }), -/* 523 */ +/* 524 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58918,7 +58985,7 @@ function zipAll(project) { /***/ }), -/* 524 */ +/* 525 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -58928,7 +58995,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); -var _observe_lines = __webpack_require__(525); +var _observe_lines = __webpack_require__(526); Object.keys(_observe_lines).forEach(function (key) { if (key === "default" || key === "__esModule") return; @@ -58941,7 +59008,7 @@ Object.keys(_observe_lines).forEach(function (key) { }); }); -var _observe_readable = __webpack_require__(526); +var _observe_readable = __webpack_require__(527); Object.keys(_observe_readable).forEach(function (key) { if (key === "default" || key === "__esModule") return; @@ -58955,7 +59022,7 @@ Object.keys(_observe_readable).forEach(function (key) { }); /***/ }), -/* 525 */ +/* 526 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -58968,9 +59035,9 @@ exports.observeLines = observeLines; var Rx = _interopRequireWildcard(__webpack_require__(9)); -var _operators = __webpack_require__(426); +var _operators = __webpack_require__(427); -var _observe_readable = __webpack_require__(526); +var _observe_readable = __webpack_require__(527); function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } @@ -59033,7 +59100,7 @@ function observeLines(readable) { } /***/ }), -/* 526 */ +/* 527 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59046,7 +59113,7 @@ exports.observeReadable = observeReadable; var Rx = _interopRequireWildcard(__webpack_require__(9)); -var _operators = __webpack_require__(426); +var _operators = __webpack_require__(427); function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } @@ -59070,7 +59137,7 @@ function observeReadable(readable) { } /***/ }), -/* 527 */ +/* 528 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -59173,13 +59240,13 @@ async function setupRemoteCache(repoRootPath) { } /***/ }), -/* 528 */ +/* 529 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "BuildCommand", function() { return BuildCommand; }); -/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(421); +/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(422); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -59207,7 +59274,7 @@ const BuildCommand = { }; /***/ }), -/* 529 */ +/* 530 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -59217,11 +59284,11 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(dedent__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(240); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(530); +/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(531); /* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(ora__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_3__); -/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(421); +/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(422); /* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(231); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(220); /* @@ -59324,20 +59391,20 @@ const CleanCommand = { }; /***/ }), -/* 530 */ +/* 531 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const readline = __webpack_require__(531); -const chalk = __webpack_require__(532); -const cliCursor = __webpack_require__(535); -const cliSpinners = __webpack_require__(537); -const logSymbols = __webpack_require__(539); -const stripAnsi = __webpack_require__(545); -const wcwidth = __webpack_require__(547); -const isInteractive = __webpack_require__(551); -const MuteStream = __webpack_require__(552); +const readline = __webpack_require__(532); +const chalk = __webpack_require__(533); +const cliCursor = __webpack_require__(536); +const cliSpinners = __webpack_require__(538); +const logSymbols = __webpack_require__(540); +const stripAnsi = __webpack_require__(546); +const wcwidth = __webpack_require__(548); +const isInteractive = __webpack_require__(552); +const MuteStream = __webpack_require__(553); const TEXT = Symbol('text'); const PREFIX_TEXT = Symbol('prefixText'); @@ -59690,13 +59757,13 @@ module.exports.promise = (action, options) => { /***/ }), -/* 531 */ +/* 532 */ /***/ (function(module, exports) { module.exports = require("readline"); /***/ }), -/* 532 */ +/* 533 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59706,7 +59773,7 @@ const {stdout: stdoutColor, stderr: stderrColor} = __webpack_require__(121); const { stringReplaceAll, stringEncaseCRLFWithFirstIndex -} = __webpack_require__(533); +} = __webpack_require__(534); // `supportsColor.level` → `ansiStyles.color[name]` mapping const levelMapping = [ @@ -59907,7 +59974,7 @@ const chalkTag = (chalk, ...strings) => { } if (template === undefined) { - template = __webpack_require__(534); + template = __webpack_require__(535); } return template(chalk, parts.join('')); @@ -59936,7 +60003,7 @@ module.exports = chalk; /***/ }), -/* 533 */ +/* 534 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59982,7 +60049,7 @@ module.exports = { /***/ }), -/* 534 */ +/* 535 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60123,12 +60190,12 @@ module.exports = (chalk, temporary) => { /***/ }), -/* 535 */ +/* 536 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const restoreCursor = __webpack_require__(536); +const restoreCursor = __webpack_require__(537); let isHidden = false; @@ -60165,7 +60232,7 @@ exports.toggle = (force, writableStream) => { /***/ }), -/* 536 */ +/* 537 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60181,13 +60248,13 @@ module.exports = onetime(() => { /***/ }), -/* 537 */ +/* 538 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const spinners = Object.assign({}, __webpack_require__(538)); +const spinners = Object.assign({}, __webpack_require__(539)); const spinnersList = Object.keys(spinners); @@ -60205,18 +60272,18 @@ module.exports.default = spinners; /***/ }), -/* 538 */ +/* 539 */ /***/ (function(module) { module.exports = JSON.parse("{\"dots\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠹\",\"⠸\",\"⠼\",\"⠴\",\"⠦\",\"⠧\",\"⠇\",\"⠏\"]},\"dots2\":{\"interval\":80,\"frames\":[\"⣾\",\"⣽\",\"⣻\",\"⢿\",\"⡿\",\"⣟\",\"⣯\",\"⣷\"]},\"dots3\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠚\",\"⠞\",\"⠖\",\"⠦\",\"⠴\",\"⠲\",\"⠳\",\"⠓\"]},\"dots4\":{\"interval\":80,\"frames\":[\"⠄\",\"⠆\",\"⠇\",\"⠋\",\"⠙\",\"⠸\",\"⠰\",\"⠠\",\"⠰\",\"⠸\",\"⠙\",\"⠋\",\"⠇\",\"⠆\"]},\"dots5\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\"]},\"dots6\":{\"interval\":80,\"frames\":[\"⠁\",\"⠉\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠤\",\"⠄\",\"⠄\",\"⠤\",\"⠴\",\"⠲\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠚\",\"⠙\",\"⠉\",\"⠁\"]},\"dots7\":{\"interval\":80,\"frames\":[\"⠈\",\"⠉\",\"⠋\",\"⠓\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠖\",\"⠦\",\"⠤\",\"⠠\",\"⠠\",\"⠤\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\",\"⠉\",\"⠈\"]},\"dots8\":{\"interval\":80,\"frames\":[\"⠁\",\"⠁\",\"⠉\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠤\",\"⠄\",\"⠄\",\"⠤\",\"⠠\",\"⠠\",\"⠤\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\",\"⠉\",\"⠈\",\"⠈\"]},\"dots9\":{\"interval\":80,\"frames\":[\"⢹\",\"⢺\",\"⢼\",\"⣸\",\"⣇\",\"⡧\",\"⡗\",\"⡏\"]},\"dots10\":{\"interval\":80,\"frames\":[\"⢄\",\"⢂\",\"⢁\",\"⡁\",\"⡈\",\"⡐\",\"⡠\"]},\"dots11\":{\"interval\":100,\"frames\":[\"⠁\",\"⠂\",\"⠄\",\"⡀\",\"⢀\",\"⠠\",\"⠐\",\"⠈\"]},\"dots12\":{\"interval\":80,\"frames\":[\"⢀⠀\",\"⡀⠀\",\"⠄⠀\",\"⢂⠀\",\"⡂⠀\",\"⠅⠀\",\"⢃⠀\",\"⡃⠀\",\"⠍⠀\",\"⢋⠀\",\"⡋⠀\",\"⠍⠁\",\"⢋⠁\",\"⡋⠁\",\"⠍⠉\",\"⠋⠉\",\"⠋⠉\",\"⠉⠙\",\"⠉⠙\",\"⠉⠩\",\"⠈⢙\",\"⠈⡙\",\"⢈⠩\",\"⡀⢙\",\"⠄⡙\",\"⢂⠩\",\"⡂⢘\",\"⠅⡘\",\"⢃⠨\",\"⡃⢐\",\"⠍⡐\",\"⢋⠠\",\"⡋⢀\",\"⠍⡁\",\"⢋⠁\",\"⡋⠁\",\"⠍⠉\",\"⠋⠉\",\"⠋⠉\",\"⠉⠙\",\"⠉⠙\",\"⠉⠩\",\"⠈⢙\",\"⠈⡙\",\"⠈⠩\",\"⠀⢙\",\"⠀⡙\",\"⠀⠩\",\"⠀⢘\",\"⠀⡘\",\"⠀⠨\",\"⠀⢐\",\"⠀⡐\",\"⠀⠠\",\"⠀⢀\",\"⠀⡀\"]},\"dots8Bit\":{\"interval\":80,\"frames\":[\"⠀\",\"⠁\",\"⠂\",\"⠃\",\"⠄\",\"⠅\",\"⠆\",\"⠇\",\"⡀\",\"⡁\",\"⡂\",\"⡃\",\"⡄\",\"⡅\",\"⡆\",\"⡇\",\"⠈\",\"⠉\",\"⠊\",\"⠋\",\"⠌\",\"⠍\",\"⠎\",\"⠏\",\"⡈\",\"⡉\",\"⡊\",\"⡋\",\"⡌\",\"⡍\",\"⡎\",\"⡏\",\"⠐\",\"⠑\",\"⠒\",\"⠓\",\"⠔\",\"⠕\",\"⠖\",\"⠗\",\"⡐\",\"⡑\",\"⡒\",\"⡓\",\"⡔\",\"⡕\",\"⡖\",\"⡗\",\"⠘\",\"⠙\",\"⠚\",\"⠛\",\"⠜\",\"⠝\",\"⠞\",\"⠟\",\"⡘\",\"⡙\",\"⡚\",\"⡛\",\"⡜\",\"⡝\",\"⡞\",\"⡟\",\"⠠\",\"⠡\",\"⠢\",\"⠣\",\"⠤\",\"⠥\",\"⠦\",\"⠧\",\"⡠\",\"⡡\",\"⡢\",\"⡣\",\"⡤\",\"⡥\",\"⡦\",\"⡧\",\"⠨\",\"⠩\",\"⠪\",\"⠫\",\"⠬\",\"⠭\",\"⠮\",\"⠯\",\"⡨\",\"⡩\",\"⡪\",\"⡫\",\"⡬\",\"⡭\",\"⡮\",\"⡯\",\"⠰\",\"⠱\",\"⠲\",\"⠳\",\"⠴\",\"⠵\",\"⠶\",\"⠷\",\"⡰\",\"⡱\",\"⡲\",\"⡳\",\"⡴\",\"⡵\",\"⡶\",\"⡷\",\"⠸\",\"⠹\",\"⠺\",\"⠻\",\"⠼\",\"⠽\",\"⠾\",\"⠿\",\"⡸\",\"⡹\",\"⡺\",\"⡻\",\"⡼\",\"⡽\",\"⡾\",\"⡿\",\"⢀\",\"⢁\",\"⢂\",\"⢃\",\"⢄\",\"⢅\",\"⢆\",\"⢇\",\"⣀\",\"⣁\",\"⣂\",\"⣃\",\"⣄\",\"⣅\",\"⣆\",\"⣇\",\"⢈\",\"⢉\",\"⢊\",\"⢋\",\"⢌\",\"⢍\",\"⢎\",\"⢏\",\"⣈\",\"⣉\",\"⣊\",\"⣋\",\"⣌\",\"⣍\",\"⣎\",\"⣏\",\"⢐\",\"⢑\",\"⢒\",\"⢓\",\"⢔\",\"⢕\",\"⢖\",\"⢗\",\"⣐\",\"⣑\",\"⣒\",\"⣓\",\"⣔\",\"⣕\",\"⣖\",\"⣗\",\"⢘\",\"⢙\",\"⢚\",\"⢛\",\"⢜\",\"⢝\",\"⢞\",\"⢟\",\"⣘\",\"⣙\",\"⣚\",\"⣛\",\"⣜\",\"⣝\",\"⣞\",\"⣟\",\"⢠\",\"⢡\",\"⢢\",\"⢣\",\"⢤\",\"⢥\",\"⢦\",\"⢧\",\"⣠\",\"⣡\",\"⣢\",\"⣣\",\"⣤\",\"⣥\",\"⣦\",\"⣧\",\"⢨\",\"⢩\",\"⢪\",\"⢫\",\"⢬\",\"⢭\",\"⢮\",\"⢯\",\"⣨\",\"⣩\",\"⣪\",\"⣫\",\"⣬\",\"⣭\",\"⣮\",\"⣯\",\"⢰\",\"⢱\",\"⢲\",\"⢳\",\"⢴\",\"⢵\",\"⢶\",\"⢷\",\"⣰\",\"⣱\",\"⣲\",\"⣳\",\"⣴\",\"⣵\",\"⣶\",\"⣷\",\"⢸\",\"⢹\",\"⢺\",\"⢻\",\"⢼\",\"⢽\",\"⢾\",\"⢿\",\"⣸\",\"⣹\",\"⣺\",\"⣻\",\"⣼\",\"⣽\",\"⣾\",\"⣿\"]},\"line\":{\"interval\":130,\"frames\":[\"-\",\"\\\\\",\"|\",\"/\"]},\"line2\":{\"interval\":100,\"frames\":[\"⠂\",\"-\",\"–\",\"—\",\"–\",\"-\"]},\"pipe\":{\"interval\":100,\"frames\":[\"┤\",\"┘\",\"┴\",\"└\",\"├\",\"┌\",\"┬\",\"┐\"]},\"simpleDots\":{\"interval\":400,\"frames\":[\". \",\".. \",\"...\",\" \"]},\"simpleDotsScrolling\":{\"interval\":200,\"frames\":[\". \",\".. \",\"...\",\" ..\",\" .\",\" \"]},\"star\":{\"interval\":70,\"frames\":[\"✶\",\"✸\",\"✹\",\"✺\",\"✹\",\"✷\"]},\"star2\":{\"interval\":80,\"frames\":[\"+\",\"x\",\"*\"]},\"flip\":{\"interval\":70,\"frames\":[\"_\",\"_\",\"_\",\"-\",\"`\",\"`\",\"'\",\"´\",\"-\",\"_\",\"_\",\"_\"]},\"hamburger\":{\"interval\":100,\"frames\":[\"☱\",\"☲\",\"☴\"]},\"growVertical\":{\"interval\":120,\"frames\":[\"▁\",\"▃\",\"▄\",\"▅\",\"▆\",\"▇\",\"▆\",\"▅\",\"▄\",\"▃\"]},\"growHorizontal\":{\"interval\":120,\"frames\":[\"▏\",\"▎\",\"▍\",\"▌\",\"▋\",\"▊\",\"▉\",\"▊\",\"▋\",\"▌\",\"▍\",\"▎\"]},\"balloon\":{\"interval\":140,\"frames\":[\" \",\".\",\"o\",\"O\",\"@\",\"*\",\" \"]},\"balloon2\":{\"interval\":120,\"frames\":[\".\",\"o\",\"O\",\"°\",\"O\",\"o\",\".\"]},\"noise\":{\"interval\":100,\"frames\":[\"▓\",\"▒\",\"░\"]},\"bounce\":{\"interval\":120,\"frames\":[\"⠁\",\"⠂\",\"⠄\",\"⠂\"]},\"boxBounce\":{\"interval\":120,\"frames\":[\"▖\",\"▘\",\"▝\",\"▗\"]},\"boxBounce2\":{\"interval\":100,\"frames\":[\"▌\",\"▀\",\"▐\",\"▄\"]},\"triangle\":{\"interval\":50,\"frames\":[\"◢\",\"◣\",\"◤\",\"◥\"]},\"arc\":{\"interval\":100,\"frames\":[\"◜\",\"◠\",\"◝\",\"◞\",\"◡\",\"◟\"]},\"circle\":{\"interval\":120,\"frames\":[\"◡\",\"⊙\",\"◠\"]},\"squareCorners\":{\"interval\":180,\"frames\":[\"◰\",\"◳\",\"◲\",\"◱\"]},\"circleQuarters\":{\"interval\":120,\"frames\":[\"◴\",\"◷\",\"◶\",\"◵\"]},\"circleHalves\":{\"interval\":50,\"frames\":[\"◐\",\"◓\",\"◑\",\"◒\"]},\"squish\":{\"interval\":100,\"frames\":[\"╫\",\"╪\"]},\"toggle\":{\"interval\":250,\"frames\":[\"⊶\",\"⊷\"]},\"toggle2\":{\"interval\":80,\"frames\":[\"▫\",\"▪\"]},\"toggle3\":{\"interval\":120,\"frames\":[\"□\",\"■\"]},\"toggle4\":{\"interval\":100,\"frames\":[\"■\",\"□\",\"▪\",\"▫\"]},\"toggle5\":{\"interval\":100,\"frames\":[\"▮\",\"▯\"]},\"toggle6\":{\"interval\":300,\"frames\":[\"ဝ\",\"၀\"]},\"toggle7\":{\"interval\":80,\"frames\":[\"⦾\",\"⦿\"]},\"toggle8\":{\"interval\":100,\"frames\":[\"◍\",\"◌\"]},\"toggle9\":{\"interval\":100,\"frames\":[\"◉\",\"◎\"]},\"toggle10\":{\"interval\":100,\"frames\":[\"㊂\",\"㊀\",\"㊁\"]},\"toggle11\":{\"interval\":50,\"frames\":[\"⧇\",\"⧆\"]},\"toggle12\":{\"interval\":120,\"frames\":[\"☗\",\"☖\"]},\"toggle13\":{\"interval\":80,\"frames\":[\"=\",\"*\",\"-\"]},\"arrow\":{\"interval\":100,\"frames\":[\"←\",\"↖\",\"↑\",\"↗\",\"→\",\"↘\",\"↓\",\"↙\"]},\"arrow2\":{\"interval\":80,\"frames\":[\"⬆️ \",\"↗️ \",\"➡️ \",\"↘️ \",\"⬇️ \",\"↙️ \",\"⬅️ \",\"↖️ \"]},\"arrow3\":{\"interval\":120,\"frames\":[\"▹▹▹▹▹\",\"▸▹▹▹▹\",\"▹▸▹▹▹\",\"▹▹▸▹▹\",\"▹▹▹▸▹\",\"▹▹▹▹▸\"]},\"bouncingBar\":{\"interval\":80,\"frames\":[\"[ ]\",\"[= ]\",\"[== ]\",\"[=== ]\",\"[ ===]\",\"[ ==]\",\"[ =]\",\"[ ]\",\"[ =]\",\"[ ==]\",\"[ ===]\",\"[====]\",\"[=== ]\",\"[== ]\",\"[= ]\"]},\"bouncingBall\":{\"interval\":80,\"frames\":[\"( ● )\",\"( ● )\",\"( ● )\",\"( ● )\",\"( ●)\",\"( ● )\",\"( ● )\",\"( ● )\",\"( ● )\",\"(● )\"]},\"smiley\":{\"interval\":200,\"frames\":[\"😄 \",\"😝 \"]},\"monkey\":{\"interval\":300,\"frames\":[\"🙈 \",\"🙈 \",\"🙉 \",\"🙊 \"]},\"hearts\":{\"interval\":100,\"frames\":[\"💛 \",\"💙 \",\"💜 \",\"💚 \",\"❤️ \"]},\"clock\":{\"interval\":100,\"frames\":[\"🕛 \",\"🕐 \",\"🕑 \",\"🕒 \",\"🕓 \",\"🕔 \",\"🕕 \",\"🕖 \",\"🕗 \",\"🕘 \",\"🕙 \",\"🕚 \"]},\"earth\":{\"interval\":180,\"frames\":[\"🌍 \",\"🌎 \",\"🌏 \"]},\"material\":{\"interval\":17,\"frames\":[\"█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"███▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"████▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"██████▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"██████▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"███████▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"████████▁▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"██████████▁▁▁▁▁▁▁▁▁▁\",\"███████████▁▁▁▁▁▁▁▁▁\",\"█████████████▁▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁▁██████████████▁▁▁▁\",\"▁▁▁██████████████▁▁▁\",\"▁▁▁▁█████████████▁▁▁\",\"▁▁▁▁██████████████▁▁\",\"▁▁▁▁██████████████▁▁\",\"▁▁▁▁▁██████████████▁\",\"▁▁▁▁▁██████████████▁\",\"▁▁▁▁▁██████████████▁\",\"▁▁▁▁▁▁██████████████\",\"▁▁▁▁▁▁██████████████\",\"▁▁▁▁▁▁▁█████████████\",\"▁▁▁▁▁▁▁█████████████\",\"▁▁▁▁▁▁▁▁████████████\",\"▁▁▁▁▁▁▁▁████████████\",\"▁▁▁▁▁▁▁▁▁███████████\",\"▁▁▁▁▁▁▁▁▁███████████\",\"▁▁▁▁▁▁▁▁▁▁██████████\",\"▁▁▁▁▁▁▁▁▁▁██████████\",\"▁▁▁▁▁▁▁▁▁▁▁▁████████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁███████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁██████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████\",\"█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"███▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"████▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"█████▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"█████▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"██████▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"████████▁▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"███████████▁▁▁▁▁▁▁▁▁\",\"████████████▁▁▁▁▁▁▁▁\",\"████████████▁▁▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁▁▁█████████████▁▁▁▁\",\"▁▁▁▁▁████████████▁▁▁\",\"▁▁▁▁▁████████████▁▁▁\",\"▁▁▁▁▁▁███████████▁▁▁\",\"▁▁▁▁▁▁▁▁█████████▁▁▁\",\"▁▁▁▁▁▁▁▁█████████▁▁▁\",\"▁▁▁▁▁▁▁▁▁█████████▁▁\",\"▁▁▁▁▁▁▁▁▁█████████▁▁\",\"▁▁▁▁▁▁▁▁▁▁█████████▁\",\"▁▁▁▁▁▁▁▁▁▁▁████████▁\",\"▁▁▁▁▁▁▁▁▁▁▁████████▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁███████▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁███████▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁███████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁███████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\"]},\"moon\":{\"interval\":80,\"frames\":[\"🌑 \",\"🌒 \",\"🌓 \",\"🌔 \",\"🌕 \",\"🌖 \",\"🌗 \",\"🌘 \"]},\"runner\":{\"interval\":140,\"frames\":[\"🚶 \",\"🏃 \"]},\"pong\":{\"interval\":80,\"frames\":[\"▐⠂ ▌\",\"▐⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂▌\",\"▐ ⠠▌\",\"▐ ⡀▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐⠠ ▌\"]},\"shark\":{\"interval\":120,\"frames\":[\"▐|\\\\____________▌\",\"▐_|\\\\___________▌\",\"▐__|\\\\__________▌\",\"▐___|\\\\_________▌\",\"▐____|\\\\________▌\",\"▐_____|\\\\_______▌\",\"▐______|\\\\______▌\",\"▐_______|\\\\_____▌\",\"▐________|\\\\____▌\",\"▐_________|\\\\___▌\",\"▐__________|\\\\__▌\",\"▐___________|\\\\_▌\",\"▐____________|\\\\▌\",\"▐____________/|▌\",\"▐___________/|_▌\",\"▐__________/|__▌\",\"▐_________/|___▌\",\"▐________/|____▌\",\"▐_______/|_____▌\",\"▐______/|______▌\",\"▐_____/|_______▌\",\"▐____/|________▌\",\"▐___/|_________▌\",\"▐__/|__________▌\",\"▐_/|___________▌\",\"▐/|____________▌\"]},\"dqpb\":{\"interval\":100,\"frames\":[\"d\",\"q\",\"p\",\"b\"]},\"weather\":{\"interval\":100,\"frames\":[\"☀️ \",\"☀️ \",\"☀️ \",\"🌤 \",\"⛅️ \",\"🌥 \",\"☁️ \",\"🌧 \",\"🌨 \",\"🌧 \",\"🌨 \",\"🌧 \",\"🌨 \",\"⛈ \",\"🌨 \",\"🌧 \",\"🌨 \",\"☁️ \",\"🌥 \",\"⛅️ \",\"🌤 \",\"☀️ \",\"☀️ \"]},\"christmas\":{\"interval\":400,\"frames\":[\"🌲\",\"🎄\"]},\"grenade\":{\"interval\":80,\"frames\":[\"، \",\"′ \",\" ´ \",\" ‾ \",\" ⸌\",\" ⸊\",\" |\",\" ⁎\",\" ⁕\",\" ෴ \",\" ⁓\",\" \",\" \",\" \"]},\"point\":{\"interval\":125,\"frames\":[\"∙∙∙\",\"●∙∙\",\"∙●∙\",\"∙∙●\",\"∙∙∙\"]},\"layer\":{\"interval\":150,\"frames\":[\"-\",\"=\",\"≡\"]},\"betaWave\":{\"interval\":80,\"frames\":[\"ρββββββ\",\"βρβββββ\",\"ββρββββ\",\"βββρβββ\",\"ββββρββ\",\"βββββρβ\",\"ββββββρ\"]},\"aesthetic\":{\"interval\":80,\"frames\":[\"▰▱▱▱▱▱▱\",\"▰▰▱▱▱▱▱\",\"▰▰▰▱▱▱▱\",\"▰▰▰▰▱▱▱\",\"▰▰▰▰▰▱▱\",\"▰▰▰▰▰▰▱\",\"▰▰▰▰▰▰▰\",\"▰▱▱▱▱▱▱\"]}}"); /***/ }), -/* 539 */ +/* 540 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const chalk = __webpack_require__(540); +const chalk = __webpack_require__(541); const isSupported = process.platform !== 'win32' || process.env.CI || process.env.TERM === 'xterm-256color'; @@ -60238,16 +60305,16 @@ module.exports = isSupported ? main : fallbacks; /***/ }), -/* 540 */ +/* 541 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const escapeStringRegexp = __webpack_require__(357); -const ansiStyles = __webpack_require__(541); -const stdoutColor = __webpack_require__(542).stdout; +const ansiStyles = __webpack_require__(542); +const stdoutColor = __webpack_require__(543).stdout; -const template = __webpack_require__(544); +const template = __webpack_require__(545); const isSimpleWindowsTerm = process.platform === 'win32' && !(process.env.TERM || '').toLowerCase().startsWith('xterm'); @@ -60473,7 +60540,7 @@ module.exports.default = module.exports; // For TypeScript /***/ }), -/* 541 */ +/* 542 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60646,13 +60713,13 @@ Object.defineProperty(module, 'exports', { /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(116)(module))) /***/ }), -/* 542 */ +/* 543 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const os = __webpack_require__(122); -const hasFlag = __webpack_require__(543); +const hasFlag = __webpack_require__(544); const env = process.env; @@ -60784,7 +60851,7 @@ module.exports = { /***/ }), -/* 543 */ +/* 544 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60799,7 +60866,7 @@ module.exports = (flag, argv) => { /***/ }), -/* 544 */ +/* 545 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60934,18 +61001,18 @@ module.exports = (chalk, tmp) => { /***/ }), -/* 545 */ +/* 546 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const ansiRegex = __webpack_require__(546); +const ansiRegex = __webpack_require__(547); module.exports = string => typeof string === 'string' ? string.replace(ansiRegex(), '') : string; /***/ }), -/* 546 */ +/* 547 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60962,14 +61029,14 @@ module.exports = ({onlyFirst = false} = {}) => { /***/ }), -/* 547 */ +/* 548 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var defaults = __webpack_require__(548) -var combining = __webpack_require__(550) +var defaults = __webpack_require__(549) +var combining = __webpack_require__(551) var DEFAULTS = { nul: 0, @@ -61068,10 +61135,10 @@ function bisearch(ucs) { /***/ }), -/* 548 */ +/* 549 */ /***/ (function(module, exports, __webpack_require__) { -var clone = __webpack_require__(549); +var clone = __webpack_require__(550); module.exports = function(options, defaults) { options = options || {}; @@ -61086,7 +61153,7 @@ module.exports = function(options, defaults) { }; /***/ }), -/* 549 */ +/* 550 */ /***/ (function(module, exports, __webpack_require__) { var clone = (function() { @@ -61258,7 +61325,7 @@ if ( true && module.exports) { /***/ }), -/* 550 */ +/* 551 */ /***/ (function(module, exports) { module.exports = [ @@ -61314,7 +61381,7 @@ module.exports = [ /***/ }), -/* 551 */ +/* 552 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -61330,7 +61397,7 @@ module.exports = ({stream = process.stdout} = {}) => { /***/ }), -/* 552 */ +/* 553 */ /***/ (function(module, exports, __webpack_require__) { var Stream = __webpack_require__(173) @@ -61481,7 +61548,7 @@ MuteStream.prototype.close = proxy('close') /***/ }), -/* 553 */ +/* 554 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -61491,11 +61558,11 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(dedent__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(240); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(530); +/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(531); /* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(ora__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_3__); -/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(421); +/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(422); /* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(231); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(220); /* @@ -61604,7 +61671,7 @@ const ResetCommand = { }; /***/ }), -/* 554 */ +/* 555 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -61614,7 +61681,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(dedent__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var _utils_errors__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(341); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(220); -/* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(555); +/* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(556); /* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(340); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one @@ -61673,7 +61740,7 @@ const RunCommand = { }; /***/ }), -/* 555 */ +/* 556 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -61728,13 +61795,13 @@ async function parallelize(items, fn, concurrency = 4) { } /***/ }), -/* 556 */ +/* 557 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "WatchCommand", function() { return WatchCommand; }); -/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(421); +/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(422); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -61764,86 +61831,6 @@ const WatchCommand = { }; -/***/ }), -/* 557 */ -/***/ (function(module, __webpack_exports__, __webpack_require__) { - -"use strict"; -__webpack_require__.r(__webpack_exports__); -/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PatchNativeModulesCommand", function() { return PatchNativeModulesCommand; }); -/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4); -/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(132); -/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var _kbn_dev_utils_ci_stats_reporter__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(131); -/* harmony import */ var _kbn_dev_utils_ci_stats_reporter__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_kbn_dev_utils_ci_stats_reporter__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(220); -/* harmony import */ var _utils_child_process__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(221); -/* - * Copyright 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. - */ - - - - - -const PatchNativeModulesCommand = { - description: 'Patch native modules by running build commands on M1 Macs', - name: 'patch_native_modules', - - async run(projects, _, { - kbn - }) { - var _projects$get; - - const kibanaProjectPath = ((_projects$get = projects.get('kibana')) === null || _projects$get === void 0 ? void 0 : _projects$get.path) || ''; - const reporter = _kbn_dev_utils_ci_stats_reporter__WEBPACK_IMPORTED_MODULE_2__["CiStatsReporter"].fromEnv(_utils_log__WEBPACK_IMPORTED_MODULE_3__["log"]); - - if (process.platform !== 'darwin' || process.arch !== 'arm64') { - return; - } - - const startTime = Date.now(); - const nodeSassDir = path__WEBPACK_IMPORTED_MODULE_0___default.a.resolve(kibanaProjectPath, 'node_modules/node-sass'); - const nodeSassNativeDist = path__WEBPACK_IMPORTED_MODULE_0___default.a.resolve(nodeSassDir, `vendor/darwin-arm64-${process.versions.modules}/binding.node`); - - if (!fs__WEBPACK_IMPORTED_MODULE_1___default.a.existsSync(nodeSassNativeDist)) { - _utils_log__WEBPACK_IMPORTED_MODULE_3__["log"].info('Running build script for node-sass'); - await Object(_utils_child_process__WEBPACK_IMPORTED_MODULE_4__["spawn"])('npm', ['run', 'build'], { - cwd: nodeSassDir - }); - } - - const re2Dir = path__WEBPACK_IMPORTED_MODULE_0___default.a.resolve(kibanaProjectPath, 'node_modules/re2'); - const re2NativeDist = path__WEBPACK_IMPORTED_MODULE_0___default.a.resolve(re2Dir, 'build/Release/re2.node'); - - if (!fs__WEBPACK_IMPORTED_MODULE_1___default.a.existsSync(re2NativeDist)) { - _utils_log__WEBPACK_IMPORTED_MODULE_3__["log"].info('Running build script for re2'); - await Object(_utils_child_process__WEBPACK_IMPORTED_MODULE_4__["spawn"])('npm', ['run', 'rebuild'], { - cwd: re2Dir - }); - } - - _utils_log__WEBPACK_IMPORTED_MODULE_3__["log"].success('native modules should be setup for native ARM Mac development'); // send timings - - await reporter.timings({ - upstreamBranch: kbn.kibanaProject.json.branch, - // prevent loading @kbn/utils by passing null - kibanaUuid: kbn.getUuid() || null, - timings: [{ - group: 'scripts/kbn bootstrap', - id: 'patch native modudles for arm macs', - ms: Date.now() - startTime - }] - }); - } - -}; - /***/ }), /* 558 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { @@ -61856,7 +61843,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_errors__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(341); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(220); /* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(340); -/* harmony import */ var _utils_projects_tree__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(420); +/* harmony import */ var _utils_projects_tree__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(421); /* harmony import */ var _utils_kibana__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(559); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } @@ -62301,7 +62288,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _build_bazel_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(565); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildBazelProductionProjects", function() { return _build_bazel_production_projects__WEBPACK_IMPORTED_MODULE_0__["buildBazelProductionProjects"]; }); -/* harmony import */ var _build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(812); +/* harmony import */ var _build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(806); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildNonBazelProductionProjects", function() { return _build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_1__["buildNonBazelProductionProjects"]; }); /* @@ -62327,8 +62314,8 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var globby__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(globby__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var _build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(812); -/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(421); +/* harmony import */ var _build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(806); +/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(422); /* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(231); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(220); /* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(343); @@ -88979,8 +88966,8 @@ const arrayUnion = __webpack_require__(242); const merge2 = __webpack_require__(243); const fastGlob = __webpack_require__(779); const dirGlob = __webpack_require__(326); -const gitignore = __webpack_require__(810); -const {FilterStream, UniqueStream} = __webpack_require__(811); +const gitignore = __webpack_require__(804); +const {FilterStream, UniqueStream} = __webpack_require__(805); const DEFAULT_FILTER = () => false; @@ -89163,10 +89150,10 @@ module.exports.gitignore = gitignore; "use strict"; const taskManager = __webpack_require__(780); -const async_1 = __webpack_require__(796); -const stream_1 = __webpack_require__(806); -const sync_1 = __webpack_require__(807); -const settings_1 = __webpack_require__(809); +const async_1 = __webpack_require__(790); +const stream_1 = __webpack_require__(800); +const sync_1 = __webpack_require__(801); +const settings_1 = __webpack_require__(803); const utils = __webpack_require__(781); async function FastGlob(source, options) { assertPatternsInput(source); @@ -89335,9 +89322,9 @@ const path = __webpack_require__(785); exports.path = path; const pattern = __webpack_require__(786); exports.pattern = pattern; -const stream = __webpack_require__(794); +const stream = __webpack_require__(788); exports.stream = stream; -const string = __webpack_require__(795); +const string = __webpack_require__(789); exports.string = string; @@ -89623,8 +89610,8 @@ exports.matchAny = matchAny; const util = __webpack_require__(113); const braces = __webpack_require__(269); -const picomatch = __webpack_require__(788); -const utils = __webpack_require__(791); +const picomatch = __webpack_require__(279); +const utils = __webpack_require__(282); const isEmptyString = val => val === '' || val === './'; /** @@ -90094,8 +90081,22 @@ module.exports = micromatch; "use strict"; - -module.exports = __webpack_require__(789); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.merge = void 0; +const merge2 = __webpack_require__(243); +function merge(streams) { + const mergedStream = merge2(streams); + streams.forEach((stream) => { + stream.once('error', (error) => mergedStream.emit('error', error)); + }); + mergedStream.once('close', () => propagateCloseEventToSources(streams)); + mergedStream.once('end', () => propagateCloseEventToSources(streams)); + return mergedStream; +} +exports.merge = merge; +function propagateCloseEventToSources(streams) { + streams.forEach((stream) => stream.emit('close')); +} /***/ }), @@ -90104,2286 +90105,167 @@ module.exports = __webpack_require__(789); "use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.isEmpty = exports.isString = void 0; +function isString(input) { + return typeof input === 'string'; +} +exports.isString = isString; +function isEmpty(input) { + return input === ''; +} +exports.isEmpty = isEmpty; -const path = __webpack_require__(4); -const scan = __webpack_require__(790); -const parse = __webpack_require__(793); -const utils = __webpack_require__(791); -const constants = __webpack_require__(792); -const isObject = val => val && typeof val === 'object' && !Array.isArray(val); - -/** - * Creates a matcher function from one or more glob patterns. The - * returned function takes a string to match as its first argument, - * and returns true if the string is a match. The returned matcher - * function also takes a boolean as the second argument that, when true, - * returns an object with additional information. - * - * ```js - * const picomatch = require('picomatch'); - * // picomatch(glob[, options]); - * - * const isMatch = picomatch('*.!(*a)'); - * console.log(isMatch('a.a')); //=> false - * console.log(isMatch('a.b')); //=> true - * ``` - * @name picomatch - * @param {String|Array} `globs` One or more glob patterns. - * @param {Object=} `options` - * @return {Function=} Returns a matcher function. - * @api public - */ - -const picomatch = (glob, options, returnState = false) => { - if (Array.isArray(glob)) { - const fns = glob.map(input => picomatch(input, options, returnState)); - const arrayMatcher = str => { - for (const isMatch of fns) { - const state = isMatch(str); - if (state) return state; - } - return false; - }; - return arrayMatcher; - } - const isState = isObject(glob) && glob.tokens && glob.input; +/***/ }), +/* 790 */ +/***/ (function(module, exports, __webpack_require__) { - if (glob === '' || (typeof glob !== 'string' && !isState)) { - throw new TypeError('Expected pattern to be a non-empty string'); - } +"use strict"; - const opts = options || {}; - const posix = utils.isWindows(options); - const regex = isState - ? picomatch.compileRe(glob, options) - : picomatch.makeRe(glob, options, false, true); +Object.defineProperty(exports, "__esModule", { value: true }); +const stream_1 = __webpack_require__(791); +const provider_1 = __webpack_require__(793); +class ProviderAsync extends provider_1.default { + constructor() { + super(...arguments); + this._reader = new stream_1.default(this._settings); + } + read(task) { + const root = this._getRootDirectory(task); + const options = this._getReaderOptions(task); + const entries = []; + return new Promise((resolve, reject) => { + const stream = this.api(root, task, options); + stream.once('error', reject); + stream.on('data', (entry) => entries.push(options.transform(entry))); + stream.once('end', () => resolve(entries)); + }); + } + api(root, task, options) { + if (task.dynamic) { + return this._reader.dynamic(root, options); + } + return this._reader.static(task.patterns, options); + } +} +exports.default = ProviderAsync; - const state = regex.state; - delete regex.state; - let isIgnored = () => false; - if (opts.ignore) { - const ignoreOpts = { ...options, ignore: null, onMatch: null, onResult: null }; - isIgnored = picomatch(opts.ignore, ignoreOpts, returnState); - } +/***/ }), +/* 791 */ +/***/ (function(module, exports, __webpack_require__) { - const matcher = (input, returnObject = false) => { - const { isMatch, match, output } = picomatch.test(input, regex, options, { glob, posix }); - const result = { glob, state, regex, posix, input, output, match, isMatch }; +"use strict"; - if (typeof opts.onResult === 'function') { - opts.onResult(result); +Object.defineProperty(exports, "__esModule", { value: true }); +const stream_1 = __webpack_require__(173); +const fsStat = __webpack_require__(289); +const fsWalk = __webpack_require__(294); +const reader_1 = __webpack_require__(792); +class ReaderStream extends reader_1.default { + constructor() { + super(...arguments); + this._walkStream = fsWalk.walkStream; + this._stat = fsStat.stat; } - - if (isMatch === false) { - result.isMatch = false; - return returnObject ? result : false; + dynamic(root, options) { + return this._walkStream(root, options); } - - if (isIgnored(input)) { - if (typeof opts.onIgnore === 'function') { - opts.onIgnore(result); - } - result.isMatch = false; - return returnObject ? result : false; + static(patterns, options) { + const filepaths = patterns.map(this._getFullEntryPath, this); + const stream = new stream_1.PassThrough({ objectMode: true }); + stream._write = (index, _enc, done) => { + return this._getEntry(filepaths[index], patterns[index], options) + .then((entry) => { + if (entry !== null && options.entryFilter(entry)) { + stream.push(entry); + } + if (index === filepaths.length - 1) { + stream.end(); + } + done(); + }) + .catch(done); + }; + for (let i = 0; i < filepaths.length; i++) { + stream.write(i); + } + return stream; } - - if (typeof opts.onMatch === 'function') { - opts.onMatch(result); + _getEntry(filepath, pattern, options) { + return this._getStat(filepath) + .then((stats) => this._makeEntry(stats, pattern)) + .catch((error) => { + if (options.errorFilter(error)) { + return null; + } + throw error; + }); } - return returnObject ? result : true; - }; + _getStat(filepath) { + return new Promise((resolve, reject) => { + this._stat(filepath, this._fsStatSettings, (error, stats) => { + return error === null ? resolve(stats) : reject(error); + }); + }); + } +} +exports.default = ReaderStream; - if (returnState) { - matcher.state = state; - } - return matcher; -}; +/***/ }), +/* 792 */ +/***/ (function(module, exports, __webpack_require__) { -/** - * Test `input` with the given `regex`. This is used by the main - * `picomatch()` function to test the input string. - * - * ```js - * const picomatch = require('picomatch'); - * // picomatch.test(input, regex[, options]); - * - * console.log(picomatch.test('foo/bar', /^(?:([^/]*?)\/([^/]*?))$/)); - * // { isMatch: true, match: [ 'foo/', 'foo', 'bar' ], output: 'foo/bar' } - * ``` - * @param {String} `input` String to test. - * @param {RegExp} `regex` - * @return {Object} Returns an object with matching info. - * @api public - */ +"use strict"; -picomatch.test = (input, regex, options, { glob, posix } = {}) => { - if (typeof input !== 'string') { - throw new TypeError('Expected input to be a string'); - } +Object.defineProperty(exports, "__esModule", { value: true }); +const path = __webpack_require__(4); +const fsStat = __webpack_require__(289); +const utils = __webpack_require__(781); +class Reader { + constructor(_settings) { + this._settings = _settings; + this._fsStatSettings = new fsStat.Settings({ + followSymbolicLink: this._settings.followSymbolicLinks, + fs: this._settings.fs, + throwErrorOnBrokenSymbolicLink: this._settings.followSymbolicLinks + }); + } + _getFullEntryPath(filepath) { + return path.resolve(this._settings.cwd, filepath); + } + _makeEntry(stats, pattern) { + const entry = { + name: pattern, + path: pattern, + dirent: utils.fs.createDirentFromStats(pattern, stats) + }; + if (this._settings.stats) { + entry.stats = stats; + } + return entry; + } + _isFatalError(error) { + return !utils.errno.isEnoentCodeError(error) && !this._settings.suppressErrors; + } +} +exports.default = Reader; - if (input === '') { - return { isMatch: false, output: '' }; - } - const opts = options || {}; - const format = opts.format || (posix ? utils.toPosixSlashes : null); - let match = input === glob; - let output = (match && format) ? format(input) : input; +/***/ }), +/* 793 */ +/***/ (function(module, exports, __webpack_require__) { - if (match === false) { - output = format ? format(input) : input; - match = output === glob; - } - - if (match === false || opts.capture === true) { - if (opts.matchBase === true || opts.basename === true) { - match = picomatch.matchBase(input, regex, options, posix); - } else { - match = regex.exec(output); - } - } - - return { isMatch: Boolean(match), match, output }; -}; - -/** - * Match the basename of a filepath. - * - * ```js - * const picomatch = require('picomatch'); - * // picomatch.matchBase(input, glob[, options]); - * console.log(picomatch.matchBase('foo/bar.js', '*.js'); // true - * ``` - * @param {String} `input` String to test. - * @param {RegExp|String} `glob` Glob pattern or regex created by [.makeRe](#makeRe). - * @return {Boolean} - * @api public - */ - -picomatch.matchBase = (input, glob, options, posix = utils.isWindows(options)) => { - const regex = glob instanceof RegExp ? glob : picomatch.makeRe(glob, options); - return regex.test(path.basename(input)); -}; - -/** - * Returns true if **any** of the given glob `patterns` match the specified `string`. - * - * ```js - * const picomatch = require('picomatch'); - * // picomatch.isMatch(string, patterns[, options]); - * - * console.log(picomatch.isMatch('a.a', ['b.*', '*.a'])); //=> true - * console.log(picomatch.isMatch('a.a', 'b.*')); //=> false - * ``` - * @param {String|Array} str The string to test. - * @param {String|Array} patterns One or more glob patterns to use for matching. - * @param {Object} [options] See available [options](#options). - * @return {Boolean} Returns true if any patterns match `str` - * @api public - */ - -picomatch.isMatch = (str, patterns, options) => picomatch(patterns, options)(str); - -/** - * Parse a glob pattern to create the source string for a regular - * expression. - * - * ```js - * const picomatch = require('picomatch'); - * const result = picomatch.parse(pattern[, options]); - * ``` - * @param {String} `pattern` - * @param {Object} `options` - * @return {Object} Returns an object with useful properties and output to be used as a regex source string. - * @api public - */ - -picomatch.parse = (pattern, options) => { - if (Array.isArray(pattern)) return pattern.map(p => picomatch.parse(p, options)); - return parse(pattern, { ...options, fastpaths: false }); -}; - -/** - * Scan a glob pattern to separate the pattern into segments. - * - * ```js - * const picomatch = require('picomatch'); - * // picomatch.scan(input[, options]); - * - * const result = picomatch.scan('!./foo/*.js'); - * console.log(result); - * { prefix: '!./', - * input: '!./foo/*.js', - * start: 3, - * base: 'foo', - * glob: '*.js', - * isBrace: false, - * isBracket: false, - * isGlob: true, - * isExtglob: false, - * isGlobstar: false, - * negated: true } - * ``` - * @param {String} `input` Glob pattern to scan. - * @param {Object} `options` - * @return {Object} Returns an object with - * @api public - */ - -picomatch.scan = (input, options) => scan(input, options); - -/** - * Compile a regular expression from the `state` object returned by the - * [parse()](#parse) method. - * - * @param {Object} `state` - * @param {Object} `options` - * @param {Boolean} `returnOutput` Intended for implementors, this argument allows you to return the raw output from the parser. - * @param {Boolean} `returnState` Adds the state to a `state` property on the returned regex. Useful for implementors and debugging. - * @return {RegExp} - * @api public - */ - -picomatch.compileRe = (state, options, returnOutput = false, returnState = false) => { - if (returnOutput === true) { - return state.output; - } - - const opts = options || {}; - const prepend = opts.contains ? '' : '^'; - const append = opts.contains ? '' : '$'; - - let source = `${prepend}(?:${state.output})${append}`; - if (state && state.negated === true) { - source = `^(?!${source}).*$`; - } - - const regex = picomatch.toRegex(source, options); - if (returnState === true) { - regex.state = state; - } - - return regex; -}; - -/** - * Create a regular expression from a parsed glob pattern. - * - * ```js - * const picomatch = require('picomatch'); - * const state = picomatch.parse('*.js'); - * // picomatch.compileRe(state[, options]); - * - * console.log(picomatch.compileRe(state)); - * //=> /^(?:(?!\.)(?=.)[^/]*?\.js)$/ - * ``` - * @param {String} `state` The object returned from the `.parse` method. - * @param {Object} `options` - * @param {Boolean} `returnOutput` Implementors may use this argument to return the compiled output, instead of a regular expression. This is not exposed on the options to prevent end-users from mutating the result. - * @param {Boolean} `returnState` Implementors may use this argument to return the state from the parsed glob with the returned regular expression. - * @return {RegExp} Returns a regex created from the given pattern. - * @api public - */ - -picomatch.makeRe = (input, options = {}, returnOutput = false, returnState = false) => { - if (!input || typeof input !== 'string') { - throw new TypeError('Expected a non-empty string'); - } - - let parsed = { negated: false, fastpaths: true }; - - if (options.fastpaths !== false && (input[0] === '.' || input[0] === '*')) { - parsed.output = parse.fastpaths(input, options); - } - - if (!parsed.output) { - parsed = parse(input, options); - } - - return picomatch.compileRe(parsed, options, returnOutput, returnState); -}; - -/** - * Create a regular expression from the given regex source string. - * - * ```js - * const picomatch = require('picomatch'); - * // picomatch.toRegex(source[, options]); - * - * const { output } = picomatch.parse('*.js'); - * console.log(picomatch.toRegex(output)); - * //=> /^(?:(?!\.)(?=.)[^/]*?\.js)$/ - * ``` - * @param {String} `source` Regular expression source string. - * @param {Object} `options` - * @return {RegExp} - * @api public - */ - -picomatch.toRegex = (source, options) => { - try { - const opts = options || {}; - return new RegExp(source, opts.flags || (opts.nocase ? 'i' : '')); - } catch (err) { - if (options && options.debug === true) throw err; - return /$^/; - } -}; - -/** - * Picomatch constants. - * @return {Object} - */ - -picomatch.constants = constants; - -/** - * Expose "picomatch" - */ - -module.exports = picomatch; - - -/***/ }), -/* 790 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -const utils = __webpack_require__(791); -const { - CHAR_ASTERISK, /* * */ - CHAR_AT, /* @ */ - CHAR_BACKWARD_SLASH, /* \ */ - CHAR_COMMA, /* , */ - CHAR_DOT, /* . */ - CHAR_EXCLAMATION_MARK, /* ! */ - CHAR_FORWARD_SLASH, /* / */ - CHAR_LEFT_CURLY_BRACE, /* { */ - CHAR_LEFT_PARENTHESES, /* ( */ - CHAR_LEFT_SQUARE_BRACKET, /* [ */ - CHAR_PLUS, /* + */ - CHAR_QUESTION_MARK, /* ? */ - CHAR_RIGHT_CURLY_BRACE, /* } */ - CHAR_RIGHT_PARENTHESES, /* ) */ - CHAR_RIGHT_SQUARE_BRACKET /* ] */ -} = __webpack_require__(792); - -const isPathSeparator = code => { - return code === CHAR_FORWARD_SLASH || code === CHAR_BACKWARD_SLASH; -}; - -const depth = token => { - if (token.isPrefix !== true) { - token.depth = token.isGlobstar ? Infinity : 1; - } -}; - -/** - * Quickly scans a glob pattern and returns an object with a handful of - * useful properties, like `isGlob`, `path` (the leading non-glob, if it exists), - * `glob` (the actual pattern), `negated` (true if the path starts with `!` but not - * with `!(`) and `negatedExtglob` (true if the path starts with `!(`). - * - * ```js - * const pm = require('picomatch'); - * console.log(pm.scan('foo/bar/*.js')); - * { isGlob: true, input: 'foo/bar/*.js', base: 'foo/bar', glob: '*.js' } - * ``` - * @param {String} `str` - * @param {Object} `options` - * @return {Object} Returns an object with tokens and regex source string. - * @api public - */ - -const scan = (input, options) => { - const opts = options || {}; - - const length = input.length - 1; - const scanToEnd = opts.parts === true || opts.scanToEnd === true; - const slashes = []; - const tokens = []; - const parts = []; - - let str = input; - let index = -1; - let start = 0; - let lastIndex = 0; - let isBrace = false; - let isBracket = false; - let isGlob = false; - let isExtglob = false; - let isGlobstar = false; - let braceEscaped = false; - let backslashes = false; - let negated = false; - let negatedExtglob = false; - let finished = false; - let braces = 0; - let prev; - let code; - let token = { value: '', depth: 0, isGlob: false }; - - const eos = () => index >= length; - const peek = () => str.charCodeAt(index + 1); - const advance = () => { - prev = code; - return str.charCodeAt(++index); - }; - - while (index < length) { - code = advance(); - let next; - - if (code === CHAR_BACKWARD_SLASH) { - backslashes = token.backslashes = true; - code = advance(); - - if (code === CHAR_LEFT_CURLY_BRACE) { - braceEscaped = true; - } - continue; - } - - if (braceEscaped === true || code === CHAR_LEFT_CURLY_BRACE) { - braces++; - - while (eos() !== true && (code = advance())) { - if (code === CHAR_BACKWARD_SLASH) { - backslashes = token.backslashes = true; - advance(); - continue; - } - - if (code === CHAR_LEFT_CURLY_BRACE) { - braces++; - continue; - } - - if (braceEscaped !== true && code === CHAR_DOT && (code = advance()) === CHAR_DOT) { - isBrace = token.isBrace = true; - isGlob = token.isGlob = true; - finished = true; - - if (scanToEnd === true) { - continue; - } - - break; - } - - if (braceEscaped !== true && code === CHAR_COMMA) { - isBrace = token.isBrace = true; - isGlob = token.isGlob = true; - finished = true; - - if (scanToEnd === true) { - continue; - } - - break; - } - - if (code === CHAR_RIGHT_CURLY_BRACE) { - braces--; - - if (braces === 0) { - braceEscaped = false; - isBrace = token.isBrace = true; - finished = true; - break; - } - } - } - - if (scanToEnd === true) { - continue; - } - - break; - } - - if (code === CHAR_FORWARD_SLASH) { - slashes.push(index); - tokens.push(token); - token = { value: '', depth: 0, isGlob: false }; - - if (finished === true) continue; - if (prev === CHAR_DOT && index === (start + 1)) { - start += 2; - continue; - } - - lastIndex = index + 1; - continue; - } - - if (opts.noext !== true) { - const isExtglobChar = code === CHAR_PLUS - || code === CHAR_AT - || code === CHAR_ASTERISK - || code === CHAR_QUESTION_MARK - || code === CHAR_EXCLAMATION_MARK; - - if (isExtglobChar === true && peek() === CHAR_LEFT_PARENTHESES) { - isGlob = token.isGlob = true; - isExtglob = token.isExtglob = true; - finished = true; - if (code === CHAR_EXCLAMATION_MARK && index === start) { - negatedExtglob = true; - } - - if (scanToEnd === true) { - while (eos() !== true && (code = advance())) { - if (code === CHAR_BACKWARD_SLASH) { - backslashes = token.backslashes = true; - code = advance(); - continue; - } - - if (code === CHAR_RIGHT_PARENTHESES) { - isGlob = token.isGlob = true; - finished = true; - break; - } - } - continue; - } - break; - } - } - - if (code === CHAR_ASTERISK) { - if (prev === CHAR_ASTERISK) isGlobstar = token.isGlobstar = true; - isGlob = token.isGlob = true; - finished = true; - - if (scanToEnd === true) { - continue; - } - break; - } - - if (code === CHAR_QUESTION_MARK) { - isGlob = token.isGlob = true; - finished = true; - - if (scanToEnd === true) { - continue; - } - break; - } - - if (code === CHAR_LEFT_SQUARE_BRACKET) { - while (eos() !== true && (next = advance())) { - if (next === CHAR_BACKWARD_SLASH) { - backslashes = token.backslashes = true; - advance(); - continue; - } - - if (next === CHAR_RIGHT_SQUARE_BRACKET) { - isBracket = token.isBracket = true; - isGlob = token.isGlob = true; - finished = true; - break; - } - } - - if (scanToEnd === true) { - continue; - } - - break; - } - - if (opts.nonegate !== true && code === CHAR_EXCLAMATION_MARK && index === start) { - negated = token.negated = true; - start++; - continue; - } - - if (opts.noparen !== true && code === CHAR_LEFT_PARENTHESES) { - isGlob = token.isGlob = true; - - if (scanToEnd === true) { - while (eos() !== true && (code = advance())) { - if (code === CHAR_LEFT_PARENTHESES) { - backslashes = token.backslashes = true; - code = advance(); - continue; - } - - if (code === CHAR_RIGHT_PARENTHESES) { - finished = true; - break; - } - } - continue; - } - break; - } - - if (isGlob === true) { - finished = true; - - if (scanToEnd === true) { - continue; - } - - break; - } - } - - if (opts.noext === true) { - isExtglob = false; - isGlob = false; - } - - let base = str; - let prefix = ''; - let glob = ''; - - if (start > 0) { - prefix = str.slice(0, start); - str = str.slice(start); - lastIndex -= start; - } - - if (base && isGlob === true && lastIndex > 0) { - base = str.slice(0, lastIndex); - glob = str.slice(lastIndex); - } else if (isGlob === true) { - base = ''; - glob = str; - } else { - base = str; - } - - if (base && base !== '' && base !== '/' && base !== str) { - if (isPathSeparator(base.charCodeAt(base.length - 1))) { - base = base.slice(0, -1); - } - } - - if (opts.unescape === true) { - if (glob) glob = utils.removeBackslashes(glob); - - if (base && backslashes === true) { - base = utils.removeBackslashes(base); - } - } - - const state = { - prefix, - input, - start, - base, - glob, - isBrace, - isBracket, - isGlob, - isExtglob, - isGlobstar, - negated, - negatedExtglob - }; - - if (opts.tokens === true) { - state.maxDepth = 0; - if (!isPathSeparator(code)) { - tokens.push(token); - } - state.tokens = tokens; - } - - if (opts.parts === true || opts.tokens === true) { - let prevIndex; - - for (let idx = 0; idx < slashes.length; idx++) { - const n = prevIndex ? prevIndex + 1 : start; - const i = slashes[idx]; - const value = input.slice(n, i); - if (opts.tokens) { - if (idx === 0 && start !== 0) { - tokens[idx].isPrefix = true; - tokens[idx].value = prefix; - } else { - tokens[idx].value = value; - } - depth(tokens[idx]); - state.maxDepth += tokens[idx].depth; - } - if (idx !== 0 || value !== '') { - parts.push(value); - } - prevIndex = i; - } - - if (prevIndex && prevIndex + 1 < input.length) { - const value = input.slice(prevIndex + 1); - parts.push(value); - - if (opts.tokens) { - tokens[tokens.length - 1].value = value; - depth(tokens[tokens.length - 1]); - state.maxDepth += tokens[tokens.length - 1].depth; - } - } - - state.slashes = slashes; - state.parts = parts; - } - - return state; -}; - -module.exports = scan; - - -/***/ }), -/* 791 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -const path = __webpack_require__(4); -const win32 = process.platform === 'win32'; -const { - REGEX_BACKSLASH, - REGEX_REMOVE_BACKSLASH, - REGEX_SPECIAL_CHARS, - REGEX_SPECIAL_CHARS_GLOBAL -} = __webpack_require__(792); - -exports.isObject = val => val !== null && typeof val === 'object' && !Array.isArray(val); -exports.hasRegexChars = str => REGEX_SPECIAL_CHARS.test(str); -exports.isRegexChar = str => str.length === 1 && exports.hasRegexChars(str); -exports.escapeRegex = str => str.replace(REGEX_SPECIAL_CHARS_GLOBAL, '\\$1'); -exports.toPosixSlashes = str => str.replace(REGEX_BACKSLASH, '/'); - -exports.removeBackslashes = str => { - return str.replace(REGEX_REMOVE_BACKSLASH, match => { - return match === '\\' ? '' : match; - }); -}; - -exports.supportsLookbehinds = () => { - const segs = process.version.slice(1).split('.').map(Number); - if (segs.length === 3 && segs[0] >= 9 || (segs[0] === 8 && segs[1] >= 10)) { - return true; - } - return false; -}; - -exports.isWindows = options => { - if (options && typeof options.windows === 'boolean') { - return options.windows; - } - return win32 === true || path.sep === '\\'; -}; - -exports.escapeLast = (input, char, lastIdx) => { - const idx = input.lastIndexOf(char, lastIdx); - if (idx === -1) return input; - if (input[idx - 1] === '\\') return exports.escapeLast(input, char, idx - 1); - return `${input.slice(0, idx)}\\${input.slice(idx)}`; -}; - -exports.removePrefix = (input, state = {}) => { - let output = input; - if (output.startsWith('./')) { - output = output.slice(2); - state.prefix = './'; - } - return output; -}; - -exports.wrapOutput = (input, state = {}, options = {}) => { - const prepend = options.contains ? '' : '^'; - const append = options.contains ? '' : '$'; - - let output = `${prepend}(?:${input})${append}`; - if (state.negated === true) { - output = `(?:^(?!${output}).*$)`; - } - return output; -}; - - -/***/ }), -/* 792 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -const path = __webpack_require__(4); -const WIN_SLASH = '\\\\/'; -const WIN_NO_SLASH = `[^${WIN_SLASH}]`; - -/** - * Posix glob regex - */ - -const DOT_LITERAL = '\\.'; -const PLUS_LITERAL = '\\+'; -const QMARK_LITERAL = '\\?'; -const SLASH_LITERAL = '\\/'; -const ONE_CHAR = '(?=.)'; -const QMARK = '[^/]'; -const END_ANCHOR = `(?:${SLASH_LITERAL}|$)`; -const START_ANCHOR = `(?:^|${SLASH_LITERAL})`; -const DOTS_SLASH = `${DOT_LITERAL}{1,2}${END_ANCHOR}`; -const NO_DOT = `(?!${DOT_LITERAL})`; -const NO_DOTS = `(?!${START_ANCHOR}${DOTS_SLASH})`; -const NO_DOT_SLASH = `(?!${DOT_LITERAL}{0,1}${END_ANCHOR})`; -const NO_DOTS_SLASH = `(?!${DOTS_SLASH})`; -const QMARK_NO_DOT = `[^.${SLASH_LITERAL}]`; -const STAR = `${QMARK}*?`; - -const POSIX_CHARS = { - DOT_LITERAL, - PLUS_LITERAL, - QMARK_LITERAL, - SLASH_LITERAL, - ONE_CHAR, - QMARK, - END_ANCHOR, - DOTS_SLASH, - NO_DOT, - NO_DOTS, - NO_DOT_SLASH, - NO_DOTS_SLASH, - QMARK_NO_DOT, - STAR, - START_ANCHOR -}; - -/** - * Windows glob regex - */ - -const WINDOWS_CHARS = { - ...POSIX_CHARS, - - SLASH_LITERAL: `[${WIN_SLASH}]`, - QMARK: WIN_NO_SLASH, - STAR: `${WIN_NO_SLASH}*?`, - DOTS_SLASH: `${DOT_LITERAL}{1,2}(?:[${WIN_SLASH}]|$)`, - NO_DOT: `(?!${DOT_LITERAL})`, - NO_DOTS: `(?!(?:^|[${WIN_SLASH}])${DOT_LITERAL}{1,2}(?:[${WIN_SLASH}]|$))`, - NO_DOT_SLASH: `(?!${DOT_LITERAL}{0,1}(?:[${WIN_SLASH}]|$))`, - NO_DOTS_SLASH: `(?!${DOT_LITERAL}{1,2}(?:[${WIN_SLASH}]|$))`, - QMARK_NO_DOT: `[^.${WIN_SLASH}]`, - START_ANCHOR: `(?:^|[${WIN_SLASH}])`, - END_ANCHOR: `(?:[${WIN_SLASH}]|$)` -}; - -/** - * POSIX Bracket Regex - */ - -const POSIX_REGEX_SOURCE = { - alnum: 'a-zA-Z0-9', - alpha: 'a-zA-Z', - ascii: '\\x00-\\x7F', - blank: ' \\t', - cntrl: '\\x00-\\x1F\\x7F', - digit: '0-9', - graph: '\\x21-\\x7E', - lower: 'a-z', - print: '\\x20-\\x7E ', - punct: '\\-!"#$%&\'()\\*+,./:;<=>?@[\\]^_`{|}~', - space: ' \\t\\r\\n\\v\\f', - upper: 'A-Z', - word: 'A-Za-z0-9_', - xdigit: 'A-Fa-f0-9' -}; - -module.exports = { - MAX_LENGTH: 1024 * 64, - POSIX_REGEX_SOURCE, - - // regular expressions - REGEX_BACKSLASH: /\\(?![*+?^${}(|)[\]])/g, - REGEX_NON_SPECIAL_CHARS: /^[^@![\].,$*+?^{}()|\\/]+/, - REGEX_SPECIAL_CHARS: /[-*+?.^${}(|)[\]]/, - REGEX_SPECIAL_CHARS_BACKREF: /(\\?)((\W)(\3*))/g, - REGEX_SPECIAL_CHARS_GLOBAL: /([-*+?.^${}(|)[\]])/g, - REGEX_REMOVE_BACKSLASH: /(?:\[.*?[^\\]\]|\\(?=.))/g, - - // Replace globs with equivalent patterns to reduce parsing time. - REPLACEMENTS: { - '***': '*', - '**/**': '**', - '**/**/**': '**' - }, - - // Digits - CHAR_0: 48, /* 0 */ - CHAR_9: 57, /* 9 */ - - // Alphabet chars. - CHAR_UPPERCASE_A: 65, /* A */ - CHAR_LOWERCASE_A: 97, /* a */ - CHAR_UPPERCASE_Z: 90, /* Z */ - CHAR_LOWERCASE_Z: 122, /* z */ - - CHAR_LEFT_PARENTHESES: 40, /* ( */ - CHAR_RIGHT_PARENTHESES: 41, /* ) */ - - CHAR_ASTERISK: 42, /* * */ - - // Non-alphabetic chars. - CHAR_AMPERSAND: 38, /* & */ - CHAR_AT: 64, /* @ */ - CHAR_BACKWARD_SLASH: 92, /* \ */ - CHAR_CARRIAGE_RETURN: 13, /* \r */ - CHAR_CIRCUMFLEX_ACCENT: 94, /* ^ */ - CHAR_COLON: 58, /* : */ - CHAR_COMMA: 44, /* , */ - CHAR_DOT: 46, /* . */ - CHAR_DOUBLE_QUOTE: 34, /* " */ - CHAR_EQUAL: 61, /* = */ - CHAR_EXCLAMATION_MARK: 33, /* ! */ - CHAR_FORM_FEED: 12, /* \f */ - CHAR_FORWARD_SLASH: 47, /* / */ - CHAR_GRAVE_ACCENT: 96, /* ` */ - CHAR_HASH: 35, /* # */ - CHAR_HYPHEN_MINUS: 45, /* - */ - CHAR_LEFT_ANGLE_BRACKET: 60, /* < */ - CHAR_LEFT_CURLY_BRACE: 123, /* { */ - CHAR_LEFT_SQUARE_BRACKET: 91, /* [ */ - CHAR_LINE_FEED: 10, /* \n */ - CHAR_NO_BREAK_SPACE: 160, /* \u00A0 */ - CHAR_PERCENT: 37, /* % */ - CHAR_PLUS: 43, /* + */ - CHAR_QUESTION_MARK: 63, /* ? */ - CHAR_RIGHT_ANGLE_BRACKET: 62, /* > */ - CHAR_RIGHT_CURLY_BRACE: 125, /* } */ - CHAR_RIGHT_SQUARE_BRACKET: 93, /* ] */ - CHAR_SEMICOLON: 59, /* ; */ - CHAR_SINGLE_QUOTE: 39, /* ' */ - CHAR_SPACE: 32, /* */ - CHAR_TAB: 9, /* \t */ - CHAR_UNDERSCORE: 95, /* _ */ - CHAR_VERTICAL_LINE: 124, /* | */ - CHAR_ZERO_WIDTH_NOBREAK_SPACE: 65279, /* \uFEFF */ - - SEP: path.sep, - - /** - * Create EXTGLOB_CHARS - */ - - extglobChars(chars) { - return { - '!': { type: 'negate', open: '(?:(?!(?:', close: `))${chars.STAR})` }, - '?': { type: 'qmark', open: '(?:', close: ')?' }, - '+': { type: 'plus', open: '(?:', close: ')+' }, - '*': { type: 'star', open: '(?:', close: ')*' }, - '@': { type: 'at', open: '(?:', close: ')' } - }; - }, - - /** - * Create GLOB_CHARS - */ - - globChars(win32) { - return win32 === true ? WINDOWS_CHARS : POSIX_CHARS; - } -}; - - -/***/ }), -/* 793 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -const constants = __webpack_require__(792); -const utils = __webpack_require__(791); - -/** - * Constants - */ - -const { - MAX_LENGTH, - POSIX_REGEX_SOURCE, - REGEX_NON_SPECIAL_CHARS, - REGEX_SPECIAL_CHARS_BACKREF, - REPLACEMENTS -} = constants; - -/** - * Helpers - */ - -const expandRange = (args, options) => { - if (typeof options.expandRange === 'function') { - return options.expandRange(...args, options); - } - - args.sort(); - const value = `[${args.join('-')}]`; - - try { - /* eslint-disable-next-line no-new */ - new RegExp(value); - } catch (ex) { - return args.map(v => utils.escapeRegex(v)).join('..'); - } - - return value; -}; - -/** - * Create the message for a syntax error - */ - -const syntaxError = (type, char) => { - return `Missing ${type}: "${char}" - use "\\\\${char}" to match literal characters`; -}; - -/** - * Parse the given input string. - * @param {String} input - * @param {Object} options - * @return {Object} - */ - -const parse = (input, options) => { - if (typeof input !== 'string') { - throw new TypeError('Expected a string'); - } - - input = REPLACEMENTS[input] || input; - - const opts = { ...options }; - const max = typeof opts.maxLength === 'number' ? Math.min(MAX_LENGTH, opts.maxLength) : MAX_LENGTH; - - let len = input.length; - if (len > max) { - throw new SyntaxError(`Input length: ${len}, exceeds maximum allowed length: ${max}`); - } - - const bos = { type: 'bos', value: '', output: opts.prepend || '' }; - const tokens = [bos]; - - const capture = opts.capture ? '' : '?:'; - const win32 = utils.isWindows(options); - - // create constants based on platform, for windows or posix - const PLATFORM_CHARS = constants.globChars(win32); - const EXTGLOB_CHARS = constants.extglobChars(PLATFORM_CHARS); - - const { - DOT_LITERAL, - PLUS_LITERAL, - SLASH_LITERAL, - ONE_CHAR, - DOTS_SLASH, - NO_DOT, - NO_DOT_SLASH, - NO_DOTS_SLASH, - QMARK, - QMARK_NO_DOT, - STAR, - START_ANCHOR - } = PLATFORM_CHARS; - - const globstar = opts => { - return `(${capture}(?:(?!${START_ANCHOR}${opts.dot ? DOTS_SLASH : DOT_LITERAL}).)*?)`; - }; - - const nodot = opts.dot ? '' : NO_DOT; - const qmarkNoDot = opts.dot ? QMARK : QMARK_NO_DOT; - let star = opts.bash === true ? globstar(opts) : STAR; - - if (opts.capture) { - star = `(${star})`; - } - - // minimatch options support - if (typeof opts.noext === 'boolean') { - opts.noextglob = opts.noext; - } - - const state = { - input, - index: -1, - start: 0, - dot: opts.dot === true, - consumed: '', - output: '', - prefix: '', - backtrack: false, - negated: false, - brackets: 0, - braces: 0, - parens: 0, - quotes: 0, - globstar: false, - tokens - }; - - input = utils.removePrefix(input, state); - len = input.length; - - const extglobs = []; - const braces = []; - const stack = []; - let prev = bos; - let value; - - /** - * Tokenizing helpers - */ - - const eos = () => state.index === len - 1; - const peek = state.peek = (n = 1) => input[state.index + n]; - const advance = state.advance = () => input[++state.index] || ''; - const remaining = () => input.slice(state.index + 1); - const consume = (value = '', num = 0) => { - state.consumed += value; - state.index += num; - }; - - const append = token => { - state.output += token.output != null ? token.output : token.value; - consume(token.value); - }; - - const negate = () => { - let count = 1; - - while (peek() === '!' && (peek(2) !== '(' || peek(3) === '?')) { - advance(); - state.start++; - count++; - } - - if (count % 2 === 0) { - return false; - } - - state.negated = true; - state.start++; - return true; - }; - - const increment = type => { - state[type]++; - stack.push(type); - }; - - const decrement = type => { - state[type]--; - stack.pop(); - }; - - /** - * Push tokens onto the tokens array. This helper speeds up - * tokenizing by 1) helping us avoid backtracking as much as possible, - * and 2) helping us avoid creating extra tokens when consecutive - * characters are plain text. This improves performance and simplifies - * lookbehinds. - */ - - const push = tok => { - if (prev.type === 'globstar') { - const isBrace = state.braces > 0 && (tok.type === 'comma' || tok.type === 'brace'); - const isExtglob = tok.extglob === true || (extglobs.length && (tok.type === 'pipe' || tok.type === 'paren')); - - if (tok.type !== 'slash' && tok.type !== 'paren' && !isBrace && !isExtglob) { - state.output = state.output.slice(0, -prev.output.length); - prev.type = 'star'; - prev.value = '*'; - prev.output = star; - state.output += prev.output; - } - } - - if (extglobs.length && tok.type !== 'paren') { - extglobs[extglobs.length - 1].inner += tok.value; - } - - if (tok.value || tok.output) append(tok); - if (prev && prev.type === 'text' && tok.type === 'text') { - prev.value += tok.value; - prev.output = (prev.output || '') + tok.value; - return; - } - - tok.prev = prev; - tokens.push(tok); - prev = tok; - }; - - const extglobOpen = (type, value) => { - const token = { ...EXTGLOB_CHARS[value], conditions: 1, inner: '' }; - - token.prev = prev; - token.parens = state.parens; - token.output = state.output; - const output = (opts.capture ? '(' : '') + token.open; - - increment('parens'); - push({ type, value, output: state.output ? '' : ONE_CHAR }); - push({ type: 'paren', extglob: true, value: advance(), output }); - extglobs.push(token); - }; - - const extglobClose = token => { - let output = token.close + (opts.capture ? ')' : ''); - let rest; - - if (token.type === 'negate') { - let extglobStar = star; - - if (token.inner && token.inner.length > 1 && token.inner.includes('/')) { - extglobStar = globstar(opts); - } - - if (extglobStar !== star || eos() || /^\)+$/.test(remaining())) { - output = token.close = `)$))${extglobStar}`; - } - - if (token.inner.includes('*') && (rest = remaining()) && /^\.[^\\/.]+$/.test(rest)) { - output = token.close = `)${rest})${extglobStar})`; - } - - if (token.prev.type === 'bos') { - state.negatedExtglob = true; - } - } - - push({ type: 'paren', extglob: true, value, output }); - decrement('parens'); - }; - - /** - * Fast paths - */ - - if (opts.fastpaths !== false && !/(^[*!]|[/()[\]{}"])/.test(input)) { - let backslashes = false; - - let output = input.replace(REGEX_SPECIAL_CHARS_BACKREF, (m, esc, chars, first, rest, index) => { - if (first === '\\') { - backslashes = true; - return m; - } - - if (first === '?') { - if (esc) { - return esc + first + (rest ? QMARK.repeat(rest.length) : ''); - } - if (index === 0) { - return qmarkNoDot + (rest ? QMARK.repeat(rest.length) : ''); - } - return QMARK.repeat(chars.length); - } - - if (first === '.') { - return DOT_LITERAL.repeat(chars.length); - } - - if (first === '*') { - if (esc) { - return esc + first + (rest ? star : ''); - } - return star; - } - return esc ? m : `\\${m}`; - }); - - if (backslashes === true) { - if (opts.unescape === true) { - output = output.replace(/\\/g, ''); - } else { - output = output.replace(/\\+/g, m => { - return m.length % 2 === 0 ? '\\\\' : (m ? '\\' : ''); - }); - } - } - - if (output === input && opts.contains === true) { - state.output = input; - return state; - } - - state.output = utils.wrapOutput(output, state, options); - return state; - } - - /** - * Tokenize input until we reach end-of-string - */ - - while (!eos()) { - value = advance(); - - if (value === '\u0000') { - continue; - } - - /** - * Escaped characters - */ - - if (value === '\\') { - const next = peek(); - - if (next === '/' && opts.bash !== true) { - continue; - } - - if (next === '.' || next === ';') { - continue; - } - - if (!next) { - value += '\\'; - push({ type: 'text', value }); - continue; - } - - // collapse slashes to reduce potential for exploits - const match = /^\\+/.exec(remaining()); - let slashes = 0; - - if (match && match[0].length > 2) { - slashes = match[0].length; - state.index += slashes; - if (slashes % 2 !== 0) { - value += '\\'; - } - } - - if (opts.unescape === true) { - value = advance(); - } else { - value += advance(); - } - - if (state.brackets === 0) { - push({ type: 'text', value }); - continue; - } - } - - /** - * If we're inside a regex character class, continue - * until we reach the closing bracket. - */ - - if (state.brackets > 0 && (value !== ']' || prev.value === '[' || prev.value === '[^')) { - if (opts.posix !== false && value === ':') { - const inner = prev.value.slice(1); - if (inner.includes('[')) { - prev.posix = true; - - if (inner.includes(':')) { - const idx = prev.value.lastIndexOf('['); - const pre = prev.value.slice(0, idx); - const rest = prev.value.slice(idx + 2); - const posix = POSIX_REGEX_SOURCE[rest]; - if (posix) { - prev.value = pre + posix; - state.backtrack = true; - advance(); - - if (!bos.output && tokens.indexOf(prev) === 1) { - bos.output = ONE_CHAR; - } - continue; - } - } - } - } - - if ((value === '[' && peek() !== ':') || (value === '-' && peek() === ']')) { - value = `\\${value}`; - } - - if (value === ']' && (prev.value === '[' || prev.value === '[^')) { - value = `\\${value}`; - } - - if (opts.posix === true && value === '!' && prev.value === '[') { - value = '^'; - } - - prev.value += value; - append({ value }); - continue; - } - - /** - * If we're inside a quoted string, continue - * until we reach the closing double quote. - */ - - if (state.quotes === 1 && value !== '"') { - value = utils.escapeRegex(value); - prev.value += value; - append({ value }); - continue; - } - - /** - * Double quotes - */ - - if (value === '"') { - state.quotes = state.quotes === 1 ? 0 : 1; - if (opts.keepQuotes === true) { - push({ type: 'text', value }); - } - continue; - } - - /** - * Parentheses - */ - - if (value === '(') { - increment('parens'); - push({ type: 'paren', value }); - continue; - } - - if (value === ')') { - if (state.parens === 0 && opts.strictBrackets === true) { - throw new SyntaxError(syntaxError('opening', '(')); - } - - const extglob = extglobs[extglobs.length - 1]; - if (extglob && state.parens === extglob.parens + 1) { - extglobClose(extglobs.pop()); - continue; - } - - push({ type: 'paren', value, output: state.parens ? ')' : '\\)' }); - decrement('parens'); - continue; - } - - /** - * Square brackets - */ - - if (value === '[') { - if (opts.nobracket === true || !remaining().includes(']')) { - if (opts.nobracket !== true && opts.strictBrackets === true) { - throw new SyntaxError(syntaxError('closing', ']')); - } - - value = `\\${value}`; - } else { - increment('brackets'); - } - - push({ type: 'bracket', value }); - continue; - } - - if (value === ']') { - if (opts.nobracket === true || (prev && prev.type === 'bracket' && prev.value.length === 1)) { - push({ type: 'text', value, output: `\\${value}` }); - continue; - } - - if (state.brackets === 0) { - if (opts.strictBrackets === true) { - throw new SyntaxError(syntaxError('opening', '[')); - } - - push({ type: 'text', value, output: `\\${value}` }); - continue; - } - - decrement('brackets'); - - const prevValue = prev.value.slice(1); - if (prev.posix !== true && prevValue[0] === '^' && !prevValue.includes('/')) { - value = `/${value}`; - } - - prev.value += value; - append({ value }); - - // when literal brackets are explicitly disabled - // assume we should match with a regex character class - if (opts.literalBrackets === false || utils.hasRegexChars(prevValue)) { - continue; - } - - const escaped = utils.escapeRegex(prev.value); - state.output = state.output.slice(0, -prev.value.length); - - // when literal brackets are explicitly enabled - // assume we should escape the brackets to match literal characters - if (opts.literalBrackets === true) { - state.output += escaped; - prev.value = escaped; - continue; - } - - // when the user specifies nothing, try to match both - prev.value = `(${capture}${escaped}|${prev.value})`; - state.output += prev.value; - continue; - } - - /** - * Braces - */ - - if (value === '{' && opts.nobrace !== true) { - increment('braces'); - - const open = { - type: 'brace', - value, - output: '(', - outputIndex: state.output.length, - tokensIndex: state.tokens.length - }; - - braces.push(open); - push(open); - continue; - } - - if (value === '}') { - const brace = braces[braces.length - 1]; - - if (opts.nobrace === true || !brace) { - push({ type: 'text', value, output: value }); - continue; - } - - let output = ')'; - - if (brace.dots === true) { - const arr = tokens.slice(); - const range = []; - - for (let i = arr.length - 1; i >= 0; i--) { - tokens.pop(); - if (arr[i].type === 'brace') { - break; - } - if (arr[i].type !== 'dots') { - range.unshift(arr[i].value); - } - } - - output = expandRange(range, opts); - state.backtrack = true; - } - - if (brace.comma !== true && brace.dots !== true) { - const out = state.output.slice(0, brace.outputIndex); - const toks = state.tokens.slice(brace.tokensIndex); - brace.value = brace.output = '\\{'; - value = output = '\\}'; - state.output = out; - for (const t of toks) { - state.output += (t.output || t.value); - } - } - - push({ type: 'brace', value, output }); - decrement('braces'); - braces.pop(); - continue; - } - - /** - * Pipes - */ - - if (value === '|') { - if (extglobs.length > 0) { - extglobs[extglobs.length - 1].conditions++; - } - push({ type: 'text', value }); - continue; - } - - /** - * Commas - */ - - if (value === ',') { - let output = value; - - const brace = braces[braces.length - 1]; - if (brace && stack[stack.length - 1] === 'braces') { - brace.comma = true; - output = '|'; - } - - push({ type: 'comma', value, output }); - continue; - } - - /** - * Slashes - */ - - if (value === '/') { - // if the beginning of the glob is "./", advance the start - // to the current index, and don't add the "./" characters - // to the state. This greatly simplifies lookbehinds when - // checking for BOS characters like "!" and "." (not "./") - if (prev.type === 'dot' && state.index === state.start + 1) { - state.start = state.index + 1; - state.consumed = ''; - state.output = ''; - tokens.pop(); - prev = bos; // reset "prev" to the first token - continue; - } - - push({ type: 'slash', value, output: SLASH_LITERAL }); - continue; - } - - /** - * Dots - */ - - if (value === '.') { - if (state.braces > 0 && prev.type === 'dot') { - if (prev.value === '.') prev.output = DOT_LITERAL; - const brace = braces[braces.length - 1]; - prev.type = 'dots'; - prev.output += value; - prev.value += value; - brace.dots = true; - continue; - } - - if ((state.braces + state.parens) === 0 && prev.type !== 'bos' && prev.type !== 'slash') { - push({ type: 'text', value, output: DOT_LITERAL }); - continue; - } - - push({ type: 'dot', value, output: DOT_LITERAL }); - continue; - } - - /** - * Question marks - */ - - if (value === '?') { - const isGroup = prev && prev.value === '('; - if (!isGroup && opts.noextglob !== true && peek() === '(' && peek(2) !== '?') { - extglobOpen('qmark', value); - continue; - } - - if (prev && prev.type === 'paren') { - const next = peek(); - let output = value; - - if (next === '<' && !utils.supportsLookbehinds()) { - throw new Error('Node.js v10 or higher is required for regex lookbehinds'); - } - - if ((prev.value === '(' && !/[!=<:]/.test(next)) || (next === '<' && !/<([!=]|\w+>)/.test(remaining()))) { - output = `\\${value}`; - } - - push({ type: 'text', value, output }); - continue; - } - - if (opts.dot !== true && (prev.type === 'slash' || prev.type === 'bos')) { - push({ type: 'qmark', value, output: QMARK_NO_DOT }); - continue; - } - - push({ type: 'qmark', value, output: QMARK }); - continue; - } - - /** - * Exclamation - */ - - if (value === '!') { - if (opts.noextglob !== true && peek() === '(') { - if (peek(2) !== '?' || !/[!=<:]/.test(peek(3))) { - extglobOpen('negate', value); - continue; - } - } - - if (opts.nonegate !== true && state.index === 0) { - negate(); - continue; - } - } - - /** - * Plus - */ - - if (value === '+') { - if (opts.noextglob !== true && peek() === '(' && peek(2) !== '?') { - extglobOpen('plus', value); - continue; - } - - if ((prev && prev.value === '(') || opts.regex === false) { - push({ type: 'plus', value, output: PLUS_LITERAL }); - continue; - } - - if ((prev && (prev.type === 'bracket' || prev.type === 'paren' || prev.type === 'brace')) || state.parens > 0) { - push({ type: 'plus', value }); - continue; - } - - push({ type: 'plus', value: PLUS_LITERAL }); - continue; - } - - /** - * Plain text - */ - - if (value === '@') { - if (opts.noextglob !== true && peek() === '(' && peek(2) !== '?') { - push({ type: 'at', extglob: true, value, output: '' }); - continue; - } - - push({ type: 'text', value }); - continue; - } - - /** - * Plain text - */ - - if (value !== '*') { - if (value === '$' || value === '^') { - value = `\\${value}`; - } - - const match = REGEX_NON_SPECIAL_CHARS.exec(remaining()); - if (match) { - value += match[0]; - state.index += match[0].length; - } - - push({ type: 'text', value }); - continue; - } - - /** - * Stars - */ - - if (prev && (prev.type === 'globstar' || prev.star === true)) { - prev.type = 'star'; - prev.star = true; - prev.value += value; - prev.output = star; - state.backtrack = true; - state.globstar = true; - consume(value); - continue; - } - - let rest = remaining(); - if (opts.noextglob !== true && /^\([^?]/.test(rest)) { - extglobOpen('star', value); - continue; - } - - if (prev.type === 'star') { - if (opts.noglobstar === true) { - consume(value); - continue; - } - - const prior = prev.prev; - const before = prior.prev; - const isStart = prior.type === 'slash' || prior.type === 'bos'; - const afterStar = before && (before.type === 'star' || before.type === 'globstar'); - - if (opts.bash === true && (!isStart || (rest[0] && rest[0] !== '/'))) { - push({ type: 'star', value, output: '' }); - continue; - } - - const isBrace = state.braces > 0 && (prior.type === 'comma' || prior.type === 'brace'); - const isExtglob = extglobs.length && (prior.type === 'pipe' || prior.type === 'paren'); - if (!isStart && prior.type !== 'paren' && !isBrace && !isExtglob) { - push({ type: 'star', value, output: '' }); - continue; - } - - // strip consecutive `/**/` - while (rest.slice(0, 3) === '/**') { - const after = input[state.index + 4]; - if (after && after !== '/') { - break; - } - rest = rest.slice(3); - consume('/**', 3); - } - - if (prior.type === 'bos' && eos()) { - prev.type = 'globstar'; - prev.value += value; - prev.output = globstar(opts); - state.output = prev.output; - state.globstar = true; - consume(value); - continue; - } - - if (prior.type === 'slash' && prior.prev.type !== 'bos' && !afterStar && eos()) { - state.output = state.output.slice(0, -(prior.output + prev.output).length); - prior.output = `(?:${prior.output}`; - - prev.type = 'globstar'; - prev.output = globstar(opts) + (opts.strictSlashes ? ')' : '|$)'); - prev.value += value; - state.globstar = true; - state.output += prior.output + prev.output; - consume(value); - continue; - } - - if (prior.type === 'slash' && prior.prev.type !== 'bos' && rest[0] === '/') { - const end = rest[1] !== void 0 ? '|$' : ''; - - state.output = state.output.slice(0, -(prior.output + prev.output).length); - prior.output = `(?:${prior.output}`; - - prev.type = 'globstar'; - prev.output = `${globstar(opts)}${SLASH_LITERAL}|${SLASH_LITERAL}${end})`; - prev.value += value; - - state.output += prior.output + prev.output; - state.globstar = true; - - consume(value + advance()); - - push({ type: 'slash', value: '/', output: '' }); - continue; - } - - if (prior.type === 'bos' && rest[0] === '/') { - prev.type = 'globstar'; - prev.value += value; - prev.output = `(?:^|${SLASH_LITERAL}|${globstar(opts)}${SLASH_LITERAL})`; - state.output = prev.output; - state.globstar = true; - consume(value + advance()); - push({ type: 'slash', value: '/', output: '' }); - continue; - } - - // remove single star from output - state.output = state.output.slice(0, -prev.output.length); - - // reset previous token to globstar - prev.type = 'globstar'; - prev.output = globstar(opts); - prev.value += value; - - // reset output with globstar - state.output += prev.output; - state.globstar = true; - consume(value); - continue; - } - - const token = { type: 'star', value, output: star }; - - if (opts.bash === true) { - token.output = '.*?'; - if (prev.type === 'bos' || prev.type === 'slash') { - token.output = nodot + token.output; - } - push(token); - continue; - } - - if (prev && (prev.type === 'bracket' || prev.type === 'paren') && opts.regex === true) { - token.output = value; - push(token); - continue; - } - - if (state.index === state.start || prev.type === 'slash' || prev.type === 'dot') { - if (prev.type === 'dot') { - state.output += NO_DOT_SLASH; - prev.output += NO_DOT_SLASH; - - } else if (opts.dot === true) { - state.output += NO_DOTS_SLASH; - prev.output += NO_DOTS_SLASH; - - } else { - state.output += nodot; - prev.output += nodot; - } - - if (peek() !== '*') { - state.output += ONE_CHAR; - prev.output += ONE_CHAR; - } - } - - push(token); - } - - while (state.brackets > 0) { - if (opts.strictBrackets === true) throw new SyntaxError(syntaxError('closing', ']')); - state.output = utils.escapeLast(state.output, '['); - decrement('brackets'); - } - - while (state.parens > 0) { - if (opts.strictBrackets === true) throw new SyntaxError(syntaxError('closing', ')')); - state.output = utils.escapeLast(state.output, '('); - decrement('parens'); - } - - while (state.braces > 0) { - if (opts.strictBrackets === true) throw new SyntaxError(syntaxError('closing', '}')); - state.output = utils.escapeLast(state.output, '{'); - decrement('braces'); - } - - if (opts.strictSlashes !== true && (prev.type === 'star' || prev.type === 'bracket')) { - push({ type: 'maybe_slash', value: '', output: `${SLASH_LITERAL}?` }); - } - - // rebuild the output if we had to backtrack at any point - if (state.backtrack === true) { - state.output = ''; - - for (const token of state.tokens) { - state.output += token.output != null ? token.output : token.value; - - if (token.suffix) { - state.output += token.suffix; - } - } - } - - return state; -}; - -/** - * Fast paths for creating regular expressions for common glob patterns. - * This can significantly speed up processing and has very little downside - * impact when none of the fast paths match. - */ - -parse.fastpaths = (input, options) => { - const opts = { ...options }; - const max = typeof opts.maxLength === 'number' ? Math.min(MAX_LENGTH, opts.maxLength) : MAX_LENGTH; - const len = input.length; - if (len > max) { - throw new SyntaxError(`Input length: ${len}, exceeds maximum allowed length: ${max}`); - } - - input = REPLACEMENTS[input] || input; - const win32 = utils.isWindows(options); - - // create constants based on platform, for windows or posix - const { - DOT_LITERAL, - SLASH_LITERAL, - ONE_CHAR, - DOTS_SLASH, - NO_DOT, - NO_DOTS, - NO_DOTS_SLASH, - STAR, - START_ANCHOR - } = constants.globChars(win32); - - const nodot = opts.dot ? NO_DOTS : NO_DOT; - const slashDot = opts.dot ? NO_DOTS_SLASH : NO_DOT; - const capture = opts.capture ? '' : '?:'; - const state = { negated: false, prefix: '' }; - let star = opts.bash === true ? '.*?' : STAR; - - if (opts.capture) { - star = `(${star})`; - } - - const globstar = opts => { - if (opts.noglobstar === true) return star; - return `(${capture}(?:(?!${START_ANCHOR}${opts.dot ? DOTS_SLASH : DOT_LITERAL}).)*?)`; - }; - - const create = str => { - switch (str) { - case '*': - return `${nodot}${ONE_CHAR}${star}`; - - case '.*': - return `${DOT_LITERAL}${ONE_CHAR}${star}`; - - case '*.*': - return `${nodot}${star}${DOT_LITERAL}${ONE_CHAR}${star}`; - - case '*/*': - return `${nodot}${star}${SLASH_LITERAL}${ONE_CHAR}${slashDot}${star}`; - - case '**': - return nodot + globstar(opts); - - case '**/*': - return `(?:${nodot}${globstar(opts)}${SLASH_LITERAL})?${slashDot}${ONE_CHAR}${star}`; - - case '**/*.*': - return `(?:${nodot}${globstar(opts)}${SLASH_LITERAL})?${slashDot}${star}${DOT_LITERAL}${ONE_CHAR}${star}`; - - case '**/.*': - return `(?:${nodot}${globstar(opts)}${SLASH_LITERAL})?${DOT_LITERAL}${ONE_CHAR}${star}`; - - default: { - const match = /^(.*?)\.(\w+)$/.exec(str); - if (!match) return; - - const source = create(match[1]); - if (!source) return; - - return source + DOT_LITERAL + match[2]; - } - } - }; - - const output = utils.removePrefix(input, state); - let source = create(output); - - if (source && opts.strictSlashes !== true) { - source += `${SLASH_LITERAL}?`; - } - - return source; -}; - -module.exports = parse; - - -/***/ }), -/* 794 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - -Object.defineProperty(exports, "__esModule", { value: true }); -exports.merge = void 0; -const merge2 = __webpack_require__(243); -function merge(streams) { - const mergedStream = merge2(streams); - streams.forEach((stream) => { - stream.once('error', (error) => mergedStream.emit('error', error)); - }); - mergedStream.once('close', () => propagateCloseEventToSources(streams)); - mergedStream.once('end', () => propagateCloseEventToSources(streams)); - return mergedStream; -} -exports.merge = merge; -function propagateCloseEventToSources(streams) { - streams.forEach((stream) => stream.emit('close')); -} - - -/***/ }), -/* 795 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - -Object.defineProperty(exports, "__esModule", { value: true }); -exports.isEmpty = exports.isString = void 0; -function isString(input) { - return typeof input === 'string'; -} -exports.isString = isString; -function isEmpty(input) { - return input === ''; -} -exports.isEmpty = isEmpty; - - -/***/ }), -/* 796 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - -Object.defineProperty(exports, "__esModule", { value: true }); -const stream_1 = __webpack_require__(797); -const provider_1 = __webpack_require__(799); -class ProviderAsync extends provider_1.default { - constructor() { - super(...arguments); - this._reader = new stream_1.default(this._settings); - } - read(task) { - const root = this._getRootDirectory(task); - const options = this._getReaderOptions(task); - const entries = []; - return new Promise((resolve, reject) => { - const stream = this.api(root, task, options); - stream.once('error', reject); - stream.on('data', (entry) => entries.push(options.transform(entry))); - stream.once('end', () => resolve(entries)); - }); - } - api(root, task, options) { - if (task.dynamic) { - return this._reader.dynamic(root, options); - } - return this._reader.static(task.patterns, options); - } -} -exports.default = ProviderAsync; - - -/***/ }), -/* 797 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - -Object.defineProperty(exports, "__esModule", { value: true }); -const stream_1 = __webpack_require__(173); -const fsStat = __webpack_require__(289); -const fsWalk = __webpack_require__(294); -const reader_1 = __webpack_require__(798); -class ReaderStream extends reader_1.default { - constructor() { - super(...arguments); - this._walkStream = fsWalk.walkStream; - this._stat = fsStat.stat; - } - dynamic(root, options) { - return this._walkStream(root, options); - } - static(patterns, options) { - const filepaths = patterns.map(this._getFullEntryPath, this); - const stream = new stream_1.PassThrough({ objectMode: true }); - stream._write = (index, _enc, done) => { - return this._getEntry(filepaths[index], patterns[index], options) - .then((entry) => { - if (entry !== null && options.entryFilter(entry)) { - stream.push(entry); - } - if (index === filepaths.length - 1) { - stream.end(); - } - done(); - }) - .catch(done); - }; - for (let i = 0; i < filepaths.length; i++) { - stream.write(i); - } - return stream; - } - _getEntry(filepath, pattern, options) { - return this._getStat(filepath) - .then((stats) => this._makeEntry(stats, pattern)) - .catch((error) => { - if (options.errorFilter(error)) { - return null; - } - throw error; - }); - } - _getStat(filepath) { - return new Promise((resolve, reject) => { - this._stat(filepath, this._fsStatSettings, (error, stats) => { - return error === null ? resolve(stats) : reject(error); - }); - }); - } -} -exports.default = ReaderStream; - - -/***/ }), -/* 798 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - -Object.defineProperty(exports, "__esModule", { value: true }); -const path = __webpack_require__(4); -const fsStat = __webpack_require__(289); -const utils = __webpack_require__(781); -class Reader { - constructor(_settings) { - this._settings = _settings; - this._fsStatSettings = new fsStat.Settings({ - followSymbolicLink: this._settings.followSymbolicLinks, - fs: this._settings.fs, - throwErrorOnBrokenSymbolicLink: this._settings.followSymbolicLinks - }); - } - _getFullEntryPath(filepath) { - return path.resolve(this._settings.cwd, filepath); - } - _makeEntry(stats, pattern) { - const entry = { - name: pattern, - path: pattern, - dirent: utils.fs.createDirentFromStats(pattern, stats) - }; - if (this._settings.stats) { - entry.stats = stats; - } - return entry; - } - _isFatalError(error) { - return !utils.errno.isEnoentCodeError(error) && !this._settings.suppressErrors; - } -} -exports.default = Reader; - - -/***/ }), -/* 799 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; +"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const path = __webpack_require__(4); -const deep_1 = __webpack_require__(800); -const entry_1 = __webpack_require__(803); -const error_1 = __webpack_require__(804); -const entry_2 = __webpack_require__(805); +const deep_1 = __webpack_require__(794); +const entry_1 = __webpack_require__(797); +const error_1 = __webpack_require__(798); +const entry_2 = __webpack_require__(799); class Provider { constructor(_settings) { this._settings = _settings; @@ -92428,14 +90310,14 @@ exports.default = Provider; /***/ }), -/* 800 */ +/* 794 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const utils = __webpack_require__(781); -const partial_1 = __webpack_require__(801); +const partial_1 = __webpack_require__(795); class DeepFilter { constructor(_settings, _micromatchOptions) { this._settings = _settings; @@ -92497,13 +90379,13 @@ exports.default = DeepFilter; /***/ }), -/* 801 */ +/* 795 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const matcher_1 = __webpack_require__(802); +const matcher_1 = __webpack_require__(796); class PartialMatcher extends matcher_1.default { match(filepath) { const parts = filepath.split('/'); @@ -92542,7 +90424,7 @@ exports.default = PartialMatcher; /***/ }), -/* 802 */ +/* 796 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92599,7 +90481,7 @@ exports.default = Matcher; /***/ }), -/* 803 */ +/* 797 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92662,7 +90544,7 @@ exports.default = EntryFilter; /***/ }), -/* 804 */ +/* 798 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92684,7 +90566,7 @@ exports.default = ErrorFilter; /***/ }), -/* 805 */ +/* 799 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92717,15 +90599,15 @@ exports.default = EntryTransformer; /***/ }), -/* 806 */ +/* 800 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const stream_1 = __webpack_require__(173); -const stream_2 = __webpack_require__(797); -const provider_1 = __webpack_require__(799); +const stream_2 = __webpack_require__(791); +const provider_1 = __webpack_require__(793); class ProviderStream extends provider_1.default { constructor() { super(...arguments); @@ -92755,14 +90637,14 @@ exports.default = ProviderStream; /***/ }), -/* 807 */ +/* 801 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const sync_1 = __webpack_require__(808); -const provider_1 = __webpack_require__(799); +const sync_1 = __webpack_require__(802); +const provider_1 = __webpack_require__(793); class ProviderSync extends provider_1.default { constructor() { super(...arguments); @@ -92785,7 +90667,7 @@ exports.default = ProviderSync; /***/ }), -/* 808 */ +/* 802 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92793,7 +90675,7 @@ exports.default = ProviderSync; Object.defineProperty(exports, "__esModule", { value: true }); const fsStat = __webpack_require__(289); const fsWalk = __webpack_require__(294); -const reader_1 = __webpack_require__(798); +const reader_1 = __webpack_require__(792); class ReaderSync extends reader_1.default { constructor() { super(...arguments); @@ -92835,7 +90717,7 @@ exports.default = ReaderSync; /***/ }), -/* 809 */ +/* 803 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92899,7 +90781,7 @@ exports.default = Settings; /***/ }), -/* 810 */ +/* 804 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -93026,7 +90908,7 @@ module.exports.sync = options => { /***/ }), -/* 811 */ +/* 805 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -93079,7 +90961,7 @@ module.exports = { /***/ }), -/* 812 */ +/* 806 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; diff --git a/packages/kbn-pm/src/commands/bootstrap.ts b/packages/kbn-pm/src/commands/bootstrap.ts index 0b3141ab9a5a..cf425264ab6d 100644 --- a/packages/kbn-pm/src/commands/bootstrap.ts +++ b/packages/kbn-pm/src/commands/bootstrap.ts @@ -40,7 +40,19 @@ export const BootstrapCommand: ICommand = { const kibanaProjectPath = projects.get('kibana')?.path || ''; const runOffline = options?.offline === true; const reporter = CiStatsReporter.fromEnv(log); - const timings = []; + + const timings: Array<{ id: string; ms: number }> = []; + const time = async (id: string, body: () => Promise): Promise => { + const start = Date.now(); + try { + return await body(); + } finally { + timings.push({ + id, + ms: Date.now() - start, + }); + } + }; // Force install is set in case a flag is passed or // if the `.yarn-integrity` file is not found which @@ -70,20 +82,14 @@ export const BootstrapCommand: ICommand = { // if (forceInstall) { - const forceInstallStartTime = Date.now(); - await runBazel(['run', '@nodejs//:yarn'], runOffline); - timings.push({ - id: 'force install dependencies', - ms: Date.now() - forceInstallStartTime, + await time('force install dependencies', async () => { + await runBazel(['run', '@nodejs//:yarn'], runOffline); }); } // build packages - const packageStartTime = Date.now(); - await runBazel(['build', '//packages:build', '--show_result=1'], runOffline); - timings.push({ - id: 'build packages', - ms: Date.now() - packageStartTime, + await time('build packages', async () => { + await runBazel(['build', '//packages:build', '--show_result=1'], runOffline); }); // Install monorepo npm dependencies outside of the Bazel managed ones @@ -112,30 +118,38 @@ export const BootstrapCommand: ICommand = { } } - await sortPackageJson(kbn); + await time('sort package json', async () => { + await sortPackageJson(kbn); + }); - const yarnLock = await readYarnLock(kbn); + const yarnLock = await time('read yarn.lock', async () => await readYarnLock(kbn)); if (options.validate) { - await validateDependencies(kbn, yarnLock); + await time('validate dependencies', async () => { + await validateDependencies(kbn, yarnLock); + }); } // Assure all kbn projects with bin defined scripts // copy those scripts into the top level node_modules folder // // NOTE: We don't probably need this anymore, is actually not being used - await linkProjectExecutables(projects, projectGraph); - - // Update vscode settings - await spawnStreaming( - process.execPath, - ['scripts/update_vscode_config'], - { - cwd: kbn.getAbsolute(), - env: process.env, - }, - { prefix: '[vscode]', debug: false } - ); + await time('link project executables', async () => { + await linkProjectExecutables(projects, projectGraph); + }); + + await time('update vscode config', async () => { + // Update vscode settings + await spawnStreaming( + process.execPath, + ['scripts/update_vscode_config'], + { + cwd: kbn.getAbsolute(), + env: process.env, + }, + { prefix: '[vscode]', debug: false } + ); + }); // send timings await reporter.timings({ diff --git a/packages/kbn-pm/src/commands/index.ts b/packages/kbn-pm/src/commands/index.ts index 70e641f1e935..4c7992859ebd 100644 --- a/packages/kbn-pm/src/commands/index.ts +++ b/packages/kbn-pm/src/commands/index.ts @@ -32,7 +32,6 @@ import { CleanCommand } from './clean'; import { ResetCommand } from './reset'; import { RunCommand } from './run'; import { WatchCommand } from './watch'; -import { PatchNativeModulesCommand } from './patch_native_modules'; import { Kibana } from '../utils/kibana'; export const commands: { [key: string]: ICommand } = { @@ -42,5 +41,4 @@ export const commands: { [key: string]: ICommand } = { reset: ResetCommand, run: RunCommand, watch: WatchCommand, - patch_native_modules: PatchNativeModulesCommand, }; diff --git a/packages/kbn-pm/src/commands/patch_native_modules.ts b/packages/kbn-pm/src/commands/patch_native_modules.ts deleted file mode 100644 index 30fd599b83be..000000000000 --- a/packages/kbn-pm/src/commands/patch_native_modules.ts +++ /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 Path from 'path'; -import Fs from 'fs'; - -import { CiStatsReporter } from '@kbn/dev-utils/ci_stats_reporter'; - -import { log } from '../utils/log'; -import { spawn } from '../utils/child_process'; -import { ICommand } from './index'; - -export const PatchNativeModulesCommand: ICommand = { - description: 'Patch native modules by running build commands on M1 Macs', - name: 'patch_native_modules', - - async run(projects, _, { kbn }) { - const kibanaProjectPath = projects.get('kibana')?.path || ''; - const reporter = CiStatsReporter.fromEnv(log); - - if (process.platform !== 'darwin' || process.arch !== 'arm64') { - return; - } - - const startTime = Date.now(); - const nodeSassDir = Path.resolve(kibanaProjectPath, 'node_modules/node-sass'); - const nodeSassNativeDist = Path.resolve( - nodeSassDir, - `vendor/darwin-arm64-${process.versions.modules}/binding.node` - ); - if (!Fs.existsSync(nodeSassNativeDist)) { - log.info('Running build script for node-sass'); - await spawn('npm', ['run', 'build'], { - cwd: nodeSassDir, - }); - } - - const re2Dir = Path.resolve(kibanaProjectPath, 'node_modules/re2'); - const re2NativeDist = Path.resolve(re2Dir, 'build/Release/re2.node'); - if (!Fs.existsSync(re2NativeDist)) { - log.info('Running build script for re2'); - await spawn('npm', ['run', 'rebuild'], { - cwd: re2Dir, - }); - } - - log.success('native modules should be setup for native ARM Mac development'); - - // send timings - await reporter.timings({ - upstreamBranch: kbn.kibanaProject.json.branch, - // prevent loading @kbn/utils by passing null - kibanaUuid: kbn.getUuid() || null, - timings: [ - { - group: 'scripts/kbn bootstrap', - id: 'patch native modudles for arm macs', - ms: Date.now() - startTime, - }, - ], - }); - }, -}; diff --git a/packages/kbn-pm/src/utils/scripts.ts b/packages/kbn-pm/src/utils/scripts.ts index ab013495a326..6e99285e145f 100644 --- a/packages/kbn-pm/src/utils/scripts.ts +++ b/packages/kbn-pm/src/utils/scripts.ts @@ -21,6 +21,12 @@ export async function installInDir(directory: string, extraArgs: string[] = []) // given time (e.g. to avoid conflicts). await spawn(YARN_EXEC, options, { cwd: directory, + env: { + SASS_BINARY_SITE: + 'https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/node-sass', + RE2_DOWNLOAD_MIRROR: + 'https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/node-re2', + }, }); } diff --git a/packages/kbn-pm/src/utils/sort_package_json.ts b/packages/kbn-pm/src/utils/sort_package_json.ts index b4df5355744f..84a1ed6d8096 100644 --- a/packages/kbn-pm/src/utils/sort_package_json.ts +++ b/packages/kbn-pm/src/utils/sort_package_json.ts @@ -7,41 +7,10 @@ */ import Fs from 'fs/promises'; - -import sorter from 'sort-package-json'; - +import { sortPackageJson as sort } from '@kbn/dev-utils/sort_package_json'; import { Kibana } from './kibana'; export async function sortPackageJson(kbn: Kibana) { const packageJsonPath = kbn.getAbsolute('package.json'); - const packageJson = await Fs.readFile(packageJsonPath, 'utf-8'); - await Fs.writeFile( - packageJsonPath, - JSON.stringify( - sorter(JSON.parse(packageJson), { - // top level keys in the order they were written when this was implemented - sortOrder: [ - 'name', - 'description', - 'keywords', - 'private', - 'version', - 'branch', - 'types', - 'tsdocMetadata', - 'build', - 'homepage', - 'bugs', - 'kibana', - 'author', - 'scripts', - 'repository', - 'engines', - 'resolutions', - ], - }), - null, - 2 - ) + '\n' - ); + await Fs.writeFile(packageJsonPath, sort(await Fs.readFile(packageJsonPath, 'utf-8'))); } diff --git a/packages/kbn-securitysolution-autocomplete/README.md b/packages/kbn-securitysolution-autocomplete/README.md index 41bfd9baf628..83b2d6a1882c 100644 --- a/packages/kbn-securitysolution-autocomplete/README.md +++ b/packages/kbn-securitysolution-autocomplete/README.md @@ -1,6 +1,6 @@ # Autocomplete Fields -Need an input that shows available index fields? Or an input that autocompletes based on a selected indexPattern field? Bingo! That's what these components are for. They are generalized enough so that they can be reused throughout and repurposed based on your needs. +Need an input that shows available index fields? Or an input that auto-completes based on a selected indexPattern field? Bingo! That's what these components are for. They are generalized enough so that they can be reused throughout and repurposed based on your needs. All three of the available components rely on Eui's combo box. @@ -119,4 +119,24 @@ The `onChange` handler is passed selected `string[]`. indexPattern={indexPattern} onChange={handleFieldMatchAnyValueChange} /> +``` + +## AutocompleteFieldWildcardComponent + +This component can be used to allow users to select a single value. It uses the autocomplete hook to display any autocomplete options based on the passed in `indexPattern`, but also allows a user to add their own value. + +The `onChange` handler is passed selected `string[]`. + +```js + ``` \ No newline at end of file diff --git a/packages/kbn-securitysolution-autocomplete/src/field_value_wildcard/index.test.tsx b/packages/kbn-securitysolution-autocomplete/src/field_value_wildcard/index.test.tsx new file mode 100644 index 000000000000..34769a76563c --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/field_value_wildcard/index.test.tsx @@ -0,0 +1,279 @@ +/* + * Copyright 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 { ReactWrapper, mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { act } from '@testing-library/react'; +import { AutocompleteFieldWildcardComponent } from '.'; +import { useFieldValueAutocomplete } from '../hooks/use_field_value_autocomplete'; +import { fields, getField } from '../fields/index.mock'; +import { autocompleteStartMock } from '../autocomplete/index.mock'; + +jest.mock('../hooks/use_field_value_autocomplete'); + +describe('AutocompleteFieldWildcardComponent', () => { + let wrapper: ReactWrapper; + + const getValueSuggestionsMock = jest + .fn() + .mockResolvedValue([false, true, ['value 3', 'value 4'], jest.fn()]); + + beforeEach(() => { + (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ + false, + true, + ['value 1', 'value 2'], + getValueSuggestionsMock, + ]); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('it renders row label if one passed in', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteWildcardLabel"] label').at(0).text() + ).toEqual('Row Label'); + }); + + test('it renders disabled if "isDisabled" is true', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteWildcard"] input').prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + wrapper = mount( + + ); + wrapper.find('[data-test-subj="valuesAutocompleteWildcard"] button').at(0).simulate('click'); + expect( + wrapper + .find('EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteWildcard-optionsList"]') + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + wrapper = mount( + + ); + + expect( + wrapper + .find('[data-test-subj="comboBoxInput"]') + .hasClass('euiComboBox__inputWrap-isClearable') + ).toBeTruthy(); + }); + + test('it correctly displays selected value', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteWildcard"] EuiComboBoxPill').at(0).text() + ).toEqual('/opt/*/app.dmg'); + }); + + test('it invokes "onChange" when new value created', async () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ( + wrapper.find(EuiComboBox).props() as unknown as { + onCreateOption: (a: string) => void; + } + ).onCreateOption('/opt/*/app.dmg'); + + expect(mockOnChange).toHaveBeenCalledWith('/opt/*/app.dmg'); + }); + + test('it invokes "onChange" when new value selected', async () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ( + wrapper.find(EuiComboBox).props() as unknown as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + } + ).onChange([{ label: 'value 1' }]); + + expect(mockOnChange).toHaveBeenCalledWith('value 1'); + }); + + test('it refreshes autocomplete with search query when new value searched', () => { + wrapper = mount( + + ); + act(() => { + ( + wrapper.find(EuiComboBox).props() as unknown as { + onSearchChange: (a: string) => void; + } + ).onSearchChange('A:\\Some Folder\\inc*.exe'); + }); + + expect(useFieldValueAutocomplete).toHaveBeenCalledWith({ + autocompleteService: autocompleteStartMock, + fieldValue: '', + indexPattern: { + fields, + id: '1234', + title: 'logs-endpoint.events.*', + }, + operatorType: 'wildcard', + query: 'A:\\Some Folder\\inc*.exe', + selectedField: getField('file.path.text'), + }); + }); +}); diff --git a/packages/kbn-securitysolution-autocomplete/src/field_value_wildcard/index.tsx b/packages/kbn-securitysolution-autocomplete/src/field_value_wildcard/index.tsx new file mode 100644 index 000000000000..159267c3386d --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/field_value_wildcard/index.tsx @@ -0,0 +1,255 @@ +/* + * Copyright 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, useMemo, useState, useEffect, memo } from 'react'; +import { EuiFormRow, EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; +import { DataViewBase, DataViewFieldBase } from '@kbn/es-query'; + +import { uniq } from 'lodash'; + +import { ListOperatorTypeEnum as OperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; + +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/100715 +// import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; +type AutocompleteStart = any; + +import * as i18n from '../translations'; +import { useFieldValueAutocomplete } from '../hooks/use_field_value_autocomplete'; +import { + getGenericComboBoxProps, + GetGenericComboBoxPropsReturn, +} from '../get_generic_combo_box_props'; +import { paramIsValid } from '../param_is_valid'; + +const SINGLE_SELECTION = { asPlainText: true }; + +interface AutocompleteFieldWildcardProps { + placeholder: string; + selectedField: DataViewFieldBase | undefined; + selectedValue: string | undefined; + indexPattern: DataViewBase | undefined; + isLoading: boolean; + isDisabled?: boolean; + isClearable?: boolean; + isRequired?: boolean; + fieldInputWidth?: number; + rowLabel?: string; + autocompleteService: AutocompleteStart; + onChange: (arg: string) => void; + onError: (arg: boolean) => void; + onWarning: (arg: boolean) => void; + warning?: string; +} + +export const AutocompleteFieldWildcardComponent: React.FC = memo( + ({ + autocompleteService, + placeholder, + rowLabel, + selectedField, + selectedValue, + indexPattern, + isLoading, + isDisabled = false, + isClearable = false, + isRequired = false, + fieldInputWidth, + onChange, + onError, + onWarning, + warning, + }): JSX.Element => { + const [searchQuery, setSearchQuery] = useState(''); + const [touched, setIsTouched] = useState(false); + const [error, setError] = useState(undefined); + const [isLoadingSuggestions, , suggestions] = useFieldValueAutocomplete({ + autocompleteService, + fieldValue: selectedValue, + indexPattern, + operatorType: OperatorTypeEnum.WILDCARD, + query: searchQuery, + selectedField, + }); + const getLabel = useCallback((option: string): string => option, []); + const optionsMemo = useMemo((): string[] => { + const valueAsStr = String(selectedValue); + return selectedValue != null && selectedValue.trim() !== '' + ? uniq([valueAsStr, ...suggestions]) + : suggestions; + }, [suggestions, selectedValue]); + const selectedOptionsMemo = useMemo((): string[] => { + const valueAsStr = String(selectedValue); + return selectedValue ? [valueAsStr] : []; + }, [selectedValue]); + + const handleError = useCallback( + (err: string | undefined): void => { + setError((existingErr): string | undefined => { + const oldErr = existingErr != null; + const newErr = err != null; + if (oldErr !== newErr && onError != null) { + onError(newErr); + } + + return err; + }); + }, + [setError, onError] + ); + + const handleWarning = useCallback( + (warn: string | undefined): void => { + onWarning(warn !== undefined); + }, + [onWarning] + ); + + const { comboOptions, labels, selectedComboOptions } = useMemo( + (): GetGenericComboBoxPropsReturn => + getGenericComboBoxProps({ + getLabel, + options: optionsMemo, + selectedOptions: selectedOptionsMemo, + }), + [optionsMemo, selectedOptionsMemo, getLabel] + ); + + const handleValuesChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]): void => { + const [newValue] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]); + handleError(undefined); + handleWarning(undefined); + onChange(newValue ?? ''); + }, + [handleError, handleWarning, labels, onChange, optionsMemo] + ); + + const handleSearchChange = useCallback( + (searchVal: string): void => { + if (searchVal.trim() !== '' && selectedField != null) { + const err = paramIsValid(searchVal, selectedField, isRequired, touched); + handleError(err); + handleWarning(warning); + setSearchQuery(searchVal); + } + }, + [handleError, isRequired, selectedField, touched, warning, handleWarning] + ); + + const handleCreateOption = useCallback( + (option: string): boolean | undefined => { + const err = paramIsValid(option, selectedField, isRequired, touched); + handleError(err); + handleWarning(warning); + + if (err != null) { + // Explicitly reject the user's input + return false; + } else { + onChange(option); + return undefined; + } + }, + [isRequired, onChange, selectedField, touched, handleError, handleWarning, warning] + ); + + const setIsTouchedValue = useCallback((): void => { + setIsTouched(true); + + const err = paramIsValid(selectedValue, selectedField, isRequired, true); + handleError(err); + handleWarning(warning); + }, [ + setIsTouched, + handleError, + selectedValue, + selectedField, + isRequired, + handleWarning, + warning, + ]); + + const inputPlaceholder = useMemo((): string => { + if (isLoading || isLoadingSuggestions) { + return i18n.LOADING; + } else if (selectedField == null) { + return i18n.SELECT_FIELD_FIRST; + } else { + return placeholder; + } + }, [isLoading, selectedField, isLoadingSuggestions, placeholder]); + + const isLoadingState = useMemo( + (): boolean => isLoading || isLoadingSuggestions, + [isLoading, isLoadingSuggestions] + ); + + useEffect((): void => { + setError(undefined); + if (onError != null) { + onError(false); + } + if (onWarning != null) { + onWarning(false); + } + }, [selectedField, onError, onWarning]); + + const defaultInput = useMemo((): JSX.Element => { + return ( + + + + ); + }, [ + comboOptions, + error, + fieldInputWidth, + handleCreateOption, + handleSearchChange, + handleValuesChange, + inputPlaceholder, + isClearable, + isDisabled, + isLoadingState, + rowLabel, + selectedComboOptions, + selectedField, + setIsTouchedValue, + warning, + ]); + + return defaultInput; + } +); + +AutocompleteFieldWildcardComponent.displayName = 'AutocompleteFieldWildcard'; diff --git a/packages/kbn-securitysolution-autocomplete/src/fields/index.mock.ts b/packages/kbn-securitysolution-autocomplete/src/fields/index.mock.ts index d25dc5d45c9e..b3def81c4336 100644 --- a/packages/kbn-securitysolution-autocomplete/src/fields/index.mock.ts +++ b/packages/kbn-securitysolution-autocomplete/src/fields/index.mock.ts @@ -309,6 +309,14 @@ export const fields: DataViewFieldBase[] = [ readFromDocValues: false, subType: { nested: { path: 'nestedField.nestedChild' } }, }, + { + name: 'file.path.text', + type: 'string', + esTypes: ['text'], + searchable: true, + aggregatable: false, + subType: { multi: { parent: 'file.path' } }, + }, ] as unknown as DataViewFieldBase[]; export const getField = (name: string) => fields.find((field) => field.name === name); diff --git a/packages/kbn-securitysolution-autocomplete/src/get_operators/index.test.ts b/packages/kbn-securitysolution-autocomplete/src/get_operators/index.test.ts index 9ed9c6358c39..24e4d759989e 100644 --- a/packages/kbn-securitysolution-autocomplete/src/get_operators/index.test.ts +++ b/packages/kbn-securitysolution-autocomplete/src/get_operators/index.test.ts @@ -8,6 +8,7 @@ import { doesNotExistOperator, + EVENT_FILTERS_OPERATORS, EXCEPTION_OPERATORS, existsOperator, isNotOperator, @@ -40,6 +41,15 @@ describe('#getOperators', () => { expect(operator).toEqual([isOperator]); }); + test('it includes a "matches" operator when field is "file.path.text"', () => { + const operator = getOperators({ + name: 'file.path.text', + type: 'simple', + }); + + expect(operator).toEqual(EVENT_FILTERS_OPERATORS); + }); + test('it returns all operator types when field type is not null, boolean, or nested', () => { const operator = getOperators(getField('machine.os.raw')); diff --git a/packages/kbn-securitysolution-autocomplete/src/get_operators/index.ts b/packages/kbn-securitysolution-autocomplete/src/get_operators/index.ts index e84dc33e676e..643c330b1524 100644 --- a/packages/kbn-securitysolution-autocomplete/src/get_operators/index.ts +++ b/packages/kbn-securitysolution-autocomplete/src/get_operators/index.ts @@ -10,6 +10,7 @@ import { DataViewFieldBase } from '@kbn/es-query'; import { EXCEPTION_OPERATORS, + EVENT_FILTERS_OPERATORS, OperatorOption, doesNotExistOperator, existsOperator, @@ -30,6 +31,8 @@ export const getOperators = (field: DataViewFieldBase | undefined): OperatorOpti return [isOperator, isNotOperator, existsOperator, doesNotExistOperator]; } else if (field.type === 'nested') { return [isOperator]; + } else if (field.name === 'file.path.text') { + return EVENT_FILTERS_OPERATORS; } else { return EXCEPTION_OPERATORS; } diff --git a/packages/kbn-securitysolution-autocomplete/src/index.ts b/packages/kbn-securitysolution-autocomplete/src/index.ts index 5fcb3f954189..fcb1ea6b2cde 100644 --- a/packages/kbn-securitysolution-autocomplete/src/index.ts +++ b/packages/kbn-securitysolution-autocomplete/src/index.ts @@ -11,6 +11,7 @@ export * from './field_value_exists'; export * from './field_value_lists'; export * from './field_value_match'; export * from './field_value_match_any'; +export * from './field_value_wildcard'; export * from './filter_field_to_list'; export * from './get_generic_combo_box_props'; export * from './get_operators'; diff --git a/packages/kbn-securitysolution-autocomplete/src/operator/index.test.tsx b/packages/kbn-securitysolution-autocomplete/src/operator/index.test.tsx index 6d1622f0fa95..48f5cbf25b91 100644 --- a/packages/kbn-securitysolution-autocomplete/src/operator/index.test.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/operator/index.test.tsx @@ -129,6 +129,9 @@ describe('operator', () => { { label: 'is not in list', }, + { + label: 'matches', + }, ]); }); @@ -196,6 +199,30 @@ describe('operator', () => { ]); }); + test('it only displays subset of operators if field name is "file.path.text"', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([ + { label: 'is' }, + { label: 'is not' }, + { label: 'is one of' }, + { label: 'is not one of' }, + { label: 'matches' }, + ]); + }); + test('it invokes "onChange" when option selected', () => { const mockOnChange = jest.fn(); const wrapper = mount( diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.mock.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.mock.ts index 176a6357b30e..64f7e1aceeb2 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.mock.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.mock.ts @@ -10,11 +10,11 @@ import { EndpointEntriesArray } from '.'; import { getEndpointEntryMatchMock } from '../entry_match/index.mock'; import { getEndpointEntryMatchAnyMock } from '../entry_match_any/index.mock'; import { getEndpointEntryNestedMock } from '../entry_nested/index.mock'; -import { getEndpointEntryMatchWildcard } from '../entry_match_wildcard/index.mock'; +import { getEndpointEntryMatchWildcardMock } from '../entry_match_wildcard/index.mock'; export const getEndpointEntriesArrayMock = (): EndpointEntriesArray => [ getEndpointEntryMatchMock(), getEndpointEntryMatchAnyMock(), getEndpointEntryNestedMock(), - getEndpointEntryMatchWildcard(), + getEndpointEntryMatchWildcardMock(), ]; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.test.ts index ca852e15c5c2..08235d35e921 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.test.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.test.ts @@ -20,7 +20,7 @@ import { getEndpointEntryNestedMock } from '../entry_nested/index.mock'; import { getEndpointEntriesArrayMock } from './index.mock'; import { getEntryListMock } from '../../entries_list/index.mock'; import { getEntryExistsMock } from '../../entries_exist/index.mock'; -import { getEndpointEntryMatchWildcard } from '../entry_match_wildcard/index.mock'; +import { getEndpointEntryMatchWildcardMock } from '../entry_match_wildcard/index.mock'; describe('Endpoint', () => { describe('entriesArray', () => { @@ -101,7 +101,7 @@ describe('Endpoint', () => { }); test('it should validate an array with wildcard entry', () => { - const payload = [getEndpointEntryMatchWildcard()]; + const payload = [getEndpointEntryMatchWildcardMock()]; const decoded = endpointEntriesArray.decode(payload); const message = pipe(decoded, foldLeftRight); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.mock.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.mock.ts index e001552277e0..842e046ea67e 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.mock.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.mock.ts @@ -9,7 +9,7 @@ import { ENTRY_VALUE, FIELD, OPERATOR, WILDCARD } from '../../../constants/index.mock'; import { EndpointEntryMatchWildcard } from './index'; -export const getEndpointEntryMatchWildcard = (): EndpointEntryMatchWildcard => ({ +export const getEndpointEntryMatchWildcardMock = (): EndpointEntryMatchWildcard => ({ field: FIELD, operator: OPERATOR, type: WILDCARD, diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.test.ts new file mode 100644 index 000000000000..9671e721f20c --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; +import { getEndpointEntryMatchWildcardMock } from './index.mock'; +import { EndpointEntryMatchWildcard, endpointEntryMatchWildcard } from '.'; +import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; +import { getEntryMatchWildcardMock } from '../../entry_match_wildcard/index.mock'; + +describe('endpointEntryMatchWildcard', () => { + test('it should validate an entry', () => { + const payload = getEndpointEntryMatchWildcardMock(); + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate when "operator" is "excluded"', () => { + const payload = getEntryMatchWildcardMock(); + payload.operator = 'excluded'; + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "excluded" supplied to "operator"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "field" is empty string', () => { + const payload: Omit & { field: string } = { + ...getEndpointEntryMatchWildcardMock(), + field: '', + }; + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "value" is not string', () => { + const payload: Omit & { value: string[] } = { + ...getEndpointEntryMatchWildcardMock(), + value: ['some value'], + }; + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "["some value"]" supplied to "value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "value" is empty string', () => { + const payload: Omit & { value: string } = { + ...getEndpointEntryMatchWildcardMock(), + value: '', + }; + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "value"']); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "type" is not "wildcard"', () => { + const payload: Omit & { type: string } = { + ...getEndpointEntryMatchWildcardMock(), + type: 'match', + }; + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EndpointEntryMatchWildcard & { + extraKey?: string; + } = getEndpointEntryMatchWildcardMock(); + payload.extraKey = 'some value'; + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getEntryMatchWildcardMock()); + }); +}); diff --git a/packages/kbn-securitysolution-list-utils/src/autocomplete_operators/index.ts b/packages/kbn-securitysolution-list-utils/src/autocomplete_operators/index.ts index 101076bdfcff..ac3236528b67 100644 --- a/packages/kbn-securitysolution-list-utils/src/autocomplete_operators/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/autocomplete_operators/index.ts @@ -85,11 +85,21 @@ export const isNotInListOperator: OperatorOption = { value: 'is_not_in_list', }; +export const matchesOperator: OperatorOption = { + message: i18n.translate('lists.exceptions.matchesOperatorLabel', { + defaultMessage: 'matches', + }), + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.WILDCARD, + value: 'matches', +}; + export const EVENT_FILTERS_OPERATORS: OperatorOption[] = [ isOperator, isNotOperator, isOneOfOperator, isNotOneOfOperator, + matchesOperator, ]; export const EXCEPTION_OPERATORS: OperatorOption[] = [ @@ -101,6 +111,7 @@ export const EXCEPTION_OPERATORS: OperatorOption[] = [ doesNotExistOperator, isInListOperator, isNotInListOperator, + matchesOperator, ]; export const EXCEPTION_OPERATORS_SANS_LISTS: OperatorOption[] = [ diff --git a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts index 394d4f02b877..eabf8dfa33f9 100644 --- a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts @@ -172,6 +172,8 @@ export const getOperatorType = (item: BuilderEntry): OperatorTypeEnum => { return OperatorTypeEnum.MATCH; case 'match_any': return OperatorTypeEnum.MATCH_ANY; + case 'wildcard': + return OperatorTypeEnum.WILDCARD; case 'list': return OperatorTypeEnum.LIST; default: @@ -207,6 +209,7 @@ export const getEntryValue = (item: BuilderEntry): string | string[] | undefined switch (item.type) { case OperatorTypeEnum.MATCH: case OperatorTypeEnum.MATCH_ANY: + case OperatorTypeEnum.WILDCARD: return item.value; case OperatorTypeEnum.EXISTS: return undefined; @@ -523,6 +526,54 @@ export const getEntryOnMatchChange = ( } }; +/** + * Determines proper entry update when user updates value + * when operator is of type "wildcard" + * + * @param item - current exception item entry values + * @param newField - newly entered value + * + */ +export const getEntryOnWildcardChange = ( + item: FormattedBuilderEntry, + newField: string +): { index: number; updatedEntry: BuilderEntry } => { + const { nested, parent, entryIndex, field, operator } = item; + + if (nested != null && parent != null) { + const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; + + return { + index: parent.parentIndex, + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: fieldName, + id: item.id, + operator: operator.operator, + type: OperatorTypeEnum.WILDCARD, + value: newField, + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + }; + } else { + return { + index: entryIndex, + updatedEntry: { + field: field != null ? field.name : '', + id: item.id, + operator: operator.operator, + type: OperatorTypeEnum.WILDCARD, + value: newField, + }, + }; + } +}; + /** * On operator change, determines whether value needs to be cleared or not * @@ -563,6 +614,15 @@ export const getEntryFromOperator = ( operator: selectedOperator.operator, type: OperatorTypeEnum.LIST, }; + case 'wildcard': + return { + field: fieldValue, + id: currentEntry.id, + operator: selectedOperator.operator, + type: OperatorTypeEnum.WILDCARD, + value: + isSameOperatorType && typeof currentEntry.value === 'string' ? currentEntry.value : '', + }; default: return { field: fieldValue, diff --git a/packages/kbn-securitysolution-utils/BUILD.bazel b/packages/kbn-securitysolution-utils/BUILD.bazel index cfb6b722ea2e..70ecc2712d4a 100644 --- a/packages/kbn-securitysolution-utils/BUILD.bazel +++ b/packages/kbn-securitysolution-utils/BUILD.bazel @@ -28,11 +28,13 @@ NPM_MODULE_EXTRA_FILES = [ ] RUNTIME_DEPS = [ + "//packages/kbn-i18n", "@npm//tslib", - "@npm//uuid", + "@npm//uuid" ] TYPES_DEPS = [ + "//packages/kbn-i18n:npm_module_types", "@npm//tslib", "@npm//@types/jest", "@npm//@types/node", diff --git a/packages/kbn-securitysolution-utils/src/index.ts b/packages/kbn-securitysolution-utils/src/index.ts index 755bbd2203df..e3442a3ec7dc 100644 --- a/packages/kbn-securitysolution-utils/src/index.ts +++ b/packages/kbn-securitysolution-utils/src/index.ts @@ -8,3 +8,4 @@ export * from './add_remove_id_to_item'; export * from './transform_data_to_ndjson'; +export * from './path_validations'; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.test.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts similarity index 84% rename from x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.test.ts rename to packages/kbn-securitysolution-utils/src/path_validations/index.test.ts index 952a2fa234ac..ee2d8764a30a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.test.ts +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts @@ -1,12 +1,84 @@ /* * Copyright 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. + * 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 { isPathValid, hasSimpleExecutableName } from './validations'; -import { OperatingSystem, ConditionEntryField } from '../../types'; +import { + isPathValid, + hasSimpleExecutableName, + OperatingSystem, + ConditionEntryField, + validateFilePathInput, + FILENAME_WILDCARD_WARNING, + FILEPATH_WARNING, +} from '.'; + +describe('validateFilePathInput', () => { + describe('windows', () => { + const os = OperatingSystem.WINDOWS; + + it('warns on wildcard in file name at the end of the path', () => { + expect(validateFilePathInput({ os, value: 'c:\\path*.exe' })).toEqual( + FILENAME_WILDCARD_WARNING + ); + }); + + it('warns on unix paths or non-windows paths', () => { + expect(validateFilePathInput({ os, value: '/opt/bin' })).toEqual(FILEPATH_WARNING); + }); + + it('warns on malformed paths', () => { + expect(validateFilePathInput({ os, value: 'c:\\path/opt' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: '1242' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: 'w12efdfa' })).toEqual(FILEPATH_WARNING); + }); + }); + describe('unix paths', () => { + const os = + parseInt((Math.random() * 2).toString(), 10) === 1 + ? OperatingSystem.MAC + : OperatingSystem.LINUX; + + it('warns on wildcard in file name at the end of the path', () => { + expect(validateFilePathInput({ os, value: '/opt/bin*' })).toEqual(FILENAME_WILDCARD_WARNING); + }); + + it('warns on windows paths', () => { + expect(validateFilePathInput({ os, value: 'd:\\path\\file.exe' })).toEqual(FILEPATH_WARNING); + }); + + it('warns on malformed paths', () => { + expect(validateFilePathInput({ os, value: 'opt/bin\\file.exe' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: '1242' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: 'w12efdfa' })).toEqual(FILEPATH_WARNING); + }); + }); +}); + +describe('No Warnings', () => { + it('should not show warnings on non path entries ', () => { + expect( + isPathValid({ + os: OperatingSystem.WINDOWS, + field: ConditionEntryField.HASH, + type: 'match', + value: '5d5b09f6dcb2d53a5fffc60c4ac0d55fabdf556069d6631545f42aa6e3500f2e', + }) + ).toEqual(true); + + expect( + isPathValid({ + os: OperatingSystem.WINDOWS, + field: ConditionEntryField.SIGNER, + type: 'match', + value: '', + }) + ).toEqual(true); + }); +}); describe('Unacceptable Windows wildcard paths', () => { it('should not accept paths that do not have a folder name with a wildcard ', () => { diff --git a/packages/kbn-securitysolution-utils/src/path_validations/index.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.ts new file mode 100644 index 000000000000..82d2cc3151b9 --- /dev/null +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.ts @@ -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 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'; + +export const FILENAME_WILDCARD_WARNING = i18n.translate('utils.filename.wildcardWarning', { + defaultMessage: `A wildcard in the filename will affect the endpoint's performance`, +}); + +export const FILEPATH_WARNING = i18n.translate('utils.filename.pathWarning', { + defaultMessage: `Path may be formed incorrectly; verify value`, +}); + +export const enum ConditionEntryField { + HASH = 'process.hash.*', + PATH = 'process.executable.caseless', + SIGNER = 'process.Ext.code_signature', +} + +export const enum OperatingSystem { + LINUX = 'linux', + MAC = 'macos', + WINDOWS = 'windows', +} + +export type TrustedAppEntryTypes = 'match' | 'wildcard'; +/* + * regex to match executable names + * starts matching from the eol of the path + * file names with a single or multiple spaces (for spaced names) + * and hyphens and combinations of these that produce complex names + * such as: + * c:\home\lib\dmp.dmp + * c:\home\lib\my-binary-app-+/ some/ x/ dmp.dmp + * /home/lib/dmp.dmp + * /home/lib/my-binary-app+-\ some\ x\ dmp.dmp + */ +export const WIN_EXEC_PATH = /(\\[-\w]+|\\[-\w]+[\.]+[\w]+)$/i; +export const UNIX_EXEC_PATH = /(\/[-\w]+|\/[-\w]+[\.]+[\w]+)$/i; + +export const validateFilePathInput = ({ + os, + value = '', +}: { + os: OperatingSystem; + value?: string; +}): string | undefined => { + const textInput = value.trim(); + const isValidFilePath = isPathValid({ + os, + field: 'file.path.text', + type: 'wildcard', + value: textInput, + }); + const hasSimpleFileName = hasSimpleExecutableName({ + os, + type: 'wildcard', + value: textInput, + }); + + if (!textInput.length) { + return FILEPATH_WARNING; + } + + if (isValidFilePath) { + if (!hasSimpleFileName) { + return FILENAME_WILDCARD_WARNING; + } + } else { + return FILEPATH_WARNING; + } +}; + +export const hasSimpleExecutableName = ({ + os, + type, + value, +}: { + os: OperatingSystem; + type: TrustedAppEntryTypes; + value: string; +}): boolean => { + if (type === 'wildcard') { + return os === OperatingSystem.WINDOWS ? WIN_EXEC_PATH.test(value) : UNIX_EXEC_PATH.test(value); + } + return true; +}; + +export const isPathValid = ({ + os, + field, + type, + value, +}: { + os: OperatingSystem; + field: ConditionEntryField | 'file.path.text'; + type: TrustedAppEntryTypes; + value: string; +}): boolean => { + if (field === ConditionEntryField.PATH || field === 'file.path.text') { + if (type === 'wildcard') { + return os === OperatingSystem.WINDOWS + ? isWindowsWildcardPathValid(value) + : isLinuxMacWildcardPathValid(value); + } + return doesPathMatchRegex({ value, os }); + } + return true; +}; + +const doesPathMatchRegex = ({ os, value }: { os: OperatingSystem; value: string }): boolean => { + if (os === OperatingSystem.WINDOWS) { + const filePathRegex = + /^[a-z]:(?:|\\\\[^<>:"'/\\|?*]+\\[^<>:"'/\\|?*]+|%\w+%|)[\\](?:[^<>:"'/\\|?*]+[\\/])*([^<>:"'/\\|?*])+$/i; + return filePathRegex.test(value); + } + return /^(\/|(\/[\w\-]+)+|\/[\w\-]+\.[\w]+|(\/[\w-]+)+\/[\w\-]+\.[\w]+)$/i.test(value); +}; + +const isWindowsWildcardPathValid = (path: string): boolean => { + const firstCharacter = path[0]; + const lastCharacter = path.slice(-1); + const trimmedValue = path.trim(); + const hasSlash = /\//.test(trimmedValue); + if (path.length === 0) { + return false; + } else if ( + hasSlash || + trimmedValue.length !== path.length || + firstCharacter === '^' || + lastCharacter === '\\' || + !hasWildcard({ path, isWindowsPath: true }) + ) { + return false; + } else { + return true; + } +}; + +const isLinuxMacWildcardPathValid = (path: string): boolean => { + const firstCharacter = path[0]; + const lastCharacter = path.slice(-1); + const trimmedValue = path.trim(); + if (path.length === 0) { + return false; + } else if ( + trimmedValue.length !== path.length || + firstCharacter !== '/' || + lastCharacter === '/' || + path.length > 1024 === true || + path.includes('//') === true || + !hasWildcard({ path, isWindowsPath: false }) + ) { + return false; + } else { + return true; + } +}; + +const hasWildcard = ({ + path, + isWindowsPath, +}: { + path: string; + isWindowsPath: boolean; +}): boolean => { + for (const pathComponent of path.split(isWindowsPath ? '\\' : '/')) { + if (/[\*|\?]+/.test(pathComponent) === true) { + return true; + } + } + return false; +}; diff --git a/packages/kbn-storybook/src/lib/decorators.tsx b/packages/kbn-storybook/src/lib/decorators.tsx index 97ee5393b4ea..24af82875424 100644 --- a/packages/kbn-storybook/src/lib/decorators.tsx +++ b/packages/kbn-storybook/src/lib/decorators.tsx @@ -11,6 +11,8 @@ import { EuiProvider } from '@elastic/eui'; import createCache from '@emotion/cache'; import type { DecoratorFn } from '@storybook/react'; +import 'core_styles'; + /** * Storybook decorator using the EUI provider. Uses the value from * `globals` provided by the Storybook theme switcher. diff --git a/packages/kbn-storybook/src/lib/default_config.ts b/packages/kbn-storybook/src/lib/default_config.ts index 6db6a6bd4643..3caf879c48cb 100644 --- a/packages/kbn-storybook/src/lib/default_config.ts +++ b/packages/kbn-storybook/src/lib/default_config.ts @@ -26,7 +26,16 @@ export const defaultConfig: StorybookConfig = { // @ts-expect-error StorybookConfig type is incomplete // https://storybook.js.org/docs/react/configure/babel#custom-configuration babel: async (options) => { - options.presets.push('@emotion/babel-preset-css-prop'); + options.presets.push([ + require.resolve('@emotion/babel-preset-css-prop'), + { + // There's an issue where emotion classnames may be duplicated, + // (e.g. `[hash]-[filename]--[local]_[filename]--[local]`) + // https://github.com/emotion-js/emotion/issues/2417 + autoLabel: 'always', + labelFormat: '[filename]--[local]', + }, + ]); return options; }, webpackFinal: (config, options) => { diff --git a/packages/kbn-storybook/src/webpack.config.ts b/packages/kbn-storybook/src/webpack.config.ts index 8d5818182b87..4da3c58688f7 100644 --- a/packages/kbn-storybook/src/webpack.config.ts +++ b/packages/kbn-storybook/src/webpack.config.ts @@ -121,6 +121,7 @@ export default ({ config: storybookConfig }: { config: Configuration }) => { mainFields: ['browser', 'main'], alias: { core_app_image_assets: resolve(REPO_ROOT, 'src/core/public/core_app/images'), + core_styles: resolve(REPO_ROOT, 'src/core/public/index.scss'), }, symlinks: false, }, diff --git a/packages/kbn-storybook/templates/index.ejs b/packages/kbn-storybook/templates/index.ejs index 8be0e58bf87d..53dc0f5e5575 100644 --- a/packages/kbn-storybook/templates/index.ejs +++ b/packages/kbn-storybook/templates/index.ejs @@ -38,7 +38,7 @@ - + <% if (typeof bodyHtmlSnippet !== 'undefined') { %> diff --git a/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts b/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts index 2485b04f4b10..4e7cff799ea7 100644 --- a/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts +++ b/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts @@ -78,7 +78,6 @@ describe('checkCompatibleTypeDescriptor', () => { ]); expect(incompatibles).toHaveLength(1); const { diff, message } = incompatibles[0]; - // eslint-disable-next-line @typescript-eslint/naming-convention expect(diff).toEqual({ '@@INDEX@@.count_2.kind': 'number' }); expect(message).toHaveLength(1); expect(message).toEqual([ diff --git a/packages/kbn-test-jest-helpers/src/enzyme_helpers.tsx b/packages/kbn-test-jest-helpers/src/enzyme_helpers.tsx index 222689d621b5..8388ed55eb51 100644 --- a/packages/kbn-test-jest-helpers/src/enzyme_helpers.tsx +++ b/packages/kbn-test-jest-helpers/src/enzyme_helpers.tsx @@ -14,7 +14,6 @@ */ import { I18nProvider, InjectedIntl, intlShape, __IntlProvider } from '@kbn/i18n-react'; -// eslint-disable-next-line import/no-extraneous-dependencies import { mount, ReactWrapper, render, shallow } from 'enzyme'; import React, { ReactElement, ValidationMap } from 'react'; import { act as reactAct } from 'react-dom/test-utils'; diff --git a/packages/kbn-test-jest-helpers/src/find_test_subject.ts b/packages/kbn-test-jest-helpers/src/find_test_subject.ts index 9d519f5197cd..ef3a744fbd99 100644 --- a/packages/kbn-test-jest-helpers/src/find_test_subject.ts +++ b/packages/kbn-test-jest-helpers/src/find_test_subject.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-extraneous-dependencies import { ReactWrapper } from 'enzyme'; type Matcher = '=' | '~=' | '|=' | '^=' | '$=' | '*='; diff --git a/packages/kbn-test-jest-helpers/src/random.ts b/packages/kbn-test-jest-helpers/src/random.ts index 9f4efccf810f..4aa8a30555e0 100644 --- a/packages/kbn-test-jest-helpers/src/random.ts +++ b/packages/kbn-test-jest-helpers/src/random.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-extraneous-dependencies import Chance from 'chance'; const chance = new Chance(); diff --git a/packages/kbn-test-jest-helpers/src/testbed/mount_component.tsx b/packages/kbn-test-jest-helpers/src/testbed/mount_component.tsx index 5c5fd3f2237d..2ac482abc0fb 100644 --- a/packages/kbn-test-jest-helpers/src/testbed/mount_component.tsx +++ b/packages/kbn-test-jest-helpers/src/testbed/mount_component.tsx @@ -8,7 +8,6 @@ import React, { ComponentType } from 'react'; import { Store } from 'redux'; -// eslint-disable-next-line import/no-extraneous-dependencies import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; diff --git a/packages/kbn-test-jest-helpers/src/testbed/testbed.ts b/packages/kbn-test-jest-helpers/src/testbed/testbed.ts index 87efb9e61b34..ddd574ace64b 100644 --- a/packages/kbn-test-jest-helpers/src/testbed/testbed.ts +++ b/packages/kbn-test-jest-helpers/src/testbed/testbed.ts @@ -7,7 +7,6 @@ */ import { Component as ReactComponent } from 'react'; -// eslint-disable-next-line import/no-extraneous-dependencies import { ComponentType, HTMLAttributes, ReactWrapper } from 'enzyme'; import { findTestSubject } from '../find_test_subject'; diff --git a/packages/kbn-test-jest-helpers/src/testbed/types.ts b/packages/kbn-test-jest-helpers/src/testbed/types.ts index ff548f3af5f5..11f8c802a975 100644 --- a/packages/kbn-test-jest-helpers/src/testbed/types.ts +++ b/packages/kbn-test-jest-helpers/src/testbed/types.ts @@ -7,7 +7,6 @@ */ import { Store } from 'redux'; -// eslint-disable-next-line import/no-extraneous-dependencies import { ReactWrapper as GenericReactWrapper } from 'enzyme'; import { LocationDescriptor } from 'history'; diff --git a/packages/kbn-test/BUILD.bazel b/packages/kbn-test/BUILD.bazel index 6732b08d8bc7..4dc8d684941f 100644 --- a/packages/kbn-test/BUILD.bazel +++ b/packages/kbn-test/BUILD.bazel @@ -53,6 +53,7 @@ RUNTIME_DEPS = [ "@npm//execa", "@npm//exit-hook", "@npm//form-data", + "@npm//get-port", "@npm//getopts", "@npm//globby", "@npm//he", @@ -90,6 +91,7 @@ TYPES_DEPS = [ "@npm//del", "@npm//exit-hook", "@npm//form-data", + "@npm//get-port", "@npm//getopts", "@npm//jest", "@npm//jest-cli", diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index 9dad901f5eb2..ba515865e532 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -9,6 +9,8 @@ // For a detailed explanation regarding each configuration property, visit: // https://jestjs.io/docs/en/configuration.html +/** @typedef {import("@jest/types").Config.InitialOptions} JestConfig */ +/** @type {JestConfig} */ module.exports = { // The directory where Jest should output its coverage files coverageDirectory: '/target/kibana-coverage/jest', @@ -128,4 +130,6 @@ module.exports = { // A custom resolver to preserve symlinks by default resolver: '/node_modules/@kbn/test/target_node/jest/setup/preserve_symlinks_resolver.js', + + watchPathIgnorePatterns: ['.*/__tmp__/.*'], }; diff --git a/packages/kbn-test/src/es/es_client_for_testing.ts b/packages/kbn-test/src/es/es_client_for_testing.ts index 084cb8d77eac..3eeccffcc218 100644 --- a/packages/kbn-test/src/es/es_client_for_testing.ts +++ b/packages/kbn-test/src/es/es_client_for_testing.ts @@ -27,6 +27,22 @@ export interface EsClientForTestingOptions extends Omit +) { + const ccsConfig = config.get('esTestCluster.ccs'); + if (!ccsConfig) { + throw new Error('FTR config is missing esTestCluster.ccs'); + } + + return createEsClientForTesting({ + esUrl: ccsConfig.remoteClusterUrl, + requestTimeout: config.get('timeouts.esRequestTimeout'), + ...overrides, + }); +} + export function createEsClientForFtrConfig( config: Config, overrides?: Omit diff --git a/packages/kbn-test/src/es/index.ts b/packages/kbn-test/src/es/index.ts index 641253acc364..bdc338894582 100644 --- a/packages/kbn-test/src/es/index.ts +++ b/packages/kbn-test/src/es/index.ts @@ -9,5 +9,9 @@ export { createTestEsCluster } from './test_es_cluster'; export type { CreateTestEsClusterOptions, EsTestCluster, ICluster } from './test_es_cluster'; export { esTestConfig } from './es_test_config'; -export { createEsClientForTesting, createEsClientForFtrConfig } from './es_client_for_testing'; +export { + createEsClientForTesting, + createEsClientForFtrConfig, + createRemoteEsClientForFtrConfig, +} from './es_client_for_testing'; export type { EsClientForTestingOptions } from './es_client_for_testing'; diff --git a/packages/kbn-test/src/es/test_es_cluster.ts b/packages/kbn-test/src/es/test_es_cluster.ts index 6e4fc2fb1462..27f29ce6995a 100644 --- a/packages/kbn-test/src/es/test_es_cluster.ts +++ b/packages/kbn-test/src/es/test_es_cluster.ts @@ -136,7 +136,15 @@ export interface CreateTestEsClusterOptions { * } */ port?: number; + /** + * Should this ES cluster use SSL? + */ ssl?: boolean; + /** + * Explicit transport port for a single node to run on, or a string port range to use eg. '9300-9400' + * defaults to the transport port from `packages/kbn-test/src/es/es_test_config.ts` + */ + transportPort?: number | string; } export function createTestEsCluster< @@ -155,13 +163,14 @@ export function createTestEsCluster< esJavaOpts, clusterName: customClusterName = 'es-test-cluster', ssl, + transportPort, } = options; const clusterName = `${CI_PARALLEL_PROCESS_PREFIX}${customClusterName}`; const defaultEsArgs = [ `cluster.name=${clusterName}`, - `transport.port=${esTestConfig.getTransportPort()}`, + `transport.port=${transportPort ?? esTestConfig.getTransportPort()}`, // For multi-node clusters, we make all nodes master-eligible by default. ...(nodes.length > 1 ? ['discovery.type=zen', `cluster.initial_master_nodes=${nodes.map((n) => n.name).join(',')}`] 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 dec381fb04b5..96ebcd79c4e4 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 @@ -31,6 +31,7 @@ export interface Test { file?: string; parent?: Suite; isPassed: () => boolean; + pending?: boolean; } export interface Runner extends EventEmitter { 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 53ec36dfbe55..c8caad3049f1 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 @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import Path from 'path'; +import { writeFileSync, mkdirSync } from 'fs'; +import Path, { dirname } from 'path'; import { ToolingLog } from '@kbn/dev-utils'; import { REPO_ROOT } from '@kbn/utils'; @@ -98,6 +99,15 @@ export class FunctionalTestRunner { reporterOptions ); + // there's a bug in mocha's dry run, see https://github.com/mochajs/mocha/issues/4838 + // until we can update to a mocha version where this is fixed, we won't actually + // execute the mocha dry run but simulate it by reading the suites and tests of + // the mocha object and writing a report file with similar structure to the json report + // (just leave out some execution details like timing, retry and erros) + if (config.get('mochaOpts.dryRun')) { + return this.simulateMochaDryRun(mocha); + } + await this.lifecycle.beforeTests.trigger(mocha.suite); this.log.info('Starting tests'); @@ -145,8 +155,9 @@ export class FunctionalTestRunner { readProviderSpec(type, providers).map((p) => ({ ...p, fn: skip.includes(p.name) - ? (...args: unknown[]) => { - const result = p.fn(...args); + ? (ctx: any) => { + const result = ProviderCollection.callProviderFn(p.fn, ctx); + if ('then' in result) { throw new Error( `Provider [${p.name}] returns a promise so it can't loaded during test analysis` @@ -244,4 +255,62 @@ export class FunctionalTestRunner { this.closed = true; await this.lifecycle.cleanup.trigger(); } + + simulateMochaDryRun(mocha: any) { + interface TestEntry { + file: string; + title: string; + fullTitle: string; + } + + const getFullTitle = (node: Test | Suite): string => { + const parentTitle = node.parent && getFullTitle(node.parent); + return parentTitle ? `${parentTitle} ${node.title}` : node.title; + }; + + let suiteCount = 0; + const passes: TestEntry[] = []; + const pending: TestEntry[] = []; + + const collectTests = (suite: Suite) => { + for (const subSuite of suite.suites) { + suiteCount++; + for (const test of subSuite.tests) { + const testEntry = { + title: test.title, + fullTitle: getFullTitle(test), + file: test.file || '', + }; + if (test.pending) { + pending.push(testEntry); + } else { + passes.push(testEntry); + } + } + collectTests(subSuite); + } + }; + + collectTests(mocha.suite); + + const reportData = { + stats: { + suites: suiteCount, + tests: passes.length + pending.length, + passes: passes.length, + pending: pending.length, + failures: 0, + }, + tests: [...passes, ...pending], + passes, + pending, + failures: [], + }; + + const reportPath = mocha.options.reporterOptions.output; + mkdirSync(dirname(reportPath), { recursive: true }); + writeFileSync(reportPath, JSON.stringify(reportData, null, 2), 'utf8'); + + return 0; + } } diff --git a/packages/kbn-test/src/functional_test_runner/index.ts b/packages/kbn-test/src/functional_test_runner/index.ts index e67e72fd5801..b5d55c28ee9b 100644 --- a/packages/kbn-test/src/functional_test_runner/index.ts +++ b/packages/kbn-test/src/functional_test_runner/index.ts @@ -7,7 +7,14 @@ */ export { FunctionalTestRunner } from './functional_test_runner'; -export { readConfigFile, Config, EsVersion, Lifecycle, LifecyclePhase } from './lib'; +export { + readConfigFile, + Config, + createAsyncInstance, + EsVersion, + Lifecycle, + LifecyclePhase, +} from './lib'; export type { ScreenshotRecord } from './lib'; export { runFtrCli } from './cli'; export * from './lib/docker_servers'; diff --git a/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js index 6e25e4c073ab..417fc8e10aec 100644 --- a/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js @@ -37,5 +37,10 @@ export default function () { captureLogOutput: false, sendToCiStats: false, }, + servers: { + elasticsearch: { + port: 1234, + }, + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js index 4c87b53b5753..067528c4ae12 100644 --- a/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js @@ -13,4 +13,9 @@ export default () => ({ mochaReporter: { sendToCiStats: false, }, + servers: { + elasticsearch: { + port: 1234, + }, + }, }); diff --git a/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js b/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js index 0d986a1602e1..47ae51ca62f1 100644 --- a/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js @@ -61,7 +61,7 @@ describe('failure hooks', function () { expect(tests).toHaveLength(0); } catch (error) { - console.error('full log output', linesCopy.join('\n')); + error.message += `\n\nfull log output:${linesCopy.join('\n')}`; throw error; } }); diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js index 123bc8b9bc20..afcad01c4ab9 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js @@ -9,5 +9,10 @@ export default function () { return { testFiles: ['config.1'], + servers: { + elasticsearch: { + port: 1234, + }, + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js index 2dd4c96186fc..692a3de78672 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js @@ -11,5 +11,10 @@ export default async function ({ readConfigFile }) { return { testFiles: [...config1.get('testFiles'), 'config.2'], + servers: { + elasticsearch: { + port: 1234, + }, + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/config.test.ts b/packages/kbn-test/src/functional_test_runner/lib/config/config.test.ts index 88c1fd99f001..d551e7a884b4 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/config.test.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/config.test.ts @@ -15,6 +15,11 @@ describe('Config', () => { services: { foo: () => 42, }, + servers: { + elasticsearch: { + port: 1234, + }, + }, }, primary: true, path: process.cwd(), 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 f65cb3c41f42..cf1afbb810c7 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 @@ -17,19 +17,33 @@ const ID_PATTERN = /^[a-zA-Z0-9_]+$/; // it will search both --inspect and --inspect-brk const INSPECTING = !!process.execArgv.find((arg) => arg.includes('--inspect')); -const urlPartsSchema = () => +const maybeRequireKeys = (keys: string[], schemas: Record) => { + if (!keys.length) { + return schemas; + } + + const withRequires: Record = {}; + for (const [key, schema] of Object.entries(schemas)) { + withRequires[key] = keys.includes(key) ? schema.required() : schema; + } + return withRequires; +}; + +const urlPartsSchema = ({ requiredKeys }: { requiredKeys?: string[] } = {}) => Joi.object() - .keys({ - protocol: Joi.string().valid('http', 'https').default('http'), - hostname: Joi.string().hostname().default('localhost'), - port: Joi.number(), - auth: Joi.string().regex(/^[^:]+:.+$/, 'username and password separated by a colon'), - username: Joi.string(), - password: Joi.string(), - pathname: Joi.string().regex(/^\//, 'start with a /'), - hash: Joi.string().regex(/^\//, 'start with a /'), - certificateAuthorities: Joi.array().items(Joi.binary()).optional(), - }) + .keys( + maybeRequireKeys(requiredKeys ?? [], { + protocol: Joi.string().valid('http', 'https').default('http'), + hostname: Joi.string().hostname().default('localhost'), + port: Joi.number(), + auth: Joi.string().regex(/^[^:]+:.+$/, 'username and password separated by a colon'), + username: Joi.string(), + password: Joi.string(), + pathname: Joi.string().regex(/^\//, 'start with a /'), + hash: Joi.string().regex(/^\//, 'start with a /'), + certificateAuthorities: Joi.array().items(Joi.binary()).optional(), + }) + ) .default(); const appUrlPartsSchema = () => @@ -170,18 +184,25 @@ export const schema = Joi.object() servers: Joi.object() .keys({ kibana: urlPartsSchema(), - elasticsearch: urlPartsSchema(), + elasticsearch: urlPartsSchema({ + requiredKeys: ['port'], + }), }) .default(), esTestCluster: Joi.object() .keys({ - license: Joi.string().default('basic'), + license: Joi.valid('basic', 'trial', 'gold').default('basic'), from: Joi.string().default('snapshot'), - serverArgs: Joi.array(), + serverArgs: Joi.array().items(Joi.string()), esJavaOpts: Joi.string(), dataArchive: Joi.string(), ssl: Joi.boolean().default(false), + ccs: Joi.object().keys({ + remoteClusterUrl: Joi.string().uri({ + scheme: /https?/, + }), + }), }) .default(), @@ -274,6 +295,7 @@ export const schema = Joi.object() security: Joi.object() .keys({ roles: Joi.object().default(), + remoteEsRoles: Joi.object(), defaultRoles: Joi.array() .items(Joi.string()) .when('$primary', { diff --git a/packages/kbn-test/src/functional_test_runner/lib/index.ts b/packages/kbn-test/src/functional_test_runner/lib/index.ts index e387fd156fe8..077a62e8e74e 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/index.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/index.ts @@ -9,7 +9,7 @@ export { Lifecycle } from './lifecycle'; export { LifecyclePhase } from './lifecycle_phase'; export { readConfigFile, Config } from './config'; -export { readProviderSpec, ProviderCollection } from './providers'; +export * from './providers'; // @internal export { runTests, setupMocha } from './mocha'; export * from './test_metadata'; diff --git a/packages/kbn-test/src/functional_test_runner/lib/providers/index.ts b/packages/kbn-test/src/functional_test_runner/lib/providers/index.ts index 10aeca19ba45..578e41ca8e82 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/providers/index.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/providers/index.ts @@ -8,4 +8,5 @@ export { ProviderCollection } from './provider_collection'; export { readProviderSpec } from './read_provider_spec'; +export { createAsyncInstance } from './async_instance'; export type { Provider } from './read_provider_spec'; diff --git a/packages/kbn-test/src/functional_test_runner/lib/providers/provider_collection.ts b/packages/kbn-test/src/functional_test_runner/lib/providers/provider_collection.ts index 69a5973168f0..97d7d43417f9 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/providers/provider_collection.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/providers/provider_collection.ts @@ -15,6 +15,15 @@ import { createVerboseInstance } from './verbose_instance'; import { GenericFtrService } from '../../public_types'; export class ProviderCollection { + static callProviderFn(providerFn: any, ctx: any) { + if (providerFn.prototype instanceof GenericFtrService) { + const Constructor = providerFn as any as new (ctx: any) => any; + return new Constructor(ctx); + } + + return providerFn(ctx); + } + private readonly instances = new Map(); constructor(private readonly log: ToolingLog, private readonly providers: Providers) {} @@ -59,19 +68,12 @@ export class ProviderCollection { } public invokeProviderFn(provider: (args: any) => any) { - const ctx = { + return ProviderCollection.callProviderFn(provider, { getService: this.getService, hasService: this.hasService, getPageObject: this.getPageObject, getPageObjects: this.getPageObjects, - }; - - if (provider.prototype instanceof GenericFtrService) { - const Constructor = provider as any as new (ctx: any) => any; - return new Constructor(ctx); - } - - return provider(ctx); + }); } private findProvider(type: string, name: string) { 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 68e7a4992fcf..ba314e8325a6 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts @@ -8,6 +8,7 @@ import { resolve } from 'path'; import type { ToolingLog } from '@kbn/dev-utils'; +import getPort from 'get-port'; import { KIBANA_ROOT } from './paths'; import type { Config } from '../../functional_test_runner/'; import { createTestEsCluster } from '../../es'; @@ -15,32 +16,102 @@ import { createTestEsCluster } from '../../es'; interface RunElasticsearchOptions { log: ToolingLog; esFrom?: string; + config: Config; +} + +interface CcsConfig { + remoteClusterUrl: string; } -export async function runElasticsearch({ + +type EsConfig = ReturnType; + +function getEsConfig({ config, - options, -}: { - config: Config; - options: RunElasticsearchOptions; -}) { - const { log, esFrom } = options; - const ssl = config.get('esTestCluster.ssl'); - const license = config.get('esTestCluster.license'); - const esArgs = config.get('esTestCluster.serverArgs'); - const esJavaOpts = config.get('esTestCluster.esJavaOpts'); + esFrom = config.get('esTestCluster.from'), +}: RunElasticsearchOptions) { + const ssl = !!config.get('esTestCluster.ssl'); + const license: 'basic' | 'trial' | 'gold' = config.get('esTestCluster.license'); + const esArgs: string[] = config.get('esTestCluster.serverArgs') ?? []; + const esJavaOpts: string | undefined = config.get('esTestCluster.esJavaOpts'); const isSecurityEnabled = esArgs.includes('xpack.security.enabled=true'); - const cluster = createTestEsCluster({ - port: config.get('servers.elasticsearch.port'), - password: isSecurityEnabled ? 'changeme' : config.get('servers.elasticsearch.password'), + const port: number | undefined = config.get('servers.elasticsearch.port'); + const ccsConfig: CcsConfig | undefined = config.get('esTestCluster.ccs'); + + const password: string | undefined = isSecurityEnabled + ? 'changeme' + : config.get('servers.elasticsearch.password'); + + const dataArchive: string | undefined = config.get('esTestCluster.dataArchive'); + + return { + ssl, license, - log, - basePath: resolve(KIBANA_ROOT, '.es'), - esFrom: esFrom || config.get('esTestCluster.from'), - dataArchive: config.get('esTestCluster.dataArchive'), esArgs, esJavaOpts, - ssl, + isSecurityEnabled, + esFrom, + port, + password, + dataArchive, + ccsConfig, + }; +} + +export async function runElasticsearch( + options: RunElasticsearchOptions +): Promise<() => Promise> { + const { log } = options; + const config = getEsConfig(options); + + if (!config.ccsConfig) { + const node = await startEsNode(log, 'ftr', config); + return async () => { + await node.cleanup(); + }; + } + + const remotePort = await getPort(); + const remoteNode = await startEsNode(log, 'ftr-remote', { + ...config, + port: parseInt(new URL(config.ccsConfig.remoteClusterUrl).port, 10), + transportPort: remotePort, + }); + + const localNode = await startEsNode(log, 'ftr-local', { + ...config, + esArgs: [...config.esArgs, `cluster.remote.ftr-remote.seeds=localhost:${remotePort}`], + }); + + return async () => { + await localNode.cleanup(); + await remoteNode.cleanup(); + }; +} + +async function startEsNode( + log: ToolingLog, + name: string, + config: EsConfig & { transportPort?: number } +) { + const cluster = createTestEsCluster({ + clusterName: `cluster-${name}`, + esArgs: config.esArgs, + esFrom: config.esFrom, + esJavaOpts: config.esJavaOpts, + license: config.license, + password: config.password, + port: config.port, + ssl: config.ssl, + log, + basePath: resolve(KIBANA_ROOT, '.es'), + nodes: [ + { + name, + dataArchive: config.dataArchive, + }, + ], + transportPort: config.transportPort, }); await cluster.start(); diff --git a/packages/kbn-test/src/functional_tests/tasks.ts b/packages/kbn-test/src/functional_tests/tasks.ts index 5906193ca145..b1213ceef290 100644 --- a/packages/kbn-test/src/functional_tests/tasks.ts +++ b/packages/kbn-test/src/functional_tests/tasks.ts @@ -108,10 +108,10 @@ export async function runTests(options: RunTestsParams) { await withProcRunner(log, async (procs) => { const config = await readConfigFile(log, options.esVersion, configPath); - let es; + let shutdownEs; try { if (process.env.TEST_ES_DISABLE_STARTUP !== 'true') { - es = await runElasticsearch({ config, options: { ...options, log } }); + shutdownEs = await runElasticsearch({ ...options, log, config }); } await runKibanaServer({ procs, config, options }); await runFtr({ configPath, options: { ...options, log } }); @@ -125,8 +125,8 @@ export async function runTests(options: RunTestsParams) { await procs.stop('kibana'); } finally { - if (es) { - await es.cleanup(); + if (shutdownEs) { + await shutdownEs(); } } } @@ -166,7 +166,7 @@ export async function startServers({ ...options }: StartServerOptions) { await withProcRunner(log, async (procs) => { const config = await readConfigFile(log, options.esVersion, options.config); - const es = await runElasticsearch({ config, options: opts }); + const shutdownEs = await runElasticsearch({ ...opts, config }); await runKibanaServer({ procs, config, @@ -190,7 +190,7 @@ export async function startServers({ ...options }: StartServerOptions) { log.success(makeSuccessMessage(options)); await procs.waitForAllToStop(); - await es.cleanup(); + await shutdownEs(); }); } diff --git a/packages/kbn-test/src/index.ts b/packages/kbn-test/src/index.ts index 26d40f70edb7..c9f0e67c558f 100644 --- a/packages/kbn-test/src/index.ts +++ b/packages/kbn-test/src/index.ts @@ -36,6 +36,7 @@ export { createTestEsCluster, createEsClientForTesting, createEsClientForFtrConfig, + createRemoteEsClientForFtrConfig, } from './es'; export { diff --git a/packages/kbn-test/src/jest/run.ts b/packages/kbn-test/src/jest/run.ts index 26fa12497357..b8617317d6d9 100644 --- a/packages/kbn-test/src/jest/run.ts +++ b/packages/kbn-test/src/jest/run.ts @@ -22,6 +22,7 @@ import { existsSync } from 'fs'; import { run } from 'jest'; import { buildArgv } from 'jest-cli/build/cli'; import { ToolingLog, getTimeReporter } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { map } from 'lodash'; // yarn test:jest src/core/server/saved_objects @@ -67,23 +68,47 @@ export function runJest(configName = 'jest.config.js') { log.verbose('commonTestFiles:', commonTestFiles); let configPath; - let devConfigPath; // sets the working directory to the cwd or the common // base directory of the provided test files let wd = testFilesProvided ? commonTestFiles : cwd; - - devConfigPath = resolve(wd, devConfigName); - configPath = resolve(wd, configName); - - while (!existsSync(configPath) && !existsSync(devConfigPath)) { - wd = resolve(wd, '..'); - devConfigPath = resolve(wd, devConfigName); - configPath = resolve(wd, configName); + while (true) { + const dev = resolve(wd, devConfigName); + if (existsSync(dev)) { + configPath = dev; + break; + } + + const actual = resolve(wd, configName); + if (existsSync(actual)) { + configPath = actual; + break; + } + + if (wd === REPO_ROOT) { + break; + } + + const parent = resolve(wd, '..'); + if (parent === wd) { + break; + } + + wd = parent; } - if (existsSync(devConfigPath)) { - configPath = devConfigPath; + if (!configPath) { + if (testFilesProvided) { + log.error( + `unable to find a ${configName} file in ${commonTestFiles} or any parent directory up to the root of the repo. This CLI can only run Jest tests which resolve to a single ${configName} file, and that file must exist in a parent directory of all the paths you pass.` + ); + } else { + log.error( + `we no longer ship a root config file so you either need to pass a path to a test file, a folder where tests can be found, or a --config argument pointing to one of the many ${configName} files in the repository` + ); + } + + process.exit(1); } log.verbose(`no config provided, found ${configPath}`); diff --git a/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts index 717f214211d9..02861fcb27fd 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts @@ -219,6 +219,25 @@ export class KbnClientSavedObjects { this.log.success('deleted', deleted, 'objects'); } + public async cleanStandardList(options?: { space?: string }) { + // add types here + const types = [ + 'search', + 'index-pattern', + 'visualization', + 'dashboard', + 'lens', + 'map', + 'graph-workspace', + 'query', + 'tag', + 'url', + 'canvas-workpad', + ]; + const newOptions = { types, space: options?.space }; + await this.clean(newOptions); + } + public async bulkDelete(options: DeleteObjectsOptions) { let deleted = 0; let missing = 0; diff --git a/packages/kbn-type-summarizer/BUILD.bazel b/packages/kbn-type-summarizer/BUILD.bazel new file mode 100644 index 000000000000..ec0df11bc376 --- /dev/null +++ b/packages/kbn-type-summarizer/BUILD.bazel @@ -0,0 +1,134 @@ +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") +load("@build_bazel_rules_nodejs//internal/node:node.bzl", "nodejs_binary") +load("@build_bazel_rules_nodejs//:index.bzl", "directory_file_path") + +PKG_BASE_NAME = "kbn-type-summarizer" +PKG_REQUIRE_NAME = "@kbn/type-summarizer" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ] +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +RUNTIME_DEPS = [ + "@npm//@babel/runtime", + "@npm//@microsoft/api-extractor", + "@npm//source-map-support", + "@npm//chalk", + "@npm//getopts", + "@npm//is-path-inside", + "@npm//normalize-path", + "@npm//source-map", + "@npm//tslib", +] + +TYPES_DEPS = [ + "@npm//@microsoft/api-extractor", + "@npm//@types/jest", + "@npm//@types/node", + "@npm//@types/normalize-path", + "@npm//getopts", + "@npm//is-path-inside", + "@npm//normalize-path", + "@npm//source-map", + "@npm//strip-ansi", + "@npm//tslib", +] + +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, + declaration_map = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = "src", + tsconfig = ":tsconfig", +) + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +directory_file_path( + name = "bazel-cli-path", + directory = ":target_node", + path = "bazel_cli.js", +) + +nodejs_binary( + name = "bazel-cli", + data = [ + ":%s" % PKG_BASE_NAME + ], + entry_point = ":bazel-cli-path", + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ], +) + +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-type-summarizer/README.md b/packages/kbn-type-summarizer/README.md new file mode 100644 index 000000000000..fdd58886a0a6 --- /dev/null +++ b/packages/kbn-type-summarizer/README.md @@ -0,0 +1,17 @@ +# @kbn/type-summarizer + +Consume the .d.ts files for a package, produced by `tsc`, and generate a single `.d.ts` file of the public types along with a source map that points back to the original source. + +## You mean like API Extractor? + +Yeah, except with source map support and without all the legacy features and other features we disable to generate our current type summaries. + +I first attempted to implement this in api-extractor but I (@spalger) hit a wall when dealing with the `Span` class. This class handles all the text output which ends up becoming source code, and I wasn't able to find a way to associate specific spans with source locations without getting 12 headaches. Instead I decided to try implementing this from scratch, reducing our reliance on the api-extractor project and putting us in control of how we generate type summaries. + +This package is missing some critical features for wider adoption, but rather than build the entire product in a branch I decided to implement support for a small number of TS features and put this to use in the `@kbn/crypto` module ASAP. + +The plan is to expand to other packages in the Kibana repo, adding support for language features as we go. + +## Something isn't working and I'm blocked! + +If there's a problem with the implmentation blocking another team at any point we can move the package back to using api-extractor by removing the package from the `TYPE_SUMMARIZER_PACKAGES` list at the top of [packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts](./src/lib/bazel_cli_config.ts). \ No newline at end of file diff --git a/packages/kbn-type-summarizer/jest.config.js b/packages/kbn-type-summarizer/jest.config.js new file mode 100644 index 000000000000..84b10626e82c --- /dev/null +++ b/packages/kbn-type-summarizer/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 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. + */ + +/** @typedef {import("@jest/types").Config.InitialOptions} JestConfig */ +/** @type {JestConfig} */ +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-type-summarizer'], +}; diff --git a/packages/kbn-type-summarizer/jest.integration.config.js b/packages/kbn-type-summarizer/jest.integration.config.js new file mode 100644 index 000000000000..ae7b80073b93 --- /dev/null +++ b/packages/kbn-type-summarizer/jest.integration.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 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. + */ + +/** @typedef {import("@jest/types").Config.InitialOptions} JestConfig */ +/** @type {JestConfig} */ +module.exports = { + preset: '@kbn/test/jest_integration_node', + rootDir: '../..', + roots: ['/packages/kbn-type-summarizer'], +}; diff --git a/packages/kbn-type-summarizer/package.json b/packages/kbn-type-summarizer/package.json new file mode 100644 index 000000000000..df9c5564d561 --- /dev/null +++ b/packages/kbn-type-summarizer/package.json @@ -0,0 +1,10 @@ +{ + "name": "@kbn/type-summarizer", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "main": "./target_node/index.js", + "private": true, + "kibana": { + "devOnly": true + } +} diff --git a/packages/kbn-type-summarizer/src/bazel_cli.ts b/packages/kbn-type-summarizer/src/bazel_cli.ts new file mode 100644 index 000000000000..af6b13ebfc09 --- /dev/null +++ b/packages/kbn-type-summarizer/src/bazel_cli.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 Fsp from 'fs/promises'; +import Path from 'path'; + +import { run } from './lib/run'; +import { parseBazelCliConfig } from './lib/bazel_cli_config'; + +import { summarizePackage } from './summarize_package'; +import { runApiExtractor } from './run_api_extractor'; + +const HELP = ` +Script called from bazel to create the summarized version of a package. When called by Bazel +config is passed as a JSON encoded object. + +When called via "node scripts/build_type_summarizer_output" pass a path to a package and that +package's types will be read from node_modules and written to data/type-summarizer-output. + +`; + +run( + async ({ argv, log }) => { + log.debug('cwd:', process.cwd()); + log.debug('argv', process.argv); + + const config = parseBazelCliConfig(argv); + await Fsp.mkdir(config.outputDir, { recursive: true }); + + // generate pkg json output + await Fsp.writeFile( + Path.resolve(config.outputDir, 'package.json'), + JSON.stringify( + { + name: `@types/${config.packageName.replaceAll('@', '').replaceAll('/', '__')}`, + description: 'Generated by @kbn/type-summarizer', + types: './index.d.ts', + private: true, + license: 'MIT', + version: '1.1.0', + }, + null, + 2 + ) + ); + + if (config.use === 'type-summarizer') { + await summarizePackage(log, { + dtsDir: Path.dirname(config.inputPath), + inputPaths: [config.inputPath], + outputDir: config.outputDir, + tsconfigPath: config.tsconfigPath, + repoRelativePackageDir: config.repoRelativePackageDir, + }); + log.success('type summary created for', config.repoRelativePackageDir); + } else { + await runApiExtractor( + config.tsconfigPath, + config.inputPath, + Path.resolve(config.outputDir, 'index.d.ts') + ); + } + }, + { + helpText: HELP, + defaultLogLevel: 'quiet', + } +); diff --git a/packages/kbn-type-summarizer/src/index.ts b/packages/kbn-type-summarizer/src/index.ts new file mode 100644 index 000000000000..1667ab5cd8d2 --- /dev/null +++ b/packages/kbn-type-summarizer/src/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 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 { Logger } from './lib/log'; +export type { SummarizePacakgeOptions } from './summarize_package'; +export { summarizePackage } from './summarize_package'; diff --git a/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts b/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts new file mode 100644 index 000000000000..da82aef8f7d7 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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'; + +import { CliError } from './cli_error'; +import { parseCliFlags } from './cli_flags'; +import * as Path from './path'; + +const TYPE_SUMMARIZER_PACKAGES = ['@kbn/type-summarizer', '@kbn/crypto', '@kbn/generate']; + +const isString = (i: any): i is string => typeof i === 'string' && i.length > 0; + +interface BazelCliConfig { + packageName: string; + outputDir: string; + tsconfigPath: string; + inputPath: string; + repoRelativePackageDir: string; + use: 'api-extractor' | 'type-summarizer'; +} + +function isKibanaRepo(dir: string) { + try { + const json = Fs.readFileSync(Path.join(dir, 'package.json'), 'utf8'); + const parsed = JSON.parse(json); + return parsed.name === 'kibana'; + } catch { + return false; + } +} + +function findRepoRoot() { + const start = Path.resolve(__dirname); + let dir = start; + while (true) { + if (isKibanaRepo(dir)) { + return dir; + } + + // this is not the kibana directory, try moving up a directory + const parent = Path.join(dir, '..'); + if (parent === dir) { + throw new Error( + `unable to find Kibana's package.json file when traversing up from [${start}]` + ); + } + + dir = parent; + } +} + +export function parseBazelCliFlags(argv: string[]): BazelCliConfig { + const { rawFlags, unknownFlags } = parseCliFlags(argv, { + string: ['use'], + default: { + use: 'api-extractor', + }, + }); + + if (unknownFlags.length) { + throw new CliError(`Unknown flags: ${unknownFlags.join(', ')}`, { + showHelp: true, + }); + } + + const repoRoot = findRepoRoot(); + + const [relativePackagePath, ...extraPositional] = rawFlags._; + if (typeof relativePackagePath !== 'string') { + throw new CliError(`missing path to package as first positional argument`, { showHelp: true }); + } + if (extraPositional.length) { + throw new CliError(`extra positional arguments`, { showHelp: true }); + } + + const use = rawFlags.use; + if (use !== 'api-extractor' && use !== 'type-summarizer') { + throw new CliError(`invalid --use flag, expected "api-extractor" or "type-summarizer"`); + } + + const packageDir = Path.resolve(relativePackagePath); + const packageName: string = JSON.parse( + Fs.readFileSync(Path.join(packageDir, 'package.json'), 'utf8') + ).name; + const repoRelativePackageDir = Path.relative(repoRoot, packageDir); + + return { + use, + packageName, + tsconfigPath: Path.join(repoRoot, repoRelativePackageDir, 'tsconfig.json'), + inputPath: Path.join(repoRoot, 'node_modules', packageName, 'target_types/index.d.ts'), + repoRelativePackageDir, + outputDir: Path.join(repoRoot, 'data/type-summarizer-output', use), + }; +} + +function parseJsonFromCli(json: string) { + try { + return JSON.parse(json); + } catch (error) { + // TODO: This is to handle a bug in Bazel which escapes `"` in .bat arguments incorrectly, replacing them with `\` + if ( + error.message === 'Unexpected token \\ in JSON at position 1' && + process.platform === 'win32' + ) { + const unescapedJson = json.replaceAll('\\', '"'); + try { + return JSON.parse(unescapedJson); + } catch (e) { + throw new CliError( + `unable to parse first positional argument as JSON: "${e.message}"\n unescaped value: ${unescapedJson}\n raw value: ${json}` + ); + } + } + + throw new CliError( + `unable to parse first positional argument as JSON: "${error.message}"\n value: ${json}` + ); + } +} + +export function parseBazelCliJson(json: string): BazelCliConfig { + const config = parseJsonFromCli(json); + if (typeof config !== 'object' || config === null) { + throw new CliError('config JSON must be an object'); + } + + const packageName = config.packageName; + if (!isString(packageName)) { + throw new CliError('packageName config must be a non-empty string'); + } + + const outputDir = config.outputDir; + if (!isString(outputDir)) { + throw new CliError('outputDir config must be a non-empty string'); + } + if (Path.isAbsolute(outputDir)) { + throw new CliError(`outputDir [${outputDir}] must be a relative path`); + } + + const tsconfigPath = config.tsconfigPath; + if (!isString(tsconfigPath)) { + throw new CliError('tsconfigPath config must be a non-empty string'); + } + if (Path.isAbsolute(tsconfigPath)) { + throw new CliError(`tsconfigPath [${tsconfigPath}] must be a relative path`); + } + + const inputPath = config.inputPath; + if (!isString(inputPath)) { + throw new CliError('inputPath config must be a non-empty string'); + } + if (Path.isAbsolute(inputPath)) { + throw new CliError(`inputPath [${inputPath}] must be a relative path`); + } + + const buildFilePath = config.buildFilePath; + if (!isString(buildFilePath)) { + throw new CliError('buildFilePath config must be a non-empty string'); + } + if (Path.isAbsolute(buildFilePath)) { + throw new CliError(`buildFilePath [${buildFilePath}] must be a relative path`); + } + + return { + packageName, + outputDir: Path.resolve(outputDir), + tsconfigPath: Path.resolve(tsconfigPath), + inputPath: Path.resolve(inputPath), + repoRelativePackageDir: Path.dirname(buildFilePath), + use: TYPE_SUMMARIZER_PACKAGES.includes(packageName) ? 'type-summarizer' : 'api-extractor', + }; +} + +export function parseBazelCliConfig(argv: string[]) { + if (typeof argv[0] === 'string' && argv[0].startsWith('{')) { + return parseBazelCliJson(argv[0]); + } + return parseBazelCliFlags(argv); +} diff --git a/packages/kbn-type-summarizer/src/lib/cli_error.ts b/packages/kbn-type-summarizer/src/lib/cli_error.ts new file mode 100644 index 000000000000..143d790612f6 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/cli_error.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 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 interface CliErrorOptions { + exitCode?: number; + showHelp?: boolean; +} + +export class CliError extends Error { + public readonly exitCode: number; + public readonly showHelp: boolean; + + constructor(message: string, options: CliErrorOptions = {}) { + super(message); + + this.exitCode = options.exitCode ?? 1; + this.showHelp = options.showHelp ?? false; + } +} diff --git a/packages/kbn-type-summarizer/src/lib/cli_flags.ts b/packages/kbn-type-summarizer/src/lib/cli_flags.ts new file mode 100644 index 000000000000..0f616dca873b --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/cli_flags.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 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 getopts from 'getopts'; + +interface ParseCliFlagsOptions { + alias?: Record; + boolean?: string[]; + string?: string[]; + default?: Record; +} + +export function parseCliFlags(argv = process.argv.slice(2), options: ParseCliFlagsOptions = {}) { + const unknownFlags: string[] = []; + + const string = options.string ?? []; + const boolean = ['help', 'verbose', 'debug', 'quiet', 'silent', ...(options.boolean ?? [])]; + const alias = { + v: 'verbose', + d: 'debug', + h: 'help', + ...options.alias, + }; + + const rawFlags = getopts(argv, { + alias, + boolean, + string, + default: options.default, + unknown(name) { + unknownFlags.push(name); + return false; + }, + }); + + return { + rawFlags, + unknownFlags, + }; +} diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts b/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts new file mode 100644 index 000000000000..f8f4e131f838 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.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 * as ts from 'typescript'; +import { ValueNode, ExportFromDeclaration } from '../ts_nodes'; +import { ResultValue } from './result_value'; +import { ImportedSymbols } from './imported_symbols'; +import { Reference, ReferenceKey } from './reference'; +import { SourceMapper } from '../source_mapper'; + +export type CollectorResult = Reference | ImportedSymbols | ResultValue; + +export class CollectorResults { + imports: ImportedSymbols[] = []; + importsByPath = new Map(); + + nodes: ResultValue[] = []; + nodesByAst = new Map(); + + constructor(private readonly sourceMapper: SourceMapper) {} + + addNode(exported: boolean, node: ValueNode) { + const existing = this.nodesByAst.get(node); + if (existing) { + existing.exported = existing.exported || exported; + return; + } + + const result = new ResultValue(exported, node); + this.nodesByAst.set(node, result); + this.nodes.push(result); + } + + ensureExported(node: ValueNode) { + this.addNode(true, node); + } + + addImport( + exported: boolean, + node: ts.ImportDeclaration | ExportFromDeclaration, + symbol: ts.Symbol + ) { + const literal = node.moduleSpecifier; + if (!ts.isStringLiteral(literal)) { + throw new Error('import statement with non string literal module identifier'); + } + + const existing = this.importsByPath.get(literal.text); + if (existing) { + existing.symbols.push(symbol); + return; + } + + const result = new ImportedSymbols(exported, node, [symbol]); + this.importsByPath.set(literal.text, result); + this.imports.push(result); + } + + private getReferencesFromNodes() { + // collect the references from all the sourcefiles of all the resulting nodes + const sourceFiles = new Set(); + for (const { node } of this.nodes) { + sourceFiles.add(this.sourceMapper.getSourceFile(node)); + } + + const references: Record> = { + lib: new Set(), + types: new Set(), + }; + for (const sourceFile of sourceFiles) { + for (const ref of sourceFile.libReferenceDirectives) { + references.lib.add(ref.fileName); + } + for (const ref of sourceFile.typeReferenceDirectives) { + references.types.add(ref.fileName); + } + } + + return [ + ...Array.from(references.lib).map((name) => new Reference('lib', name)), + ...Array.from(references.types).map((name) => new Reference('types', name)), + ]; + } + + getAll(): CollectorResult[] { + return [...this.getReferencesFromNodes(), ...this.imports, ...this.nodes]; + } +} diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts b/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts new file mode 100644 index 000000000000..3f46ceda70e1 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts @@ -0,0 +1,209 @@ +/* + * Copyright 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 * as ts from 'typescript'; + +import { Logger } from '../log'; +import { + assertExportedValueNode, + isExportedValueNode, + DecSymbol, + assertDecSymbol, + toDecSymbol, + ExportFromDeclaration, + isExportFromDeclaration, + isAliasSymbol, +} from '../ts_nodes'; + +import { ExportInfo } from '../export_info'; +import { CollectorResults } from './collector_results'; +import { SourceMapper } from '../source_mapper'; +import { isNodeModule } from '../is_node_module'; + +interface ResolvedNmImport { + type: 'import'; + node: ts.ImportDeclaration | ExportFromDeclaration; + targetPath: string; +} +interface ResolvedSymbol { + type: 'symbol'; + symbol: DecSymbol; +} + +export class ExportCollector { + constructor( + private readonly log: Logger, + private readonly typeChecker: ts.TypeChecker, + private readonly sourceFile: ts.SourceFile, + private readonly dtsDir: string, + private readonly sourceMapper: SourceMapper + ) {} + + private getParentImport( + symbol: DecSymbol + ): ts.ImportDeclaration | ExportFromDeclaration | undefined { + for (const node of symbol.declarations) { + let cursor: ts.Node = node; + while (true) { + if (ts.isImportDeclaration(cursor) || isExportFromDeclaration(cursor)) { + return cursor; + } + + if (ts.isSourceFile(cursor)) { + break; + } + + cursor = cursor.parent; + } + } + } + + private getAllChildSymbols( + node: ts.Node, + results = new Set(), + seen = new Set() + ) { + node.forEachChild((child) => { + const childSymbol = this.typeChecker.getSymbolAtLocation(child); + if (childSymbol) { + results.add(toDecSymbol(childSymbol)); + } + if (!seen.has(child)) { + seen.add(child); + this.getAllChildSymbols(child, results, seen); + } + }); + return results; + } + + private resolveAliasSymbolStep(alias: ts.Symbol): DecSymbol { + // get the symbol this symbol references + const aliased = this.typeChecker.getImmediateAliasedSymbol(alias); + if (!aliased) { + throw new Error(`symbol [${alias.escapedName}] is an alias without aliased symbol`); + } + assertDecSymbol(aliased); + return aliased; + } + + private getImportFromNodeModules(symbol: DecSymbol): undefined | ResolvedNmImport { + const parentImport = this.getParentImport(symbol); + if (parentImport) { + // this symbol is within an import statement, is it an import from a node_module? + const aliased = this.resolveAliasSymbolStep(symbol); + + // symbol is in an import or export-from statement, make sure we want to traverse to that file + const targetPaths = [ + ...new Set(aliased.declarations.map((d) => this.sourceMapper.getSourceFile(d).fileName)), + ]; + + if (targetPaths.length > 1) { + throw new Error('importing a symbol from multiple locations is unsupported at this time'); + } + + const targetPath = targetPaths[0]; + if (isNodeModule(this.dtsDir, targetPath)) { + return { + type: 'import', + node: parentImport, + targetPath, + }; + } + } + } + + private resolveAliasSymbol(alias: DecSymbol): ResolvedNmImport | ResolvedSymbol { + let symbol = alias; + + while (isAliasSymbol(symbol)) { + const nmImport = this.getImportFromNodeModules(symbol); + if (nmImport) { + return nmImport; + } + + symbol = this.resolveAliasSymbolStep(symbol); + } + + return { + type: 'symbol', + symbol, + }; + } + + private traversedSymbols = new Set(); + private collectResults( + results: CollectorResults, + exportInfo: ExportInfo | undefined, + symbol: DecSymbol + ): void { + const seen = this.traversedSymbols.has(symbol); + if (seen && !exportInfo) { + return; + } + this.traversedSymbols.add(symbol); + + const source = this.resolveAliasSymbol(symbol); + if (source.type === 'import') { + results.addImport(!!exportInfo, source.node, symbol); + return; + } + + symbol = source.symbol; + if (seen) { + for (const node of symbol.declarations) { + assertExportedValueNode(node); + results.ensureExported(node); + } + return; + } + + const globalDecs: ts.Declaration[] = []; + const localDecs: ts.Declaration[] = []; + for (const node of symbol.declarations) { + const sourceFile = this.sourceMapper.getSourceFile(node); + (isNodeModule(this.dtsDir, sourceFile.fileName) ? globalDecs : localDecs).push(node); + } + + if (globalDecs.length) { + this.log.debug( + `Ignoring ${globalDecs.length} global declarations for "${source.symbol.escapedName}"` + ); + } + + for (const node of localDecs) { + // iterate through the child nodes to find nodes we need to export to make this useful + const childSymbols = this.getAllChildSymbols(node); + childSymbols.delete(symbol); + + for (const childSymbol of childSymbols) { + this.collectResults(results, undefined, childSymbol); + } + + if (isExportedValueNode(node)) { + results.addNode(!!exportInfo, node); + } + } + } + + run(): CollectorResults { + const results = new CollectorResults(this.sourceMapper); + + const moduleSymbol = this.typeChecker.getSymbolAtLocation(this.sourceFile); + if (!moduleSymbol) { + this.log.warn('Source file has no symbol in the type checker, is it empty?'); + return results; + } + + for (const symbol of this.typeChecker.getExportsOfModule(moduleSymbol)) { + assertDecSymbol(symbol); + this.collectResults(results, new ExportInfo(`${symbol.escapedName}`), symbol); + } + + return results; + } +} diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbols.ts b/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbols.ts new file mode 100644 index 000000000000..1c9fa800baaa --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbols.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as ts from 'typescript'; +import { ExportFromDeclaration } from '../ts_nodes'; + +export class ImportedSymbols { + type = 'import' as const; + + constructor( + public readonly exported: boolean, + public readonly importNode: ts.ImportDeclaration | ExportFromDeclaration, + // TODO: I'm going to need to keep track of local names for these... unless that's embedded in the symbols + public readonly symbols: ts.Symbol[] + ) {} +} diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/index.ts b/packages/kbn-type-summarizer/src/lib/export_collector/index.ts new file mode 100644 index 000000000000..87f6630d2fcf --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/export_collector/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * from './exports_collector'; +export * from './collector_results'; diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/reference.ts b/packages/kbn-type-summarizer/src/lib/export_collector/reference.ts new file mode 100644 index 000000000000..b664a457a24a --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/export_collector/reference.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type ReferenceKey = 'types' | 'lib'; + +export class Reference { + type = 'reference' as const; + constructor(public readonly key: ReferenceKey, public readonly name: string) {} +} diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/defaults/get_observer_defaults.ts b/packages/kbn-type-summarizer/src/lib/export_collector/result_value.ts similarity index 66% rename from packages/elastic-apm-synthtrace/src/lib/apm/defaults/get_observer_defaults.ts rename to packages/kbn-type-summarizer/src/lib/export_collector/result_value.ts index 882029a50e47..91249eea68e1 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/defaults/get_observer_defaults.ts +++ b/packages/kbn-type-summarizer/src/lib/export_collector/result_value.ts @@ -6,11 +6,10 @@ * Side Public License, v 1. */ -import { ApmFields } from '../apm_fields'; +import { ValueNode } from '../ts_nodes'; -export function getObserverDefaults(): ApmFields { - return { - 'observer.version': '7.16.0', - 'observer.version_major': 7, - }; +export class ResultValue { + type = 'value' as const; + + constructor(public exported: boolean, public readonly node: ValueNode) {} } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js b/packages/kbn-type-summarizer/src/lib/export_info.ts similarity index 78% rename from packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js rename to packages/kbn-type-summarizer/src/lib/export_info.ts index 6dc8aa803613..3dee04121d32 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js +++ b/packages/kbn-type-summarizer/src/lib/export_info.ts @@ -6,10 +6,6 @@ * Side Public License, v 1. */ -export default function () { - return { - screenshots: { - directory: 'bar', - }, - }; +export class ExportInfo { + constructor(public readonly name: string) {} } diff --git a/packages/kbn-type-summarizer/src/lib/helpers/error.ts b/packages/kbn-type-summarizer/src/lib/helpers/error.ts new file mode 100644 index 000000000000..f78eb29083b0 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/helpers/error.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 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 function toError(thrown: unknown) { + if (thrown instanceof Error) { + return thrown; + } + + return new Error(`${thrown} thrown`); +} + +export function isSystemError(error: Error): error is NodeJS.ErrnoException { + return typeof (error as any).code === 'string'; +} diff --git a/packages/kbn-type-summarizer/src/lib/helpers/fs.ts b/packages/kbn-type-summarizer/src/lib/helpers/fs.ts new file mode 100644 index 000000000000..092310c1e5db --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/helpers/fs.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 Fsp from 'fs/promises'; +import { toError, isSystemError } from './error'; + +export async function tryReadFile( + path: string, + encoding: 'utf-8' | 'utf8' +): Promise; +export async function tryReadFile(path: string, encoding?: BufferEncoding) { + try { + return await Fsp.readFile(path, encoding); + } catch (_) { + const error = toError(_); + if (isSystemError(error) && error.code === 'ENOENT') { + return undefined; + } + throw error; + } +} diff --git a/packages/kbn-type-summarizer/src/lib/helpers/json.test.ts b/packages/kbn-type-summarizer/src/lib/helpers/json.test.ts new file mode 100644 index 000000000000..4bb86652221d --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/helpers/json.test.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 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 { parseJson } from './json'; + +it('parses JSON', () => { + expect(parseJson('{"foo": "bar"}')).toMatchInlineSnapshot(` + Object { + "foo": "bar", + } + `); +}); + +it('throws more helpful errors', () => { + expect(() => parseJson('{"foo": bar}')).toThrowErrorMatchingInlineSnapshot( + `"Failed to parse JSON: Unexpected token b in JSON at position 8"` + ); +}); diff --git a/packages/kbn-type-summarizer/src/lib/helpers/json.ts b/packages/kbn-type-summarizer/src/lib/helpers/json.ts new file mode 100644 index 000000000000..ee2403bd9422 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/helpers/json.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 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 { toError } from './error'; + +export function parseJson(json: string, from?: string) { + try { + return JSON.parse(json); + } catch (_) { + const error = toError(_); + throw new Error(`Failed to parse JSON${from ? ` from ${from}` : ''}: ${error.message}`); + } +} diff --git a/packages/kbn-type-summarizer/src/lib/is_node_module.ts b/packages/kbn-type-summarizer/src/lib/is_node_module.ts new file mode 100644 index 000000000000..ba4d607ccb86 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/is_node_module.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 isPathInside from 'is-path-inside'; + +import * as Path from './path'; + +export function isNodeModule(dtsDir: string, path: string) { + return (isPathInside(path, dtsDir) ? Path.relative(dtsDir, path) : path) + .split('/') + .includes('node_modules'); +} diff --git a/packages/kbn-type-summarizer/src/lib/log/cli_log.ts b/packages/kbn-type-summarizer/src/lib/log/cli_log.ts new file mode 100644 index 000000000000..1121dfae3606 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/log/cli_log.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { format } from 'util'; +import { dim, blueBright, yellowBright, redBright, gray } from 'chalk'; +import getopts from 'getopts'; + +import { Logger } from './logger'; + +const LOG_LEVEL_RANKS = { + silent: 0, + quiet: 1, + info: 2, + debug: 3, + verbose: 4, +}; +export type LogLevel = keyof typeof LOG_LEVEL_RANKS; +const LOG_LEVELS = (Object.keys(LOG_LEVEL_RANKS) as LogLevel[]).sort( + (a, b) => LOG_LEVEL_RANKS[a] - LOG_LEVEL_RANKS[b] +); +const LOG_LEVELS_DESC = LOG_LEVELS.slice().reverse(); + +type LogLevelMap = { [k in LogLevel]: boolean }; + +export interface LogWriter { + write(chunk: string): void; +} + +export class CliLog implements Logger { + static parseLogLevel(level: LogLevel) { + if (!LOG_LEVELS.includes(level)) { + throw new Error('invalid log level'); + } + + const rank = LOG_LEVEL_RANKS[level]; + return Object.fromEntries( + LOG_LEVELS.map((l) => [l, LOG_LEVEL_RANKS[l] <= rank]) + ) as LogLevelMap; + } + + static pickLogLevelFromFlags( + flags: getopts.ParsedOptions, + defaultLogLevl: LogLevel = 'info' + ): LogLevel { + for (const level of LOG_LEVELS_DESC) { + if (Object.prototype.hasOwnProperty.call(flags, level) && flags[level] === true) { + return level; + } + } + + return defaultLogLevl; + } + + private readonly map: LogLevelMap; + constructor(public readonly level: LogLevel, private readonly writeTo: LogWriter) { + this.map = CliLog.parseLogLevel(level); + } + + info(msg: string, ...args: any[]) { + if (this.map.info) { + this.writeTo.write(`${blueBright('info')} ${format(msg, ...args)}\n`); + } + } + + warn(msg: string, ...args: any[]) { + if (this.map.quiet) { + this.writeTo.write(`${yellowBright('warning')} ${format(msg, ...args)}\n`); + } + } + + error(msg: string, ...args: any[]) { + if (this.map.quiet) { + this.writeTo.write(`${redBright('error')} ${format(msg, ...args)}\n`); + } + } + + debug(msg: string, ...args: any[]) { + if (this.map.debug) { + this.writeTo.write(`${gray('debug')} ${format(msg, ...args)}\n`); + } + } + + verbose(msg: string, ...args: any[]) { + if (this.map.verbose) { + this.writeTo.write(`${dim('verbose')}: ${format(msg, ...args)}\n`); + } + } + + success(msg: string, ...args: any[]): void { + if (this.map.quiet) { + this.writeTo.write(`✅ ${format(msg, ...args)}\n`); + } + } +} diff --git a/packages/kbn-type-summarizer/src/lib/log/index.ts b/packages/kbn-type-summarizer/src/lib/log/index.ts new file mode 100644 index 000000000000..68a37528d497 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/log/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 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 * from './logger'; +export * from './cli_log'; +export * from './test_log'; diff --git a/packages/kbn-type-summarizer/src/lib/log/logger.ts b/packages/kbn-type-summarizer/src/lib/log/logger.ts new file mode 100644 index 000000000000..76cb7fe525f6 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/log/logger.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * Logger interface used by @kbn/type-summarizer + */ +export interface Logger { + /** + * Write a message to the log with the level "info" + * @param msg any message + * @param args any serializeable values you would like to be appended to the log message + */ + info(msg: string, ...args: any[]): void; + /** + * Write a message to the log with the level "warn" + * @param msg any message + * @param args any serializeable values you would like to be appended to the log message + */ + warn(msg: string, ...args: any[]): void; + /** + * Write a message to the log with the level "error" + * @param msg any message + * @param args any serializeable values you would like to be appended to the log message + */ + error(msg: string, ...args: any[]): void; + /** + * Write a message to the log with the level "debug" + * @param msg any message + * @param args any serializeable values you would like to be appended to the log message + */ + debug(msg: string, ...args: any[]): void; + /** + * Write a message to the log with the level "verbose" + * @param msg any message + * @param args any serializeable values you would like to be appended to the log message + */ + verbose(msg: string, ...args: any[]): void; + /** + * Write a message to the log, only excluded in silent mode + * @param msg any message + * @param args any serializeable values you would like to be appended to the log message + */ + success(msg: string, ...args: any[]): void; +} diff --git a/packages/kbn-type-summarizer/src/lib/log/test_log.ts b/packages/kbn-type-summarizer/src/lib/log/test_log.ts new file mode 100644 index 000000000000..5062a8cbae84 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/log/test_log.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 { CliLog, LogLevel } from './cli_log'; + +export class TestLog extends CliLog { + messages: string[] = []; + constructor(level: LogLevel = 'verbose') { + super(level, { + write: (chunk) => { + this.messages.push(chunk); + }, + }); + } +} diff --git a/packages/kbn-type-summarizer/src/lib/path.ts b/packages/kbn-type-summarizer/src/lib/path.ts new file mode 100644 index 000000000000..79d56aa58fab --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/path.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 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 Path from 'path'; + +import normalizePath from 'normalize-path'; +const cwd = normalizePath(process.cwd()); + +export function cwdRelative(path: string) { + return relative(cwd, path); +} + +export function relative(from: string, to: string) { + return normalizePath(Path.relative(from, to)); +} + +export function join(...segments: string[]) { + return Path.join(...segments); +} + +export function dirname(path: string) { + return Path.dirname(path); +} + +export function resolve(path: string) { + return Path.isAbsolute(path) ? normalizePath(path) : join(cwd, path); +} + +export function isAbsolute(path: string) { + return Path.isAbsolute(path); +} diff --git a/packages/kbn-type-summarizer/src/lib/printer.ts b/packages/kbn-type-summarizer/src/lib/printer.ts new file mode 100644 index 000000000000..8ecc4356ea4a --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/printer.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 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 * as ts from 'typescript'; +import { SourceNode, CodeWithSourceMap } from 'source-map'; + +import * as Path from './path'; +import { findKind } from './ts_nodes'; +import { SourceMapper } from './source_mapper'; +import { CollectorResult } from './export_collector'; + +type SourceNodes = Array; +const COMMENT_TRIM = /^(\s+)(\/\*|\*|\/\/)/; + +export class Printer { + private readonly tsPrint = ts.createPrinter({ + newLine: ts.NewLineKind.LineFeed, + noEmitHelpers: true, + omitTrailingSemicolon: false, + removeComments: true, + }); + + constructor( + private readonly sourceMapper: SourceMapper, + private readonly results: CollectorResult[], + private readonly outputPath: string, + private readonly mapOutputPath: string, + private readonly sourceRoot: string, + private readonly strict: boolean + ) {} + + async print(): Promise { + const file = new SourceNode( + null, + null, + null, + this.results.flatMap((r) => { + if (r.type === 'reference') { + return `/// \n`; + } + + if (r.type === 'import') { + // TODO: handle default imports, imports with alternate names, etc + return `import { ${r.symbols + .map((s) => s.escapedName) + .join(', ')} } from ${r.importNode.moduleSpecifier.getText()};\n`; + } + + return this.toSourceNodes(r.node, r.exported); + }) + ); + + const outputDir = Path.dirname(this.outputPath); + const mapOutputDir = Path.dirname(this.mapOutputPath); + + const output = file.toStringWithSourceMap({ + file: Path.relative(mapOutputDir, this.outputPath), + sourceRoot: this.sourceRoot, + }); + + const nl = output.code.endsWith('\n') ? '' : '\n'; + const sourceMapPathRel = Path.relative(outputDir, this.mapOutputPath); + output.code += `${nl}//# sourceMappingURL=${sourceMapPathRel}`; + + return output; + } + + private getDeclarationKeyword(node: ts.Declaration) { + if (node.kind === ts.SyntaxKind.FunctionDeclaration) { + return 'function'; + } + + if (node.kind === ts.SyntaxKind.TypeAliasDeclaration) { + return 'type'; + } + + if (node.kind === ts.SyntaxKind.ClassDeclaration) { + return 'class'; + } + + if (node.kind === ts.SyntaxKind.InterfaceDeclaration) { + return 'interface'; + } + + if (ts.isVariableDeclaration(node)) { + return this.getVariableDeclarationType(node); + } + } + + private printModifiers(exported: boolean, node: ts.Declaration) { + const flags = ts.getCombinedModifierFlags(node); + const modifiers: string[] = []; + if (exported) { + modifiers.push('export'); + } + if (flags & ts.ModifierFlags.Default) { + modifiers.push('default'); + } + if (flags & ts.ModifierFlags.Abstract) { + modifiers.push('abstract'); + } + if (flags & ts.ModifierFlags.Private) { + modifiers.push('private'); + } + if (flags & ts.ModifierFlags.Public) { + modifiers.push('public'); + } + if (flags & ts.ModifierFlags.Static) { + modifiers.push('static'); + } + if (flags & ts.ModifierFlags.Readonly) { + modifiers.push('readonly'); + } + if (flags & ts.ModifierFlags.Const) { + modifiers.push('const'); + } + if (flags & ts.ModifierFlags.Async) { + modifiers.push('async'); + } + + const keyword = this.getDeclarationKeyword(node); + if (keyword) { + modifiers.push(keyword); + } + + return `${modifiers.join(' ')} `; + } + + private printNode(node: ts.Node) { + return this.tsPrint.printNode( + ts.EmitHint.Unspecified, + node, + this.sourceMapper.getSourceFile(node) + ); + } + + private ensureNewline(string: string): string; + private ensureNewline(string: SourceNodes): SourceNodes; + private ensureNewline(string: string | SourceNodes): string | SourceNodes { + if (typeof string === 'string') { + return string.endsWith('\n') ? string : `${string}\n`; + } + + const end = string.at(-1); + if (end === undefined) { + return []; + } + + const valid = (typeof end === 'string' ? end : end.toString()).endsWith('\n'); + return valid ? string : [...string, '\n']; + } + + private getMappedSourceNode(node: ts.Node, code?: string) { + return this.sourceMapper.getSourceNode(node, code ?? node.getText()); + } + + private getVariableDeclarationList(node: ts.VariableDeclaration) { + const list = node.parent; + if (!ts.isVariableDeclarationList(list)) { + const kind = findKind(list); + throw new Error( + `expected parent of variable declaration to be a VariableDeclarationList, got [${kind}]` + ); + } + return list; + } + + private getVariableDeclarationType(node: ts.VariableDeclaration) { + const flags = ts.getCombinedNodeFlags(this.getVariableDeclarationList(node)); + if (flags & ts.NodeFlags.Const) { + return 'const'; + } + if (flags & ts.NodeFlags.Let) { + return 'let'; + } + return 'var'; + } + + private getSourceWithLeadingComments(node: ts.Node) { + // variable declarations regularly have leading comments but they're two-parents up, so we have to handle them separately + if (!ts.isVariableDeclaration(node)) { + return node.getFullText(); + } + + const list = this.getVariableDeclarationList(node); + if (list.declarations.length > 1) { + return node.getFullText(); + } + + const statement = list.parent; + if (!ts.isVariableStatement(statement)) { + throw new Error('expected parent of VariableDeclarationList to be a VariableStatement'); + } + + return statement.getFullText(); + } + + private getLeadingComments(node: ts.Node, indentWidth = 0): string[] { + const fullText = this.getSourceWithLeadingComments(node); + const ranges = ts.getLeadingCommentRanges(fullText, 0); + if (!ranges) { + return []; + } + const indent = ' '.repeat(indentWidth); + + return ranges.flatMap((range) => { + const comment = fullText + .slice(range.pos, range.end) + .split('\n') + .map((line) => { + const match = line.match(COMMENT_TRIM); + if (!match) { + return line; + } + + const [, spaces, type] = match; + return line.slice(type === '*' ? spaces.length - 1 : spaces.length); + }) + .map((line) => `${indent}${line}`) + .join('\n'); + + if (comment.startsWith('/// this.printNode(p)).join(', ')}>`; + } + + private toSourceNodes(node: ts.Node, exported = false): SourceNodes { + switch (node.kind) { + case ts.SyntaxKind.LiteralType: + case ts.SyntaxKind.StringLiteral: + case ts.SyntaxKind.BigIntLiteral: + case ts.SyntaxKind.NumericLiteral: + case ts.SyntaxKind.StringKeyword: + return [this.printNode(node)]; + } + + if (ts.isFunctionDeclaration(node)) { + // we are just trying to replace the name with a sourceMapped node, so if there + // is no name just return the source + if (!node.name) { + return [node.getFullText()]; + } + + return [ + this.getLeadingComments(node), + this.printModifiers(exported, node), + this.getMappedSourceNode(node.name), + this.printTypeParameters(node), + `(${node.parameters.map((p) => p.getFullText()).join(', ')})`, + node.type ? [': ', this.printNode(node.type), ';'] : ';', + ].flat(); + } + + if (ts.isInterfaceDeclaration(node)) { + const text = node.getText(); + const name = node.name.getText(); + const nameI = text.indexOf(name); + if (nameI === -1) { + throw new Error(`printed version of interface does not include name [${name}]: ${text}`); + } + return [ + ...this.getLeadingComments(node), + text.slice(0, nameI), + this.getMappedSourceNode(node.name, name), + text.slice(nameI + name.length), + '\n', + ]; + } + + if (ts.isVariableDeclaration(node)) { + return [ + ...this.getLeadingComments(node), + this.printModifiers(exported, node), + this.getMappedSourceNode(node.name), + ...(node.type ? [': ', this.printNode(node.type)] : []), + ';\n', + ]; + } + + if (ts.isUnionTypeNode(node)) { + return node.types.flatMap((type, i) => + i > 0 ? [' | ', ...this.toSourceNodes(type)] : this.toSourceNodes(type) + ); + } + + if (ts.isTypeAliasDeclaration(node)) { + return [ + ...this.getLeadingComments(node), + this.printModifiers(exported, node), + this.getMappedSourceNode(node.name), + this.printTypeParameters(node), + ' = ', + this.ensureNewline(this.toSourceNodes(node.type)), + ].flat(); + } + + if (ts.isClassDeclaration(node)) { + return [ + ...this.getLeadingComments(node), + this.printModifiers(exported, node), + node.name ? this.getMappedSourceNode(node.name) : [], + this.printTypeParameters(node), + ' {\n', + node.members.flatMap((m) => { + const memberText = m.getText(); + + if (ts.isConstructorDeclaration(m)) { + return ` ${memberText}\n`; + } + + if (!m.name) { + return ` ${memberText}\n`; + } + + const nameText = m.name.getText(); + const pos = memberText.indexOf(nameText); + if (pos === -1) { + return ` ${memberText}\n`; + } + + const left = memberText.slice(0, pos); + const right = memberText.slice(pos + nameText.length); + const nameNode = this.getMappedSourceNode(m.name, nameText); + + return [...this.getLeadingComments(m, 2), ` `, left, nameNode, right, `\n`]; + }), + '}\n', + ].flat(); + } + + if (!this.strict) { + return [this.ensureNewline(this.printNode(node))]; + } else { + throw new Error(`unable to print export type of kind [${findKind(node)}]`); + } + } +} diff --git a/packages/kbn-type-summarizer/src/lib/run.ts b/packages/kbn-type-summarizer/src/lib/run.ts new file mode 100644 index 000000000000..4834c4d8aae9 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/run.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 getopts from 'getopts'; + +import { CliLog, LogLevel } from './log'; +import { toError } from './helpers/error'; +import { CliError } from './cli_error'; + +export interface RunContext { + argv: string[]; + log: CliLog; +} + +export interface RunOptions { + helpText: string; + defaultLogLevel?: LogLevel; +} + +export async function run(main: (ctx: RunContext) => Promise, options: RunOptions) { + const argv = process.argv.slice(2); + const rawFlags = getopts(argv); + + const log = new CliLog( + CliLog.pickLogLevelFromFlags(rawFlags, options.defaultLogLevel), + process.stdout + ); + + try { + await main({ argv, log }); + } catch (_) { + const error = toError(_); + if (error instanceof CliError) { + process.exitCode = error.exitCode; + log.error(error.message); + if (error.showHelp) { + process.stdout.write(options.helpText); + } + } else { + log.error('UNHANDLED ERROR', error.stack); + process.exitCode = 1; + } + } +} diff --git a/packages/kbn-type-summarizer/src/lib/source_mapper.ts b/packages/kbn-type-summarizer/src/lib/source_mapper.ts new file mode 100644 index 000000000000..1f03119e8e3f --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/source_mapper.ts @@ -0,0 +1,143 @@ +/* + * Copyright 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 * as ts from 'typescript'; +import { SourceNode, SourceMapConsumer, BasicSourceMapConsumer } from 'source-map'; + +import { Logger } from './log'; +import { tryReadFile } from './helpers/fs'; +import { parseJson } from './helpers/json'; +import { isNodeModule } from './is_node_module'; +import * as Path from './path'; + +type SourceMapConsumerEntry = [ts.SourceFile, BasicSourceMapConsumer | undefined]; + +export class SourceMapper { + static async forSourceFiles( + log: Logger, + dtsDir: string, + repoRelativePackageDir: string, + sourceFiles: readonly ts.SourceFile[] + ) { + const entries = await Promise.all( + sourceFiles.map(async (sourceFile): Promise => { + if (isNodeModule(dtsDir, sourceFile.fileName)) { + return; + } + + const text = sourceFile.getText(); + const match = text.match(/^\/\/#\s*sourceMappingURL=(.*)/im); + if (!match) { + return [sourceFile, undefined]; + } + + const relSourceFile = Path.cwdRelative(sourceFile.fileName); + const sourceMapPath = Path.join(Path.dirname(sourceFile.fileName), match[1]); + const relSourceMapPath = Path.cwdRelative(sourceMapPath); + const sourceJson = await tryReadFile(sourceMapPath, 'utf8'); + if (!sourceJson) { + throw new Error( + `unable to find source map for [${relSourceFile}] expected at [${match[1]}]` + ); + } + + const json = parseJson(sourceJson, `source map at [${relSourceMapPath}]`); + return [sourceFile, await new SourceMapConsumer(json)]; + }) + ); + + const consumers = new Map(entries.filter((e): e is SourceMapConsumerEntry => !!e)); + log.debug( + 'loaded sourcemaps for', + Array.from(consumers.keys()).map((s) => Path.relative(process.cwd(), s.fileName)) + ); + + return new SourceMapper(consumers, repoRelativePackageDir); + } + + private readonly sourceFixDir: string; + constructor( + private readonly consumers: Map, + repoRelativePackageDir: string + ) { + this.sourceFixDir = Path.join('/', repoRelativePackageDir); + } + + /** + * We ensure that `sourceRoot` is not defined in the tsconfig files, and we assume that the `source` value + * for each file in the source map will be a relative path out of the bazel-out dir and to the `repoRelativePackageDir` + * or some path outside of the package in rare situations. Our goal is to convert each of these source paths + * to new path that is relative to the `repoRelativePackageDir` path. To do this we resolve the `repoRelativePackageDir` + * as if it was at the root of the filesystem, then do the same for the `source`, so both paths should be + * absolute, but only include the path segments from the root of the repo. We then get the relative path from + * the absolute version of the `repoRelativePackageDir` to the absolute version of the `source`, which should give + * us the path to the source, relative to the `repoRelativePackageDir`. + */ + fixSourcePath(source: string) { + return Path.relative(this.sourceFixDir, Path.join('/', source)); + } + + getSourceNode(generatedNode: ts.Node, code: string) { + const pos = this.findOriginalPosition(generatedNode); + + if (pos) { + return new SourceNode(pos.line, pos.column, pos.source, code, pos.name ?? undefined); + } + + return new SourceNode(null, null, null, code); + } + + sourceFileCache = new WeakMap(); + // abstracted so we can cache this + getSourceFile(node: ts.Node): ts.SourceFile { + if (ts.isSourceFile(node)) { + return node; + } + + const cached = this.sourceFileCache.get(node); + if (cached) { + return cached; + } + + const sourceFile = this.getSourceFile(node.parent); + this.sourceFileCache.set(node, sourceFile); + return sourceFile; + } + + findOriginalPosition(node: ts.Node) { + const dtsSource = this.getSourceFile(node); + + if (!this.consumers.has(dtsSource)) { + throw new Error(`sourceFile for [${dtsSource.fileName}] didn't have sourcemaps loaded`); + } + + const consumer = this.consumers.get(dtsSource); + if (!consumer) { + return; + } + + const posInDts = dtsSource.getLineAndCharacterOfPosition(node.getStart()); + const pos = consumer.originalPositionFor({ + /* ts line column numbers are 0 based, source map column numbers are also 0 based */ + column: posInDts.character, + /* ts line numbers are 0 based, source map line numbers are 1 based */ + line: posInDts.line + 1, + }); + + return { + ...pos, + source: pos.source ? this.fixSourcePath(pos.source) : null, + }; + } + + close() { + for (const consumer of this.consumers.values()) { + consumer?.destroy(); + } + } +} diff --git a/packages/kbn-type-summarizer/src/lib/ts_nodes.ts b/packages/kbn-type-summarizer/src/lib/ts_nodes.ts new file mode 100644 index 000000000000..b5c03ee8c4c1 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/ts_nodes.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * as ts from 'typescript'; + +export type ValueNode = + | ts.ClassDeclaration + | ts.FunctionDeclaration + | ts.TypeAliasDeclaration + | ts.VariableDeclaration + | ts.InterfaceDeclaration; + +export function isExportedValueNode(node: ts.Node): node is ValueNode { + return ( + node.kind === ts.SyntaxKind.ClassDeclaration || + node.kind === ts.SyntaxKind.FunctionDeclaration || + node.kind === ts.SyntaxKind.TypeAliasDeclaration || + node.kind === ts.SyntaxKind.VariableDeclaration || + node.kind === ts.SyntaxKind.InterfaceDeclaration + ); +} +export function assertExportedValueNode(node: ts.Node): asserts node is ValueNode { + if (!isExportedValueNode(node)) { + const kind = findKind(node); + throw new Error(`not a valid ExportedValueNode [kind=${kind}]`); + } +} +export function toExportedNodeValue(node: ts.Node): ValueNode { + assertExportedValueNode(node); + return node; +} + +export function findKind(node: ts.Node) { + for (const [name, value] of Object.entries(ts.SyntaxKind)) { + if (node.kind === value) { + return name; + } + } + + throw new Error('node.kind is not in the SyntaxKind map'); +} + +export type DecSymbol = ts.Symbol & { + declarations: NonNullable; +}; +export function isDecSymbol(symbol: ts.Symbol): symbol is DecSymbol { + return !!symbol.declarations; +} +export function assertDecSymbol(symbol: ts.Symbol): asserts symbol is DecSymbol { + if (!isDecSymbol(symbol)) { + throw new Error('symbol has no declarations'); + } +} +export function toDecSymbol(symbol: ts.Symbol): DecSymbol { + assertDecSymbol(symbol); + return symbol; +} + +export type ExportFromDeclaration = ts.ExportDeclaration & { + moduleSpecifier: NonNullable; +}; +export function isExportFromDeclaration(node: ts.Node): node is ExportFromDeclaration { + return ts.isExportDeclaration(node) && !!node.moduleSpecifier; +} + +export function isAliasSymbol(symbol: ts.Symbol) { + return symbol.flags & ts.SymbolFlags.Alias; +} diff --git a/packages/kbn-type-summarizer/src/lib/ts_project.ts b/packages/kbn-type-summarizer/src/lib/ts_project.ts new file mode 100644 index 000000000000..92946e329044 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/ts_project.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 * as ts from 'typescript'; + +export function createTsProject(tsConfig: ts.ParsedCommandLine, inputPaths: string[]) { + return ts.createProgram({ + rootNames: inputPaths, + options: { + ...tsConfig.options, + skipLibCheck: false, + }, + projectReferences: tsConfig.projectReferences, + }); +} diff --git a/packages/kbn-type-summarizer/src/lib/tsconfig_file.ts b/packages/kbn-type-summarizer/src/lib/tsconfig_file.ts new file mode 100644 index 000000000000..f3d491c93abc --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/tsconfig_file.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 * as ts from 'typescript'; + +import * as Path from './path'; +import { CliError } from './cli_error'; + +export function readTsConfigFile(path: string) { + const json = ts.readConfigFile(path, ts.sys.readFile); + + if (json.error) { + throw new CliError(`Unable to load tsconfig file: ${json.error.messageText}`); + } + + return json.config; +} + +export function loadTsConfigFile(path: string) { + return ts.parseJsonConfigFileContent(readTsConfigFile(path) ?? {}, ts.sys, Path.dirname(path)); +} diff --git a/packages/kbn-type-summarizer/src/run_api_extractor.ts b/packages/kbn-type-summarizer/src/run_api_extractor.ts new file mode 100644 index 000000000000..0e7bae5165a4 --- /dev/null +++ b/packages/kbn-type-summarizer/src/run_api_extractor.ts @@ -0,0 +1,86 @@ +/* eslint-disable @kbn/eslint/require-license-header */ + +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import Fsp from 'fs/promises'; +import Path from 'path'; + +import { Extractor, ExtractorConfig } from '@microsoft/api-extractor'; + +import { readTsConfigFile } from './lib/tsconfig_file'; +import { CliError } from './lib/cli_error'; + +export async function runApiExtractor( + tsconfigPath: string, + entryPath: string, + dtsBundleOutDir: string +) { + const pkgJson = Path.resolve(Path.dirname(entryPath), 'package.json'); + try { + await Fsp.writeFile( + pkgJson, + JSON.stringify({ + name: 'GENERATED-BY-BAZEL', + description: 'This is a dummy package.json as API Extractor always requires one.', + types: './index.d.ts', + private: true, + license: 'SSPL-1.0 OR Elastic License 2.0', + version: '1.0.0', + }), + { + flag: 'wx', + } + ); + } catch (error) { + if (!error.code || error.code !== 'EEXIST') { + throw error; + } + } + + // API extractor doesn't always support the version of TypeScript used in the repo + // example: at the moment it is not compatable with 3.2 + // to use the internal TypeScript we shall not create a program but rather pass a parsed tsConfig. + const extractorOptions = { + localBuild: false, + }; + + const extractorConfig = ExtractorConfig.prepare({ + configObject: { + compiler: { + overrideTsconfig: readTsConfigFile(tsconfigPath), + }, + projectFolder: Path.dirname(tsconfigPath), + mainEntryPointFilePath: entryPath, + apiReport: { + enabled: false, + // TODO(alan-agius4): remove this folder name when the below issue is solved upstream + // See: https://github.com/microsoft/web-build-tools/issues/1470 + reportFileName: 'invalid', + }, + docModel: { + enabled: false, + }, + dtsRollup: { + enabled: !!dtsBundleOutDir, + untrimmedFilePath: dtsBundleOutDir, + }, + tsdocMetadata: { + enabled: false, + }, + }, + packageJson: undefined, + packageJsonFullPath: pkgJson, + configObjectFullPath: undefined, + }); + const { succeeded } = Extractor.invoke(extractorConfig, extractorOptions); + + if (!succeeded) { + throw new CliError('api-extractor failed'); + } +} diff --git a/packages/kbn-type-summarizer/src/summarize_package.ts b/packages/kbn-type-summarizer/src/summarize_package.ts new file mode 100644 index 000000000000..d3aac96af177 --- /dev/null +++ b/packages/kbn-type-summarizer/src/summarize_package.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 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 Fsp from 'fs/promises'; +import Path from 'path'; + +import normalizePath from 'normalize-path'; + +import { SourceMapper } from './lib/source_mapper'; +import { createTsProject } from './lib/ts_project'; +import { loadTsConfigFile } from './lib/tsconfig_file'; +import { ExportCollector } from './lib/export_collector'; +import { isNodeModule } from './lib/is_node_module'; +import { Printer } from './lib/printer'; +import { Logger } from './lib/log'; + +/** + * Options used to customize the summarizePackage function + */ +export interface SummarizePacakgeOptions { + /** + * Absolute path to the directory containing the .d.ts files produced by `tsc`. Maps to the + * `declarationDir` compiler option. + */ + dtsDir: string; + /** + * Absolute path to the tsconfig.json file for the project we are summarizing + */ + tsconfigPath: string; + /** + * Array of absolute paths to the .d.ts files which will be summarized. Each file in this + * array will cause an output .d.ts summary file to be created containing all the AST nodes + * which are exported or referenced by those exports. + */ + inputPaths: string[]; + /** + * Absolute path to the output directory where the summary .d.ts files should be written + */ + outputDir: string; + /** + * Repo-relative path to the package source, for example `packages/kbn-type-summarizer` for + * this package. This is used to provide the correct `sourceRoot` path in the resulting source + * map files. + */ + repoRelativePackageDir: string; + /** + * Should the printer throw an error if it doesn't know how to print an AST node? Primarily + * used for testing + */ + strictPrinting?: boolean; +} + +/** + * Produce summary .d.ts files for a package + */ +export async function summarizePackage(log: Logger, options: SummarizePacakgeOptions) { + const tsConfig = loadTsConfigFile(options.tsconfigPath); + log.verbose('Created tsconfig', tsConfig); + + if (tsConfig.options.sourceRoot) { + throw new Error(`${options.tsconfigPath} must not define "compilerOptions.sourceRoot"`); + } + + const program = createTsProject(tsConfig, options.inputPaths); + log.verbose('Loaded typescript program'); + + const typeChecker = program.getTypeChecker(); + log.verbose('Typechecker loaded'); + + const sourceFiles = program + .getSourceFiles() + .filter((f) => !isNodeModule(options.dtsDir, f.fileName)) + .sort((a, b) => a.fileName.localeCompare(b.fileName)); + + const sourceMapper = await SourceMapper.forSourceFiles( + log, + options.dtsDir, + options.repoRelativePackageDir, + sourceFiles + ); + + // value that will end up as the `sourceRoot` in the final sourceMaps + const sourceRoot = `../../../${normalizePath(options.repoRelativePackageDir)}`; + + for (const input of options.inputPaths) { + const outputPath = Path.resolve(options.outputDir, Path.basename(input)); + const mapOutputPath = `${outputPath}.map`; + const sourceFile = program.getSourceFile(input); + if (!sourceFile) { + throw new Error(`input file wasn't included in the program`); + } + + const results = new ExportCollector( + log, + typeChecker, + sourceFile, + options.dtsDir, + sourceMapper + ).run(); + + const printer = new Printer( + sourceMapper, + results.getAll(), + outputPath, + mapOutputPath, + sourceRoot, + !!options.strictPrinting + ); + + const summary = await printer.print(); + + await Fsp.mkdir(options.outputDir, { recursive: true }); + await Fsp.writeFile(outputPath, summary.code); + await Fsp.writeFile(mapOutputPath, JSON.stringify(summary.map)); + + sourceMapper.close(); + } +} diff --git a/packages/kbn-type-summarizer/src/tests/integration_helpers.ts b/packages/kbn-type-summarizer/src/tests/integration_helpers.ts new file mode 100644 index 000000000000..c64e58c4e33f --- /dev/null +++ b/packages/kbn-type-summarizer/src/tests/integration_helpers.ts @@ -0,0 +1,177 @@ +/* + * Copyright 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 no-console */ + +import Path from 'path'; +import Fsp from 'fs/promises'; + +import * as ts from 'typescript'; +import stripAnsi from 'strip-ansi'; +import normalizePath from 'normalize-path'; + +import { loadTsConfigFile } from '../lib/tsconfig_file'; +import { createTsProject } from '../lib/ts_project'; +import { TestLog } from '../lib/log'; +import { summarizePackage } from '../summarize_package'; + +const TMP_DIR = Path.resolve(__dirname, '../../__tmp__'); + +const DIAGNOSTIC_HOST = { + getCanonicalFileName: (p: string) => p, + getCurrentDirectory: () => process.cwd(), + getNewLine: () => '\n', +}; + +function dedent(string: string) { + const lines = string.split('\n'); + while (lines.length && lines[0].trim() === '') { + lines.shift(); + } + if (lines.length === 0) { + return ''; + } + const indent = lines[0].split('').findIndex((c) => c !== ' '); + return lines.map((l) => l.slice(indent)).join('\n'); +} + +function ensureDts(path: string) { + if (path.endsWith('.d.ts')) { + throw new Error('path should end with .ts, not .d.ts'); + } + return `${path.slice(0, -3)}.d.ts`; +} + +interface Options { + /* Other files which should be available to the test execution */ + otherFiles?: Record; +} + +class MockCli { + /* file contents which will be fed into TypeScript for this test */ + public readonly mockFiles: Record; + + /* directory where mockFiles pretend to be from */ + public readonly sourceDir = Path.resolve(TMP_DIR, 'src'); + /* directory where we will write .d.ts versions of mockFiles */ + public readonly dtsOutputDir = Path.resolve(TMP_DIR, 'dist_dts'); + /* directory where output will be written */ + public readonly outputDir = Path.resolve(TMP_DIR, 'dts'); + /* path where the tsconfig.json file will be written */ + public readonly tsconfigPath = Path.resolve(this.sourceDir, 'tsconfig.json'); + + /* .d.ts file which we will read to discover the types we need to summarize */ + public readonly inputPath = ensureDts(Path.resolve(this.dtsOutputDir, 'index.ts')); + /* the location we will write the summarized .d.ts file */ + public readonly outputPath = Path.resolve(this.outputDir, Path.basename(this.inputPath)); + /* the location we will write the sourcemaps for the summaried .d.ts file */ + public readonly mapOutputPath = `${this.outputPath}.map`; + + constructor(tsContent: string, options?: Options) { + this.mockFiles = { + ...options?.otherFiles, + 'index.ts': tsContent, + }; + } + + private buildDts() { + const program = createTsProject( + loadTsConfigFile(this.tsconfigPath), + Object.keys(this.mockFiles).map((n) => Path.resolve(this.sourceDir, n)) + ); + + this.printDiagnostics(`dts/config`, program.getConfigFileParsingDiagnostics()); + this.printDiagnostics(`dts/global`, program.getGlobalDiagnostics()); + this.printDiagnostics(`dts/options`, program.getOptionsDiagnostics()); + this.printDiagnostics(`dts/semantic`, program.getSemanticDiagnostics()); + this.printDiagnostics(`dts/syntactic`, program.getSyntacticDiagnostics()); + this.printDiagnostics(`dts/declaration`, program.getDeclarationDiagnostics()); + + const result = program.emit(undefined, undefined, undefined, true); + this.printDiagnostics('dts/results', result.diagnostics); + } + + private printDiagnostics(type: string, diagnostics: readonly ts.Diagnostic[]) { + const errors = diagnostics.filter((d) => d.category === ts.DiagnosticCategory.Error); + if (!errors.length) { + return; + } + + const message = ts.formatDiagnosticsWithColorAndContext(errors, DIAGNOSTIC_HOST); + + console.error( + `TS Errors (${type}):\n${message + .split('\n') + .map((l) => ` ${l}`) + .join('\n')}` + ); + } + + async run() { + const log = new TestLog('debug'); + + // wipe out the tmp dir + await Fsp.rm(TMP_DIR, { recursive: true, force: true }); + + // write mock files to the filesystem + await Promise.all( + Object.entries(this.mockFiles).map(async ([rel, content]) => { + const path = Path.resolve(this.sourceDir, rel); + await Fsp.mkdir(Path.dirname(path), { recursive: true }); + await Fsp.writeFile(path, dedent(content)); + }) + ); + + // write tsconfig.json to the filesystem + await Fsp.writeFile( + this.tsconfigPath, + JSON.stringify({ + include: [`**/*.ts`, `**/*.tsx`], + compilerOptions: { + moduleResolution: 'node', + target: 'es2021', + module: 'CommonJS', + strict: true, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + declaration: true, + emitDeclarationOnly: true, + declarationDir: '../dist_dts', + declarationMap: true, + // prevent loading all @types packages + typeRoots: [], + }, + }) + ); + + // convert the source files to .d.ts files + this.buildDts(); + + // summarize the .d.ts files into the output dir + await summarizePackage(log, { + dtsDir: normalizePath(this.dtsOutputDir), + inputPaths: [normalizePath(this.inputPath)], + outputDir: normalizePath(this.outputDir), + repoRelativePackageDir: 'src', + tsconfigPath: normalizePath(this.tsconfigPath), + strictPrinting: false, + }); + + // return the results + return { + code: await Fsp.readFile(this.outputPath, 'utf8'), + map: JSON.parse(await Fsp.readFile(this.mapOutputPath, 'utf8')), + logs: stripAnsi(log.messages.join('')), + }; + } +} + +export async function run(tsContent: string, options?: Options) { + const project = new MockCli(tsContent, options); + return await project.run(); +} diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/class.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/class.test.ts new file mode 100644 index 000000000000..eaf87cda8521 --- /dev/null +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/class.test.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 { run } from '../integration_helpers'; + +it('prints basic class correctly', async () => { + const output = await run(` + /** + * Interface for writin records to a database + */ + interface Db { + write(record: Record): Promise + } + + export class Foo { + /** + * The name of the Foo + */ + public readonly name: string + constructor(name: string) { + this.name = name.toLowerCase() + } + + speak() { + alert('hi, my name is ' + this.name) + } + + async save(db: Db) { + await db.write({ + name: this.name + }) + } + } + `); + + expect(output.code).toMatchInlineSnapshot(` + "/** + * Interface for writin records to a database + */ + interface Db { + write(record: Record): Promise; + } + export class Foo { + /** + * The name of the Foo + */ + readonly name: string; + constructor(name: string); + speak(): void; + save(db: Db): Promise; + } + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": ";;;UAGU,E;;;aAIG,G;;;;WAIK,I;;EAKhB,K;EAIM,I", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' ] + debug Ignoring 1 global declarations for \\"Record\\" + debug Ignoring 5 global declarations for \\"Promise\\" + " + `); +}); diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/function.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/function.test.ts new file mode 100644 index 000000000000..de0f1bb4c6d4 --- /dev/null +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/function.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { run } from '../integration_helpers'; + +it('prints the function declaration, including comments', async () => { + const result = await run( + ` + import { Bar } from './bar'; + + /** + * Convert a Bar to a string + */ + export function foo( + /** + * Important comment + */ + name: Bar + ) { + return name.toString(); + } + `, + { + otherFiles: { + 'bar.ts': ` + export class Bar { + constructor( + private value: T + ){} + + toString() { + return this.value.toString() + } + } + `, + }, + } + ); + + expect(result.code).toMatchInlineSnapshot(` + "class Bar { + private value; + constructor(value: T); + toString(): string; + } + /** + * Convert a Bar to a string + */ + export function foo( + /** + * Important comment + */ + name: Bar): string; + //# sourceMappingURL=index.d.ts.map" + `); + expect(result.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": "MAAa,G;;;UAED,K;;EAGV,Q;;;;;gBCAc,G", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "bar.ts", + "index.ts", + ], + "version": 3, + } + `); + expect(result.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ + 'packages/kbn-type-summarizer/__tmp__/dist_dts/bar.d.ts', + 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' + ] + " + `); +}); diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/import_boundary.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/import_boundary.test.ts new file mode 100644 index 000000000000..f1e3279bb57b --- /dev/null +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/import_boundary.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { run } from '../integration_helpers'; + +const nodeModules = { + 'node_modules/foo/index.ts': ` + export class Foo { + render() { + return 'hello' + } + } + `, + 'node_modules/bar/index.ts': ` + export default class Bar { + render() { + return 'hello' + } + } + `, +}; + +it('output type links to named import from node modules', async () => { + const output = await run( + ` + import { Foo } from 'foo' + export type ValidName = string | Foo + `, + { otherFiles: nodeModules } + ); + + expect(output.code).toMatchInlineSnapshot(` + "import { Foo } from 'foo'; + export type ValidName = string | Foo + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": ";YACY,S", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' ] + " + `); +}); + +it('output type links to default import from node modules', async () => { + const output = await run( + ` + import Bar from 'bar' + export type ValidName = string | Bar + `, + { otherFiles: nodeModules } + ); + + expect(output.code).toMatchInlineSnapshot(` + "import { Bar } from 'bar'; + export type ValidName = string | Bar + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": ";YACY,S", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' ] + " + `); +}); diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/interface.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/interface.test.ts new file mode 100644 index 000000000000..cbccbfb1d77d --- /dev/null +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/interface.test.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 { run } from '../integration_helpers'; + +it('prints the whole interface, including comments', async () => { + const result = await run(` + /** + * This is an interface + */ + export interface Foo { + /** + * method + */ + name(): string + + /** + * hello + */ + close(): Promise + } + `); + + expect(result.code).toMatchInlineSnapshot(` + "/** + * This is an interface + */ + export interface Foo { + /** + * method + */ + name(): string; + /** + * hello + */ + close(): Promise; + } + //# sourceMappingURL=index.d.ts.map" + `); + expect(result.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": ";;;iBAGiB,G", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(result.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' ] + debug Ignoring 5 global declarations for \\"Promise\\" + " + `); +}); diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/references.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/references.test.ts new file mode 100644 index 000000000000..0a2cc9aaf585 --- /dev/null +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/references.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { run } from '../integration_helpers'; + +it('collects references from source files which contribute to result', async () => { + const result = await run( + ` + /// + export type PromiseOfString = Promise<'string'> + export * from './files' + `, + { + otherFiles: { + 'files/index.ts': ` + /// + export type MySymbol = Symbol & { __tag: 'MySymbol' } + export * from './foo' + `, + 'files/foo.ts': ` + /// + interface Props {} + export type MyComponent = React.Component + `, + }, + } + ); + + expect(result.code).toMatchInlineSnapshot(` + "/// + /// + /// + export type PromiseOfString = Promise<'string'> + export type MySymbol = Symbol & { + __tag: 'MySymbol'; + } + interface Props { + } + export type MyComponent = React.Component + //# sourceMappingURL=index.d.ts.map" + `); + expect(result.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": ";;;YACY,e;YCAA,Q;;;UCAF,K;;YACE,W", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + "files/index.ts", + "files/foo.ts", + ], + "version": 3, + } + `); + expect(result.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ + 'packages/kbn-type-summarizer/__tmp__/dist_dts/files/foo.d.ts', + 'packages/kbn-type-summarizer/__tmp__/dist_dts/files/index.d.ts', + 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' + ] + debug Ignoring 5 global declarations for \\"Promise\\" + debug Ignoring 4 global declarations for \\"Symbol\\" + debug Ignoring 2 global declarations for \\"Component\\" + debug Ignoring 1 global declarations for \\"React\\" + " + `); +}); diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/type_alias.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/type_alias.test.ts new file mode 100644 index 000000000000..cbe99c54ca04 --- /dev/null +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/type_alias.test.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. + */ + +import { run } from '../integration_helpers'; + +it('prints basic type alias', async () => { + const output = await run(` + export type Name = 'foo' | string + + function hello(name: Name) { + console.log('hello', name) + } + + hello('john') + `); + + expect(output.code).toMatchInlineSnapshot(` + "export type Name = 'foo' | string + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": "YAAY,I", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' ] + " + `); +}); diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/variables.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/variables.test.ts new file mode 100644 index 000000000000..a2b47d647102 --- /dev/null +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/variables.test.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 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 { run } from '../integration_helpers'; + +it('prints basic variable exports with sourcemaps', async () => { + const output = await run(` + /** + * What is a type + */ + type Type = 'bar' | 'baz' + + /** some comment */ + export const bar: Type = 'bar' + + export var + /** + * checkout bar + */ + baz: Type = 'baz', + /** + * this is foo + */ + foo: Type = 'bar' + + export let types = [bar, baz, foo] + `); + + expect(output.code).toMatchInlineSnapshot(` + "/** + * What is a type + */ + type Type = 'bar' | 'baz' + /** some comment */ + export const bar: Type; + /** + * checkout bar + */ + export var baz: Type; + /** + * this is foo + */ + export var foo: Type; + export let types: (\\"bar\\" | \\"baz\\")[]; + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": ";;;KAGK,I;;aAGQ,G;;;;WAMX,G;;;;WAIA,G;WAES,K", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' ] + " + `); +}); diff --git a/packages/kbn-type-summarizer/tsconfig.json b/packages/kbn-type-summarizer/tsconfig.json new file mode 100644 index 000000000000..b3779bdd686e --- /dev/null +++ b/packages/kbn-type-summarizer/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "outDir": "target_types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/kbn-utility-types/src/tsd_tests/test_d/method_keys_of.ts b/packages/kbn-utility-types/src/tsd_tests/test_d/method_keys_of.ts index 8438fd1a41da..816a504b816e 100644 --- a/packages/kbn-utility-types/src/tsd_tests/test_d/method_keys_of.ts +++ b/packages/kbn-utility-types/src/tsd_tests/test_d/method_keys_of.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-extraneous-dependencies import { expectType } from 'tsd'; import { MethodKeysOf } from '../..'; diff --git a/packages/kbn-utility-types/src/tsd_tests/test_d/public_contract.ts b/packages/kbn-utility-types/src/tsd_tests/test_d/public_contract.ts index f0b5507a6f63..b37814255099 100644 --- a/packages/kbn-utility-types/src/tsd_tests/test_d/public_contract.ts +++ b/packages/kbn-utility-types/src/tsd_tests/test_d/public_contract.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-extraneous-dependencies import { expectType } from 'tsd'; import { PublicContract } from '../..'; diff --git a/packages/kbn-utility-types/src/tsd_tests/test_d/public_keys.ts b/packages/kbn-utility-types/src/tsd_tests/test_d/public_keys.ts index 1916c7c0b7a0..7f05b30f4c55 100644 --- a/packages/kbn-utility-types/src/tsd_tests/test_d/public_keys.ts +++ b/packages/kbn-utility-types/src/tsd_tests/test_d/public_keys.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-extraneous-dependencies import { expectType } from 'tsd'; import { PublicKeys } from '../..'; diff --git a/packages/kbn-utility-types/src/tsd_tests/test_d/public_methods_of.ts b/packages/kbn-utility-types/src/tsd_tests/test_d/public_methods_of.ts index fc2626179e7c..d265b86ece78 100644 --- a/packages/kbn-utility-types/src/tsd_tests/test_d/public_methods_of.ts +++ b/packages/kbn-utility-types/src/tsd_tests/test_d/public_methods_of.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-extraneous-dependencies import { expectAssignable, expectNotAssignable } from 'tsd'; import { PublicMethodsOf } from '../..'; diff --git a/packages/kbn-utility-types/src/tsd_tests/test_d/shallow_promise.ts b/packages/kbn-utility-types/src/tsd_tests/test_d/shallow_promise.ts index 4bfd5b1826fb..a77558ae6ce9 100644 --- a/packages/kbn-utility-types/src/tsd_tests/test_d/shallow_promise.ts +++ b/packages/kbn-utility-types/src/tsd_tests/test_d/shallow_promise.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-extraneous-dependencies import { expectType } from 'tsd'; import { ShallowPromise } from '../..'; diff --git a/packages/kbn-utility-types/src/tsd_tests/test_d/union_to_intersection.ts b/packages/kbn-utility-types/src/tsd_tests/test_d/union_to_intersection.ts index 776da8838ef5..057a2d5e3136 100644 --- a/packages/kbn-utility-types/src/tsd_tests/test_d/union_to_intersection.ts +++ b/packages/kbn-utility-types/src/tsd_tests/test_d/union_to_intersection.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-extraneous-dependencies import { expectAssignable } from 'tsd'; import { UnionToIntersection } from '../..'; diff --git a/packages/kbn-utility-types/src/tsd_tests/test_d/unwrap_observable.ts b/packages/kbn-utility-types/src/tsd_tests/test_d/unwrap_observable.ts index 6f76fc7cf40f..5cde15f2ab21 100644 --- a/packages/kbn-utility-types/src/tsd_tests/test_d/unwrap_observable.ts +++ b/packages/kbn-utility-types/src/tsd_tests/test_d/unwrap_observable.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-extraneous-dependencies import { expectAssignable } from 'tsd'; import { UnwrapObservable, ObservableLike } from '../..'; diff --git a/packages/kbn-utility-types/src/tsd_tests/test_d/values.ts b/packages/kbn-utility-types/src/tsd_tests/test_d/values.ts index 5e9e0d73f5b9..4033c22ea73a 100644 --- a/packages/kbn-utility-types/src/tsd_tests/test_d/values.ts +++ b/packages/kbn-utility-types/src/tsd_tests/test_d/values.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-extraneous-dependencies import { expectAssignable } from 'tsd'; import { Values } from '../..'; diff --git a/packages/kbn-utility-types/src/tsd_tests/test_d/writable.ts b/packages/kbn-utility-types/src/tsd_tests/test_d/writable.ts index db3f6460d1b3..8cfb010d0087 100644 --- a/packages/kbn-utility-types/src/tsd_tests/test_d/writable.ts +++ b/packages/kbn-utility-types/src/tsd_tests/test_d/writable.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-extraneous-dependencies import { expectAssignable } from 'tsd'; import { Writable } from '../..'; diff --git a/renovate.json b/renovate.json index 9b673a5a9ccf..0b6ca59edefe 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,6 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:base", ":disableDependencyDashboard"], + "extends": ["config:base"], "ignorePaths": ["**/__fixtures__/**", "**/fixtures/**"], "enabledManagers": ["npm"], "baseBranches": ["main", "7.16", "7.15"], diff --git a/scripts/build_type_summarizer_output.js b/scripts/build_type_summarizer_output.js new file mode 100644 index 000000000000..619c8db5d2d0 --- /dev/null +++ b/scripts/build_type_summarizer_output.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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/ensure_node_preserve_symlinks'); +require('source-map-support/register'); +require('@kbn/type-summarizer/target_node/bazel_cli'); diff --git a/scripts/functional_tests.js b/scripts/functional_tests.js index 972c682ab0d9..b185bcb0ea5d 100644 --- a/scripts/functional_tests.js +++ b/scripts/functional_tests.js @@ -9,7 +9,7 @@ require('../src/setup_node_env'); require('@kbn/test').runTestsCli([ require.resolve('../test/functional/config.js'), - require.resolve('../test/functional_ccs/config.js'), + require.resolve('../test/functional_ccs/config.ts'), require.resolve('../test/plugin_functional/config.ts'), require.resolve('../test/ui_capabilities/newsfeed_err/config.ts'), require.resolve('../test/new_visualize_flow/config.ts'), diff --git a/scripts/generate.js b/scripts/generate.js new file mode 100644 index 000000000000..29774e8088d6 --- /dev/null +++ b/scripts/generate.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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/ensure_node_preserve_symlinks'); +require('source-map-support/register'); +require('@kbn/generate').runGenerateCli(); diff --git a/scripts/update_vscode_config.js b/scripts/update_vscode_config.js index 10ed9fa200b7..d39e4ce757ce 100644 --- a/scripts/update_vscode_config.js +++ b/scripts/update_vscode_config.js @@ -6,5 +6,5 @@ * Side Public License, v 1. */ -require('../src/setup_node_env'); +require('../src/setup_node_env/no_transpilation'); require('@kbn/dev-utils').runUpdateVscodeConfigCli(); diff --git a/src/core/public/apm_system.test.ts b/src/core/public/apm_system.test.ts index 842d5de7e5af..0a3a1dee63e5 100644 --- a/src/core/public/apm_system.test.ts +++ b/src/core/public/apm_system.test.ts @@ -13,6 +13,7 @@ import type { Transaction } from '@elastic/apm-rum'; import { ApmSystem } from './apm_system'; import { Subject } from 'rxjs'; import { InternalApplicationStart } from './application/types'; +import { executionContextServiceMock } from './execution_context/execution_context_service.mock'; const initMock = init as jest.Mocked; const apmMock = apm as DeeplyMockedKeys; @@ -96,6 +97,7 @@ describe('ApmSystem', () => { application: { currentAppId$, } as any as InternalApplicationStart, + executionContext: executionContextServiceMock.createInternalStartContract(), }); expect(mark).toHaveBeenCalledWith('apm-start'); @@ -118,6 +120,7 @@ describe('ApmSystem', () => { application: { currentAppId$, } as any as InternalApplicationStart, + executionContext: executionContextServiceMock.createInternalStartContract(), }); currentAppId$.next('myapp'); @@ -145,6 +148,7 @@ describe('ApmSystem', () => { application: { currentAppId$, } as any as InternalApplicationStart, + executionContext: executionContextServiceMock.createInternalStartContract(), }); currentAppId$.next('myapp'); diff --git a/src/core/public/apm_system.ts b/src/core/public/apm_system.ts index 2231f394381f..4e116c0a0182 100644 --- a/src/core/public/apm_system.ts +++ b/src/core/public/apm_system.ts @@ -10,6 +10,7 @@ import type { ApmBase, AgentConfigOptions, Transaction } from '@elastic/apm-rum' import { modifyUrl } from '@kbn/std'; import { CachedResourceObserver } from './apm_resource_counter'; import type { InternalApplicationStart } from './application'; +import { ExecutionContextStart } from './execution_context'; /** "GET protocol://hostname:port/pathname" */ const HTTP_REQUEST_TRANSACTION_NAME_REGEX = @@ -27,6 +28,7 @@ interface ApmConfig extends AgentConfigOptions { interface StartDeps { application: InternalApplicationStart; + executionContext: ExecutionContextStart; } export class ApmSystem { @@ -34,6 +36,7 @@ export class ApmSystem { private pageLoadTransaction?: Transaction; private resourceObserver: CachedResourceObserver; private apm?: ApmBase; + /** * `apmConfig` would be populated with relevant APM RUM agent * configuration if server is started with elastic.apm.* config. @@ -64,6 +67,15 @@ export class ApmSystem { this.markPageLoadStart(); + start.executionContext.context$.subscribe((c) => { + // We're using labels because we want the context to be indexed + // https://www.elastic.co/guide/en/apm/get-started/current/metadata.html + const apmContext = start.executionContext.getAsLabels(); + this.apm?.addLabels(apmContext); + }); + + // TODO: Start a new transaction every page change instead of every app change. + /** * Register listeners for navigation changes and capture them as * route-change transactions after Kibana app is bootstrapped diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 3d3331d54792..1aa01c13dd37 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -31,6 +31,7 @@ import { DeprecationsService } from './deprecations'; import { ThemeService } from './theme'; import { CoreApp } from './core_app'; import type { InternalApplicationSetup, InternalApplicationStart } from './application/types'; +import { ExecutionContextService } from './execution_context'; interface Params { rootDomElement: HTMLElement; @@ -87,6 +88,7 @@ export class CoreSystem { private readonly theme: ThemeService; private readonly rootDomElement: HTMLElement; private readonly coreContext: CoreContext; + private readonly executionContext: ExecutionContextService; private fatalErrorsSetup: FatalErrorsSetup | null = null; constructor(params: Params) { @@ -121,6 +123,7 @@ export class CoreSystem { this.application = new ApplicationService(); this.integrations = new IntegrationsService(); this.deprecations = new DeprecationsService(); + this.executionContext = new ExecutionContextService(); this.plugins = new PluginsService(this.coreContext, injectedMetadata.uiPlugins); this.coreApp = new CoreApp(this.coreContext); @@ -137,7 +140,13 @@ export class CoreSystem { }); await this.integrations.setup(); this.docLinks.setup(); - const http = this.http.setup({ injectedMetadata, fatalErrors: this.fatalErrorsSetup }); + + const executionContext = this.executionContext.setup(); + const http = this.http.setup({ + injectedMetadata, + fatalErrors: this.fatalErrorsSetup, + executionContext, + }); const uiSettings = this.uiSettings.setup({ http, injectedMetadata }); const notifications = this.notifications.setup({ uiSettings }); const theme = this.theme.setup({ injectedMetadata }); @@ -153,6 +162,7 @@ export class CoreSystem { notifications, theme, uiSettings, + executionContext, }; // Services that do not expose contracts at setup @@ -201,6 +211,11 @@ export class CoreSystem { targetDomElement: notificationsTargetDomElement, }); const application = await this.application.start({ http, theme, overlays }); + + const executionContext = this.executionContext.start({ + curApp$: application.currentAppId$, + }); + const chrome = await this.chrome.start({ application, docLinks, @@ -216,6 +231,7 @@ export class CoreSystem { application, chrome, docLinks, + executionContext, http, theme, savedObjects, @@ -248,6 +264,7 @@ export class CoreSystem { return { application, + executionContext, }; } catch (error) { if (this.fatalErrorsSetup) { diff --git a/src/core/public/execution_context/execution_context_service.mock.ts b/src/core/public/execution_context/execution_context_service.mock.ts new file mode 100644 index 000000000000..3941aa333cfa --- /dev/null +++ b/src/core/public/execution_context/execution_context_service.mock.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 { PublicMethodsOf } from '@kbn/utility-types'; +import { BehaviorSubject } from 'rxjs'; + +import { ExecutionContextService, ExecutionContextSetup } from './execution_context_service'; + +const createContractMock = (): jest.Mocked => ({ + context$: new BehaviorSubject({}), + clear: jest.fn(), + set: jest.fn(), + get: jest.fn(), + getAsLabels: jest.fn(), + withGlobalContext: jest.fn(), +}); + +const createMock = (): jest.Mocked> => ({ + setup: jest.fn().mockReturnValue(createContractMock()), + start: jest.fn().mockReturnValue(createContractMock()), + stop: jest.fn(), +}); + +export const executionContextServiceMock = { + create: createMock, + createSetupContract: createContractMock, + createStartContract: createContractMock, + createInternalSetupContract: createContractMock, + createInternalStartContract: createContractMock, +}; diff --git a/src/core/public/execution_context/execution_context_service.test.ts b/src/core/public/execution_context/execution_context_service.test.ts new file mode 100644 index 000000000000..70e57b8993bb --- /dev/null +++ b/src/core/public/execution_context/execution_context_service.test.ts @@ -0,0 +1,205 @@ +/* + * Copyright 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 { BehaviorSubject } from 'rxjs'; +import { ExecutionContextService, ExecutionContextSetup } from './execution_context_service'; + +describe('ExecutionContextService', () => { + let execContext: ExecutionContextSetup; + let curApp$: BehaviorSubject; + let execService: ExecutionContextService; + + beforeEach(() => { + execService = new ExecutionContextService(); + execContext = execService.setup(); + curApp$ = new BehaviorSubject('app1'); + execContext = execService.start({ + curApp$, + }); + }); + + it('app name updates automatically and clears everything else', () => { + execContext.set({ + type: 'ghf', + description: 'first set', + }); + + expect(execContext.get()).toStrictEqual({ + name: 'app1', + description: 'first set', + type: 'ghf', + url: '/', + }); + + curApp$.next('app2'); + + expect(execContext.get()).toStrictEqual({ + name: 'app2', + url: '/', + }); + }); + + it('sets context and adds current url and appid when getting it', () => { + execContext.set({ + type: 'ghf', + description: 'first set', + }); + + expect(execContext.get()).toStrictEqual({ + name: 'app1', + description: 'first set', + type: 'ghf', + url: '/', + }); + }); + + it('merges context between calls and gets it', () => { + execContext.set({ + type: 'ghf', + description: 'first set', + }); + + execContext.set({ + type: 'ghf', + description: 'second set', + }); + + expect(execContext.get()).toStrictEqual({ + name: 'app1', + type: 'ghf', + description: 'second set', + url: '/', + }); + }); + + it('context observable fires the context each time it changes', () => { + const sub = jest.fn(); + + execContext.set({ + type: 'ghf', + description: 'first set', + }); + + execContext.context$.subscribe(sub); + + expect(sub).toHaveBeenCalledWith({ + name: 'app1', + type: 'ghf', + description: 'first set', + url: '/', + }); + + execContext.set({ + type: 'str', + description: 'first set', + }); + + expect(sub).toHaveBeenCalledWith({ + name: 'app1', + type: 'str', + description: 'first set', + url: '/', + }); + + expect(sub).toHaveBeenCalledTimes(2); + }); + + it('context observable doesnt fires if the context did not change', () => { + const sub = jest.fn(); + + execContext.set({ + type: 'ghf', + description: 'first set', + }); + + execContext.context$.subscribe(sub); + + execContext.set({ + type: 'ghf', + }); + + expect(sub).toHaveBeenCalledWith({ + name: 'app1', + type: 'ghf', + description: 'first set', + url: '/', + }); + + expect(sub).toHaveBeenCalledTimes(1); + }); + + it('clear resets context and triggers context observable', () => { + const sub = jest.fn(); + + execContext.set({ + type: 'ghf', + description: 'first set', + }); + execContext.context$.subscribe(sub); + + execContext.clear(); + expect(sub).toHaveBeenCalledWith({ + name: 'app1', + url: '/', + }); + + // Clear triggers the observable + expect(sub).toHaveBeenCalledTimes(2); + }); + + it('getAsLabels return relevant values', () => { + execContext.set({ + type: 'ghf', + description: 'first set', + page: 'mypage', + child: { + description: 'inner', + }, + id: '123', + }); + + expect(execContext.getAsLabels()).toStrictEqual({ + name: 'app1', + page: 'mypage', + id: '123', + }); + }); + + it('getAsLabels removes undefined values', () => { + execContext.set({ + type: 'ghf', + description: 'first set', + page: 'mypage', + id: undefined, + }); + + expect(execContext.get()).toStrictEqual({ + name: 'app1', + type: 'ghf', + page: 'mypage', + url: '/', + description: 'first set', + id: undefined, + }); + + expect(execContext.getAsLabels()).toStrictEqual({ + name: 'app1', + page: 'mypage', + }); + }); + + it('stop clears subscriptions', () => { + const sub = jest.fn(); + execContext.context$.subscribe(sub); + sub.mockReset(); + + execService.stop(); + curApp$.next('abc'); + + expect(sub).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/public/execution_context/execution_context_service.ts b/src/core/public/execution_context/execution_context_service.ts new file mode 100644 index 000000000000..a14d876c9643 --- /dev/null +++ b/src/core/public/execution_context/execution_context_service.ts @@ -0,0 +1,137 @@ +/* + * Copyright 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 { isEqual, isUndefined, omitBy } from 'lodash'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { CoreService, KibanaExecutionContext } from '../../types'; + +// Should be exported from elastic/apm-rum +export type LabelValue = string | number | boolean; + +export interface Labels { + [key: string]: LabelValue; +} + +/** + * Kibana execution context. + * Used to provide execution context to Elasticsearch, reporting, performance monitoring, etc. + * @public + **/ +export interface ExecutionContextSetup { + /** + * The current context observable + **/ + context$: Observable; + /** + * Set the current top level context + **/ + set(c$: KibanaExecutionContext): void; + /** + * Get the current top level context + **/ + get(): KibanaExecutionContext; + /** + * clears the context + **/ + clear(): void; + /** + * returns apm labels + **/ + getAsLabels(): Labels; + /** + * merges the current top level context with the specific event context + **/ + withGlobalContext(context?: KibanaExecutionContext): KibanaExecutionContext; +} + +/** + * See {@link ExecutionContextSetup}. + * @public + */ +export type ExecutionContextStart = ExecutionContextSetup; + +export interface StartDeps { + curApp$: Observable; +} + +/** @internal */ +export class ExecutionContextService + implements CoreService +{ + private context$: BehaviorSubject = new BehaviorSubject({}); + private appId?: string; + private subscription: Subscription = new Subscription(); + private contract?: ExecutionContextSetup; + + public setup() { + this.contract = { + context$: this.context$.asObservable(), + clear: () => { + this.context$.next(this.getDefaultContext()); + }, + set: (c: KibanaExecutionContext) => { + const newVal = this.mergeContext(c); + if (!isEqual(newVal, this.context$.value)) { + this.context$.next(newVal); + } + }, + get: () => { + return this.mergeContext(); + }, + getAsLabels: () => { + return this.removeUndefined({ + name: this.appId, + id: this.context$.value?.id, + page: this.context$.value?.page, + }) as Labels; + }, + withGlobalContext: (context: KibanaExecutionContext) => { + return this.mergeContext(context); + }, + }; + + return this.contract; + } + + public start({ curApp$ }: StartDeps) { + const start = this.contract!; + + // Track app id changes and clear context on app change + this.subscription.add( + curApp$.subscribe((appId) => { + this.appId = appId; + start.clear(); + }) + ); + + return start; + } + + public stop() { + this.subscription.unsubscribe(); + } + + private removeUndefined(context: KibanaExecutionContext = {}) { + return omitBy(context, isUndefined); + } + + private getDefaultContext() { + return { + name: this.appId, + url: window.location.pathname, + }; + } + + private mergeContext(context: KibanaExecutionContext = {}): KibanaExecutionContext { + return { + ...this.getDefaultContext(), + ...this.context$.value, + ...context, + }; + } +} diff --git a/src/core/public/execution_context/index.ts b/src/core/public/execution_context/index.ts index b15a967ac714..f160b0ecea67 100644 --- a/src/core/public/execution_context/index.ts +++ b/src/core/public/execution_context/index.ts @@ -8,3 +8,5 @@ export type { KibanaExecutionContext } from '../../types'; export { ExecutionContextContainer } from './execution_context_container'; +export { ExecutionContextService } from './execution_context_service'; +export type { ExecutionContextSetup, ExecutionContextStart } from './execution_context_service'; diff --git a/src/core/public/http/fetch.test.ts b/src/core/public/http/fetch.test.ts index e897d69057e0..0691e2443c17 100644 --- a/src/core/public/http/fetch.test.ts +++ b/src/core/public/http/fetch.test.ts @@ -15,6 +15,7 @@ import { first } from 'rxjs/operators'; import { Fetch } from './fetch'; import { BasePath } from './base_path'; import { HttpResponse, HttpFetchOptionsWithPath } from './types'; +import { executionContextServiceMock } from '../execution_context/execution_context_service.mock'; function delay(duration: number) { return new Promise((r) => setTimeout(r, duration)); @@ -23,9 +24,11 @@ function delay(duration: number) { const BASE_PATH = 'http://localhost/myBase'; describe('Fetch', () => { + const executionContextMock = executionContextServiceMock.createSetupContract(); const fetchInstance = new Fetch({ basePath: new BasePath(BASE_PATH), kibanaVersion: 'VERSION', + executionContext: executionContextMock, }); afterEach(() => { fetchMock.restore(); @@ -230,13 +233,15 @@ describe('Fetch', () => { it('should inject context headers if provided', async () => { fetchMock.get('*', {}); + const context = { + type: 'test-type', + name: 'test-name', + description: 'test-description', + id: '42', + }; + executionContextMock.withGlobalContext.mockReturnValue(context); await fetchInstance.fetch('/my/path', { - context: { - type: 'test-type', - name: 'test-name', - description: 'test-description', - id: '42', - }, + context, }); expect(fetchMock.lastOptions()!.headers).toMatchObject({ @@ -245,6 +250,29 @@ describe('Fetch', () => { }); }); + it('should include top level context context headers if provided', async () => { + fetchMock.get('*', {}); + + const context = { + type: 'test-type', + name: 'test-name', + description: 'test-description', + id: '42', + }; + executionContextMock.withGlobalContext.mockReturnValue({ + ...context, + name: 'banana', + }); + await fetchInstance.fetch('/my/path', { + context, + }); + + expect(fetchMock.lastOptions()!.headers).toMatchObject({ + 'x-kbn-context': + '%7B%22type%22%3A%22test-type%22%2C%22name%22%3A%22banana%22%2C%22description%22%3A%22test-description%22%2C%22id%22%3A%2242%22%7D', + }); + }); + it('should return response', async () => { fetchMock.get('*', { foo: 'bar' }); const json = await fetchInstance.fetch('/my/path'); diff --git a/src/core/public/http/fetch.ts b/src/core/public/http/fetch.ts index 4ee81f4b47aa..9a333161e1b7 100644 --- a/src/core/public/http/fetch.ts +++ b/src/core/public/http/fetch.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { omitBy } from 'lodash'; +import { isEmpty, omitBy } from 'lodash'; import { format } from 'url'; import { BehaviorSubject } from 'rxjs'; @@ -22,11 +22,12 @@ import { HttpFetchError } from './http_fetch_error'; import { HttpInterceptController } from './http_intercept_controller'; import { interceptRequest, interceptResponse } from './intercept'; import { HttpInterceptHaltError } from './http_intercept_halt_error'; -import { ExecutionContextContainer } from '../execution_context'; +import { ExecutionContextContainer, ExecutionContextSetup } from '../execution_context'; interface Params { basePath: IBasePath; kibanaVersion: string; + executionContext: ExecutionContextSetup; } const JSON_CONTENT = /^(application\/(json|x-javascript)|text\/(x-)?javascript|x-json)(;.*)?$/; @@ -107,6 +108,7 @@ export class Fetch { }; private createRequest(options: HttpFetchOptionsWithPath): Request { + const context = this.params.executionContext.withGlobalContext(options.context); // Merge and destructure options out that are not applicable to the Fetch API. const { query, @@ -125,7 +127,7 @@ export class Fetch { 'Content-Type': 'application/json', ...options.headers, 'kbn-version': this.params.kibanaVersion, - ...(options.context ? new ExecutionContextContainer(options.context).toHeader() : {}), + ...(!isEmpty(context) ? new ExecutionContextContainer(context).toHeader() : {}), }), }; diff --git a/src/core/public/http/http_service.test.ts b/src/core/public/http/http_service.test.ts index 2b41991904d9..698fa876433d 100644 --- a/src/core/public/http/http_service.test.ts +++ b/src/core/public/http/http_service.test.ts @@ -14,6 +14,7 @@ import { fatalErrorsServiceMock } from '../fatal_errors/fatal_errors_service.moc import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { HttpService } from './http_service'; import { Observable } from 'rxjs'; +import { executionContextServiceMock } from '../execution_context/execution_context_service.mock'; describe('interceptors', () => { afterEach(() => fetchMock.restore()); @@ -22,9 +23,10 @@ describe('interceptors', () => { fetchMock.get('*', {}); const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); const fatalErrors = fatalErrorsServiceMock.createSetupContract(); + const executionContext = executionContextServiceMock.createSetupContract(); const httpService = new HttpService(); - const setup = httpService.setup({ fatalErrors, injectedMetadata }); + const setup = httpService.setup({ fatalErrors, injectedMetadata, executionContext }); const setupInterceptor = jest.fn(); setup.intercept({ request: setupInterceptor }); @@ -47,7 +49,8 @@ describe('#setup()', () => { const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); const fatalErrors = fatalErrorsServiceMock.createSetupContract(); const httpService = new HttpService(); - httpService.setup({ fatalErrors, injectedMetadata }); + const executionContext = executionContextServiceMock.createSetupContract(); + httpService.setup({ fatalErrors, injectedMetadata, executionContext }); const loadingServiceSetup = loadingServiceMock.setup.mock.results[0].value; // We don't verify that this Observable comes from Fetch#getLoadingCount$() to avoid complex mocking expect(loadingServiceSetup.addLoadingCountSource).toHaveBeenCalledWith(expect.any(Observable)); @@ -59,7 +62,8 @@ describe('#stop()', () => { const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); const fatalErrors = fatalErrorsServiceMock.createSetupContract(); const httpService = new HttpService(); - httpService.setup({ fatalErrors, injectedMetadata }); + const executionContext = executionContextServiceMock.createSetupContract(); + httpService.setup({ fatalErrors, injectedMetadata, executionContext }); httpService.start(); httpService.stop(); expect(loadingServiceMock.stop).toHaveBeenCalled(); diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index a9719cfce67a..390130da4e07 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -15,10 +15,12 @@ import { LoadingCountService } from './loading_count_service'; import { Fetch } from './fetch'; import { CoreService } from '../../types'; import { ExternalUrlService } from './external_url_service'; +import { ExecutionContextSetup } from '../execution_context'; interface HttpDeps { injectedMetadata: InjectedMetadataSetup; fatalErrors: FatalErrorsSetup; + executionContext: ExecutionContextSetup; } /** @internal */ @@ -27,14 +29,15 @@ export class HttpService implements CoreService { private readonly loadingCount = new LoadingCountService(); private service?: HttpSetup; - public setup({ injectedMetadata, fatalErrors }: HttpDeps): HttpSetup { + public setup({ injectedMetadata, fatalErrors, executionContext }: HttpDeps): HttpSetup { const kibanaVersion = injectedMetadata.getKibanaVersion(); const basePath = new BasePath( injectedMetadata.getBasePath(), injectedMetadata.getServerBasePath(), injectedMetadata.getPublicBaseUrl() ); - const fetchService = new Fetch({ basePath, kibanaVersion }); + + const fetchService = new Fetch({ basePath, kibanaVersion, executionContext }); const loadingCount = this.loadingCount.setup({ fatalErrors }); loadingCount.addLoadingCountSource(fetchService.getRequestCount$()); diff --git a/src/core/public/index.ts b/src/core/public/index.ts index ded7db9c6f89..902f93d108aa 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -52,19 +52,14 @@ import { HttpSetup, HttpStart } from './http'; import { I18nStart } from './i18n'; import { NotificationsSetup, NotificationsStart } from './notifications'; import { OverlayStart } from './overlays'; -import { - Plugin, - AsyncPlugin, - PluginInitializer, - PluginInitializerContext, - PluginOpaqueId, -} from './plugins'; +import { Plugin, PluginInitializer, PluginInitializerContext, PluginOpaqueId } from './plugins'; import { UiSettingsState, IUiSettingsClient } from './ui_settings'; import { ApplicationSetup, Capabilities, ApplicationStart } from './application'; import { DocLinksStart } from './doc_links'; import { SavedObjectsStart } from './saved_objects'; import { DeprecationsServiceStart } from './deprecations'; import type { ThemeServiceSetup, ThemeServiceStart } from './theme'; +import { ExecutionContextSetup, ExecutionContextStart } from './execution_context'; export type { PackageInfo, @@ -194,7 +189,11 @@ export type { MountPoint, UnmountCallback, PublicUiSettingsParams } from './type export { URL_MAX_LENGTH } from './core_app'; -export type { KibanaExecutionContext } from './execution_context'; +export type { + KibanaExecutionContext, + ExecutionContextSetup, + ExecutionContextStart, +} from './execution_context'; /** * Core services exposed to the `Plugin` setup lifecycle @@ -221,6 +220,8 @@ export interface CoreSetup, any, any]>, []>(() => Promise.resolve([createCoreStartMock({ basePath }), pluginStartDeps, pluginStartContract]) @@ -76,6 +78,7 @@ function createCoreStartMock({ basePath = '' } = {}) { application: applicationServiceMock.createStartContract(), chrome: chromeServiceMock.createStartContract(), docLinks: docLinksServiceMock.createStartContract(), + executionContext: executionContextServiceMock.createStartContract(), http: httpServiceMock.createStartContract({ basePath }), i18n: i18nServiceMock.createStartContract(), notifications: notificationServiceMock.createStartContract(), diff --git a/src/core/public/plugins/index.ts b/src/core/public/plugins/index.ts index 9d7a61ef47a0..976ec660ac9b 100644 --- a/src/core/public/plugins/index.ts +++ b/src/core/public/plugins/index.ts @@ -7,6 +7,6 @@ */ export { PluginsService } from './plugins_service'; -export type { Plugin, AsyncPlugin, PluginInitializer } from './plugin'; +export type { Plugin, PluginInitializer } from './plugin'; export type { PluginInitializerContext } from './plugin_context'; export type { PluginOpaqueId } from '../../server/types'; diff --git a/src/core/public/plugins/plugin.test.ts b/src/core/public/plugins/plugin.test.ts index 8deef6ac9f72..2c3bfc6961fb 100644 --- a/src/core/public/plugins/plugin.test.ts +++ b/src/core/public/plugins/plugin.test.ts @@ -94,9 +94,7 @@ describe('PluginWrapper', () => { mockPluginReader.mockReturnValueOnce( jest.fn(() => ({ setup: jest.fn(), - start: jest.fn(async () => { - // Add small delay to ensure startDependencies is not resolved until after the plugin instance's start resolves. - await new Promise((resolve) => setTimeout(resolve, 10)); + start: jest.fn(() => { expect(startDependenciesResolved).toBe(false); return pluginStartContract; }), diff --git a/src/core/public/plugins/plugin.ts b/src/core/public/plugins/plugin.ts index a08a6cf0b431..a43cc2046b82 100644 --- a/src/core/public/plugins/plugin.ts +++ b/src/core/public/plugins/plugin.ts @@ -8,7 +8,6 @@ import { Subject } from 'rxjs'; import { first } from 'rxjs/operators'; -import { isPromise } from '@kbn/std'; import { DiscoveredPlugin, PluginOpaqueId } from '../../server'; import { PluginInitializerContext } from './plugin_context'; import { read } from './plugin_reader'; @@ -30,23 +29,6 @@ export interface Plugin< stop?(): void; } -/** - * A plugin with asynchronous lifecycle methods. - * - * @deprecated Asynchronous lifecycles are deprecated, and should be migrated to sync {@link Plugin | plugin} - * @public - */ -export interface AsyncPlugin< - TSetup = void, - TStart = void, - TPluginsSetup extends object = object, - TPluginsStart extends object = object -> { - setup(core: CoreSetup, plugins: TPluginsSetup): TSetup | Promise; - start(core: CoreStart, plugins: TPluginsStart): TStart | Promise; - stop?(): void; -} - /** * The `plugin` export at the root of a plugin's `public` directory should conform * to this interface. @@ -58,11 +40,7 @@ export type PluginInitializer< TStart, TPluginsSetup extends object = object, TPluginsStart extends object = object -> = ( - core: PluginInitializerContext -) => - | Plugin - | AsyncPlugin; +> = (core: PluginInitializerContext) => Plugin; /** * Lightweight wrapper around discovered plugin that is responsible for instantiating @@ -80,9 +58,7 @@ export class PluginWrapper< public readonly configPath: DiscoveredPlugin['configPath']; public readonly requiredPlugins: DiscoveredPlugin['requiredPlugins']; public readonly optionalPlugins: DiscoveredPlugin['optionalPlugins']; - private instance?: - | Plugin - | AsyncPlugin; + private instance?: Plugin; private readonly startDependencies$ = new Subject<[CoreStart, TPluginsStart, TStart]>(); public readonly startDependencies = this.startDependencies$.pipe(first()).toPromise(); @@ -105,10 +81,7 @@ export class PluginWrapper< * @param plugins The dictionary where the key is the dependency name and the value * is the contract returned by the dependency's `setup` function. */ - public setup( - setupContext: CoreSetup, - plugins: TPluginsSetup - ): TSetup | Promise { + public setup(setupContext: CoreSetup, plugins: TPluginsSetup): TSetup { this.instance = this.createPluginInstance(); return this.instance.setup(setupContext, plugins); } @@ -126,15 +99,8 @@ export class PluginWrapper< } const startContract = this.instance.start(startContext, plugins); - if (isPromise(startContract)) { - return startContract.then((resolvedContract) => { - this.startDependencies$.next([startContext, plugins, resolvedContract]); - return resolvedContract; - }); - } else { - this.startDependencies$.next([startContext, plugins, startContract]); - return startContract; - } + this.startDependencies$.next([startContext, plugins, startContract]); + return startContract; } /** diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 345aea4b6cac..8c085d3de236 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -88,6 +88,7 @@ export function createPluginSetupContext< registerAppUpdater: (statusUpdater$) => deps.application.registerAppUpdater(statusUpdater$), }, fatalErrors: deps.fatalErrors, + executionContext: deps.executionContext, http: deps.http, notifications: deps.notifications, uiSettings: deps.uiSettings, @@ -129,6 +130,7 @@ export function createPluginStartContext< getUrlForApp: deps.application.getUrlForApp, }, docLinks: deps.docLinks, + executionContext: deps.executionContext, http: deps.http, chrome: omit(deps.chrome, 'getComponent'), i18n: deps.i18n, diff --git a/src/core/public/plugins/plugins_service.test.mocks.ts b/src/core/public/plugins/plugins_service.test.mocks.ts index 1858008e2801..801ea20015f2 100644 --- a/src/core/public/plugins/plugins_service.test.mocks.ts +++ b/src/core/public/plugins/plugins_service.test.mocks.ts @@ -7,12 +7,9 @@ */ import { PluginName } from 'kibana/server'; -import { Plugin, AsyncPlugin } from './plugin'; +import { Plugin } from './plugin'; -export type MockedPluginInitializer = jest.Mock< - Plugin | AsyncPlugin, - any ->; +export type MockedPluginInitializer = jest.Mock>; export const mockPluginInitializerProvider: jest.Mock = jest .fn() diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index c4e3b7990ba3..390cba409257 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -36,6 +36,7 @@ import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock'; import { deprecationsServiceMock } from '../deprecations/deprecations_service.mock'; import { themeServiceMock } from '../theme/theme_service.mock'; +import { executionContextServiceMock } from '../execution_context/execution_context_service.mock'; export let mockPluginInitializers: Map; @@ -85,6 +86,7 @@ describe('PluginsService', () => { mockSetupDeps = { application: applicationServiceMock.createInternalSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), + executionContext: executionContextServiceMock.createSetupContract(), http: httpServiceMock.createSetupContract(), injectedMetadata: injectedMetadataServiceMock.createStartContract(), notifications: notificationServiceMock.createSetupContract(), @@ -100,6 +102,7 @@ describe('PluginsService', () => { mockStartDeps = { application: applicationServiceMock.createInternalStartContract(), docLinks: docLinksServiceMock.createStartContract(), + executionContext: executionContextServiceMock.createStartContract(), http: httpServiceMock.createStartContract(), chrome: chromeServiceMock.createStartContract(), i18n: i18nServiceMock.createStartContract(), @@ -248,36 +251,6 @@ describe('PluginsService', () => { expect((contracts.get('pluginA')! as any).setupValue).toEqual(1); expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(2); }); - - describe('timeout', () => { - const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); - beforeAll(() => { - jest.useFakeTimers(); - }); - afterAll(() => { - jest.useRealTimers(); - }); - - it('throws timeout error if "setup" was not completed in 30 sec.', async () => { - mockPluginInitializers.set( - 'pluginA', - jest.fn(() => ({ - setup: jest.fn(() => new Promise((i) => i)), - start: jest.fn(() => ({ value: 1 })), - stop: jest.fn(), - })) - ); - const pluginsService = new PluginsService(mockCoreContext, plugins); - const promise = pluginsService.setup(mockSetupDeps); - - await flushPromises(); - jest.runAllTimers(); // setup plugins - - await expect(promise).rejects.toMatchInlineSnapshot( - `[Error: Setup lifecycle of "pluginA" plugin wasn't completed in 10sec. Consider disabling the plugin and re-start.]` - ); - }); - }); }); describe('#start()', () => { @@ -330,34 +303,6 @@ describe('PluginsService', () => { expect((contracts.get('pluginA')! as any).startValue).toEqual(2); expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(3); }); - describe('timeout', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - afterAll(() => { - jest.useRealTimers(); - }); - - it('throws timeout error if "start" was not completed in 30 sec.', async () => { - mockPluginInitializers.set( - 'pluginA', - jest.fn(() => ({ - setup: jest.fn(() => ({ value: 1 })), - start: jest.fn(() => new Promise((i) => i)), - stop: jest.fn(), - })) - ); - const pluginsService = new PluginsService(mockCoreContext, plugins); - await pluginsService.setup(mockSetupDeps); - - const promise = pluginsService.start(mockStartDeps); - jest.runAllTimers(); - - await expect(promise).rejects.toMatchInlineSnapshot( - `[Error: Start lifecycle of "pluginA" plugin wasn't completed in 10sec. Consider disabling the plugin and re-start.]` - ); - }); - }); }); describe('#stop()', () => { @@ -376,124 +321,4 @@ describe('PluginsService', () => { expect(pluginCInstance.stop).toHaveBeenCalled(); }); }); - - describe('asynchronous plugins', () => { - let consoleSpy: jest.SpyInstance; - - beforeEach(() => { - consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - const runScenario = async ({ - production, - asyncSetup, - asyncStart, - }: { - production: boolean; - asyncSetup: boolean; - asyncStart: boolean; - }) => { - const coreContext = coreMock.createCoreContext({ production }); - - const syncPlugin = { id: 'sync-plugin', plugin: createManifest('sync-plugin') }; - mockPluginInitializers.set( - 'sync-plugin', - jest.fn(() => ({ - setup: jest.fn(() => 'setup-sync'), - start: jest.fn(() => 'start-sync'), - stop: jest.fn(), - })) - ); - - const asyncPlugin = { id: 'async-plugin', plugin: createManifest('async-plugin') }; - mockPluginInitializers.set( - 'async-plugin', - jest.fn(() => ({ - setup: jest.fn(() => (asyncSetup ? Promise.resolve('setup-async') : 'setup-sync')), - start: jest.fn(() => (asyncStart ? Promise.resolve('start-async') : 'start-sync')), - stop: jest.fn(), - })) - ); - - const pluginsService = new PluginsService(coreContext, [syncPlugin, asyncPlugin]); - - await pluginsService.setup(mockSetupDeps); - await pluginsService.start(mockStartDeps); - }; - - it('logs a warning if a plugin returns a promise from its setup contract in dev mode', async () => { - await runScenario({ - production: false, - asyncSetup: true, - asyncStart: false, - }); - - expect(consoleSpy.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "Plugin async-plugin is using asynchronous setup lifecycle. Asynchronous plugins support will be removed in a later version.", - ], - ] - `); - }); - - it('does not log warnings if a plugin returns a promise from its setup contract in prod mode', async () => { - await runScenario({ - production: true, - asyncSetup: true, - asyncStart: false, - }); - - expect(consoleSpy).not.toHaveBeenCalled(); - }); - - it('logs a warning if a plugin returns a promise from its start contract in dev mode', async () => { - await runScenario({ - production: false, - asyncSetup: false, - asyncStart: true, - }); - - expect(consoleSpy.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "Plugin async-plugin is using asynchronous start lifecycle. Asynchronous plugins support will be removed in a later version.", - ], - ] - `); - }); - - it('does not log warnings if a plugin returns a promise from its start contract in prod mode', async () => { - await runScenario({ - production: true, - asyncSetup: false, - asyncStart: true, - }); - - expect(consoleSpy).not.toHaveBeenCalled(); - }); - - it('logs multiple warnings if both `setup` and `start` return promises', async () => { - await runScenario({ - production: false, - asyncSetup: true, - asyncStart: true, - }); - - expect(consoleSpy.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "Plugin async-plugin is using asynchronous setup lifecycle. Asynchronous plugins support will be removed in a later version.", - ], - Array [ - "Plugin async-plugin is using asynchronous start lifecycle. Asynchronous plugins support will be removed in a later version.", - ], - ] - `); - }); - }); }); diff --git a/src/core/public/plugins/plugins_service.ts b/src/core/public/plugins/plugins_service.ts index 230a675b4cda..51af5a831d44 100644 --- a/src/core/public/plugins/plugins_service.ts +++ b/src/core/public/plugins/plugins_service.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { withTimeout, isPromise } from '@kbn/std'; import { PluginName, PluginOpaqueId } from '../../server'; import { CoreService } from '../../types'; import { CoreContext } from '../core_system'; @@ -19,7 +18,6 @@ import { import { InternalCoreSetup, InternalCoreStart } from '../core_system'; import { InjectedPluginMetadata } from '../injected_metadata'; -const Sec = 1000; /** @internal */ export type PluginsServiceSetupDeps = InternalCoreSetup; /** @internal */ @@ -98,34 +96,10 @@ export class PluginsService implements CoreService ); - let contract: unknown; - const contractOrPromise = plugin.setup( + const contract = plugin.setup( createPluginSetupContext(this.coreContext, deps, plugin), pluginDepContracts ); - if (isPromise(contractOrPromise)) { - if (this.coreContext.env.mode.dev) { - // eslint-disable-next-line no-console - console.log( - `Plugin ${pluginName} is using asynchronous setup lifecycle. Asynchronous plugins support will be removed in a later version.` - ); - } - - const contractMaybe = await withTimeout({ - promise: contractOrPromise, - timeoutMs: 10 * Sec, - }); - - if (contractMaybe.timedout) { - throw new Error( - `Setup lifecycle of "${pluginName}" plugin wasn't completed in 10sec. Consider disabling the plugin and re-start.` - ); - } else { - contract = contractMaybe.value; - } - } else { - contract = contractOrPromise; - } contracts.set(pluginName, contract); this.satupPlugins.push(pluginName); @@ -152,34 +126,10 @@ export class PluginsService implements CoreService ); - let contract: unknown; - const contractOrPromise = plugin.start( + const contract = plugin.start( createPluginStartContext(this.coreContext, deps, plugin), pluginDepContracts ); - if (isPromise(contractOrPromise)) { - if (this.coreContext.env.mode.dev) { - // eslint-disable-next-line no-console - console.log( - `Plugin ${pluginName} is using asynchronous start lifecycle. Asynchronous plugins support will be removed in a later version.` - ); - } - - const contractMaybe = await withTimeout({ - promise: contractOrPromise, - timeoutMs: 10 * Sec, - }); - - if (contractMaybe.timedout) { - throw new Error( - `Start lifecycle of "${pluginName}" plugin wasn't completed in 10sec. Consider disabling the plugin and re-start.` - ); - } else { - contract = contractMaybe.value; - } - } else { - contract = contractOrPromise; - } contracts.set(pluginName, contract); } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 4cf845de4617..6145cce3912f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -209,16 +209,6 @@ export type AppUpdatableFields = Pick Partial | undefined; -// @public @deprecated -export interface AsyncPlugin { - // (undocumented) - setup(core: CoreSetup, plugins: TPluginsSetup): TSetup | Promise; - // (undocumented) - start(core: CoreStart, plugins: TPluginsStart): TStart | Promise; - // (undocumented) - stop?(): void; -} - // @public export interface Capabilities { [key: string]: Record>; @@ -401,6 +391,8 @@ export interface CoreSetup; @@ -429,6 +421,8 @@ export interface CoreStart { // (undocumented) docLinks: DocLinksStart; // (undocumented) + executionContext: ExecutionContextStart; + // (undocumented) fatalErrors: FatalErrorsStart; // (undocumented) http: HttpStart; @@ -461,6 +455,7 @@ export class CoreSystem { // (undocumented) start(): Promise<{ application: InternalApplicationStart; + executionContext: ExecutionContextSetup; } | undefined>; // (undocumented) stop(): void; @@ -511,6 +506,20 @@ export interface ErrorToastOptions extends ToastOptions { toastMessage?: string; } +// @public +export interface ExecutionContextSetup { + clear(): void; + context$: Observable; + get(): KibanaExecutionContext; + // Warning: (ae-forgotten-export) The symbol "Labels" needs to be exported by the entry point index.d.ts + getAsLabels(): Labels_2; + set(c$: KibanaExecutionContext): void; + withGlobalContext(context?: KibanaExecutionContext): KibanaExecutionContext; +} + +// @public +export type ExecutionContextStart = ExecutionContextSetup; + // @public export interface FatalErrorInfo { // (undocumented) @@ -751,9 +760,10 @@ export interface IUiSettingsClient { // @public export type KibanaExecutionContext = { - readonly type: string; - readonly name: string; - readonly id: string; + readonly type?: string; + readonly name?: string; + readonly page?: string; + readonly id?: string; readonly description?: string; readonly url?: string; child?: KibanaExecutionContext; @@ -900,7 +910,7 @@ interface Plugin_2 = (core: PluginInitializerContext) => Plugin_2 | AsyncPlugin; +export type PluginInitializer = (core: PluginInitializerContext) => Plugin_2; // @public export interface PluginInitializerContext { @@ -1108,7 +1118,7 @@ export class SavedObjectsClient { }>) => Promise<{ resolved_objects: ResolvedSimpleSavedObject[]; }>; - bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; + bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; create: (type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise>; // Warning: (ae-forgotten-export) The symbol "SavedObjectsDeleteOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "SavedObjectsClientContract" needs to be exported by the entry point index.d.ts @@ -1235,8 +1245,6 @@ export interface SavedObjectsImportFailure { icon?: string; }; overwrite?: boolean; - // @deprecated (undocumented) - title?: string; // (undocumented) type: string; } @@ -1381,7 +1389,7 @@ export class ScopedHistory implements History_2< // @public export class SimpleSavedObject { - constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, }: SavedObject); + constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, updated_at: updatedAt, }: SavedObject); // (undocumented) attributes: T; // (undocumented) @@ -1408,6 +1416,8 @@ export class SimpleSavedObject { // (undocumented) type: SavedObject['type']; // (undocumented) + updatedAt: SavedObject['updated_at']; + // (undocumented) _version?: SavedObject['version']; } @@ -1522,6 +1532,6 @@ export interface UserProvidedValues { // Warnings were encountered during analysis: // -// src/core/public/core_system.ts:173:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts +// src/core/public/core_system.ts:183:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts ``` diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index c19233809a94..8509ace04769 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -596,7 +596,7 @@ export class SavedObjectsClient { return renameKeys< PromiseType>, SavedObjectsBatchResponse - >({ saved_objects: 'savedObjects' }, resp) as SavedObjectsBatchResponse; + >({ saved_objects: 'savedObjects' }, resp) as SavedObjectsBatchResponse; }); } diff --git a/src/core/public/saved_objects/simple_saved_object.test.ts b/src/core/public/saved_objects/simple_saved_object.test.ts index b9338f4dc38b..432bc215a5b4 100644 --- a/src/core/public/saved_objects/simple_saved_object.test.ts +++ b/src/core/public/saved_objects/simple_saved_object.test.ts @@ -45,4 +45,67 @@ describe('SimpleSavedObject', () => { const savedObject = new SimpleSavedObject(client, { version } as SavedObject); expect(savedObject._version).toEqual(version); }); + + it('save() changes updatedAt field on existing SimpleSavedObject with an id', async function () { + const date = new Date(); + const initialDate = date.toISOString(); + date.setDate(date.getDate() + 1); + const secondDate = date.toISOString(); + + const config = { + attributes: {}, + id: 'id', + type: 'type', + }; + + const initialSavedObject = new SimpleSavedObject(client, { + ...config, + updated_at: initialDate, + } as SavedObject); + + const updatedSavedObject = new SimpleSavedObject(client, { + ...config, + updated_at: secondDate, + } as SavedObject); + + (client.update as jest.Mock).mockReturnValue(Promise.resolve(updatedSavedObject)); + + const initialValue = initialSavedObject.updatedAt; + await initialSavedObject.save(); + const updatedValue = updatedSavedObject.updatedAt; + + expect(initialValue).not.toEqual(updatedValue); + expect(initialSavedObject.updatedAt).toEqual(updatedValue); + }); + + it('save() changes updatedAt field on existing SimpleSavedObject without an id', async () => { + const date = new Date(); + const initialDate = date.toISOString(); + date.setDate(date.getDate() + 1); + const secondDate = date.toISOString(); + + const config = { + attributes: {}, + type: 'type', + }; + + const initialSavedObject = new SimpleSavedObject(client, { + ...config, + updated_at: initialDate, + } as SavedObject); + + const updatedSavedObject = new SimpleSavedObject(client, { + ...config, + updated_at: secondDate, + } as SavedObject); + + (client.create as jest.Mock).mockReturnValue(Promise.resolve(updatedSavedObject)); + + const initialValue = initialSavedObject.updatedAt; + await initialSavedObject.save(); + const updatedValue = updatedSavedObject.updatedAt; + + expect(initialValue).not.toEqual(updatedValue); + expect(initialSavedObject.updatedAt).toEqual(updatedValue); + }); }); diff --git a/src/core/public/saved_objects/simple_saved_object.ts b/src/core/public/saved_objects/simple_saved_object.ts index 449d3d7943fc..512c6c765674 100644 --- a/src/core/public/saved_objects/simple_saved_object.ts +++ b/src/core/public/saved_objects/simple_saved_object.ts @@ -30,6 +30,7 @@ export class SimpleSavedObject { public coreMigrationVersion: SavedObjectType['coreMigrationVersion']; public error: SavedObjectType['error']; public references: SavedObjectType['references']; + public updatedAt: SavedObjectType['updated_at']; /** * Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with * `namespaceType: 'agnostic'`. @@ -48,6 +49,7 @@ export class SimpleSavedObject { migrationVersion, coreMigrationVersion, namespaces, + updated_at: updatedAt, }: SavedObjectType ) { this.id = id; @@ -58,6 +60,7 @@ export class SimpleSavedObject { this.migrationVersion = migrationVersion; this.coreMigrationVersion = coreMigrationVersion; this.namespaces = namespaces; + this.updatedAt = updatedAt; if (error) { this.error = error; } @@ -77,15 +80,25 @@ export class SimpleSavedObject { public save(): Promise> { if (this.id) { - return this.client.update(this.type, this.id, this.attributes, { - references: this.references, - }); + return this.client + .update(this.type, this.id, this.attributes, { + references: this.references, + }) + .then((sso) => { + this.updatedAt = sso.updatedAt; + return sso; + }); } else { - return this.client.create(this.type, this.attributes, { - migrationVersion: this.migrationVersion, - coreMigrationVersion: this.coreMigrationVersion, - references: this.references, - }); + return this.client + .create(this.type, this.attributes, { + migrationVersion: this.migrationVersion, + coreMigrationVersion: this.coreMigrationVersion, + references: this.references, + }) + .then((sso) => { + this.updatedAt = sso.updatedAt; + return sso; + }); } } diff --git a/src/core/server/elasticsearch/client/types.ts b/src/core/server/elasticsearch/client/types.ts index 68fbc8719307..17248e491962 100644 --- a/src/core/server/elasticsearch/client/types.ts +++ b/src/core/server/elasticsearch/client/types.ts @@ -15,7 +15,7 @@ import type { Client } from '@elastic/elasticsearch'; */ export type ElasticsearchClient = Omit< Client, - 'connectionPool' | 'serializer' | 'extend' | 'child' | 'close' | 'diagnostic' + 'connectionPool' | 'serializer' | 'extend' | 'close' | 'diagnostic' >; /** diff --git a/src/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index f217dbe35c7e..3ef44e2690a9 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -37,9 +37,6 @@ export type MockedElasticSearchServiceSetup = jest.Mocked< }; export interface MockedElasticSearchServiceStart { - legacy: { - config$: BehaviorSubject; - }; client: ClusterClientMock; createClient: jest.MockedFunction< (name: string, config?: Partial) => CustomClusterClientMock @@ -71,9 +68,6 @@ const createStartContractMock = () => { const startContract: MockedElasticSearchServiceStart = { client: elasticsearchClientMock.createClusterClient(), createClient: jest.fn(), - legacy: { - config$: new BehaviorSubject({} as ElasticsearchConfig), - }, }; startContract.createClient.mockImplementation(() => diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index 642b0ab75eaa..4ab59d12942e 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -141,9 +141,6 @@ export class ElasticsearchService return { client: this.client!, createClient: (type, clientConfig) => this.createClusterClient(type, config, clientConfig), - legacy: { - config$: this.config$, - }, }; } diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index c37e33426f7b..ad26eb427edb 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -81,7 +81,6 @@ export interface ElasticsearchServiceSetup { /** * @deprecated - * Use {@link ElasticsearchServiceStart.legacy} instead. */ legacy: { /** @@ -136,20 +135,6 @@ export interface ElasticsearchServiceStart { type: string, clientConfig?: Partial ) => ICustomClusterClient; - - /** - * @deprecated - * Provided for the backward compatibility. - * Switch to the new elasticsearch client as soon as https://github.com/elastic/kibana/issues/35508 done. - * */ - legacy: { - /** - * Provide direct access to the current elasticsearch configuration. - * - * @deprecated this will be removed in a later version. - */ - readonly config$: Observable; - }; } /** diff --git a/src/core/server/execution_context/execution_context_container.ts b/src/core/server/execution_context/execution_context_container.ts index de895bcff5ec..1528df6c2314 100644 --- a/src/core/server/execution_context/execution_context_container.ts +++ b/src/core/server/execution_context/execution_context_container.ts @@ -50,9 +50,10 @@ export interface IExecutionContextContainer { } function stringify(ctx: KibanaExecutionContext): string { - const stringifiedCtx = `${encodeURIComponent(ctx.type)}:${encodeURIComponent( + const encodeURIComponentIfNotEmpty = (val?: string) => encodeURIComponent(val || ''); + const stringifiedCtx = `${encodeURIComponentIfNotEmpty(ctx.type)}:${encodeURIComponentIfNotEmpty( ctx.name - )}:${encodeURIComponent(ctx.id!)}`; + )}:${encodeURIComponentIfNotEmpty(ctx.id)}`; return ctx.child ? `${stringifiedCtx};${stringify(ctx.child)}` : stringifiedCtx; } diff --git a/src/core/server/execution_context/execution_context_service.mock.ts b/src/core/server/execution_context/execution_context_service.mock.ts index 68aab7a5b84f..85768eb423f2 100644 --- a/src/core/server/execution_context/execution_context_service.mock.ts +++ b/src/core/server/execution_context/execution_context_service.mock.ts @@ -26,6 +26,7 @@ const createExecutionContextMock = () => { get: jest.fn(), getParentContextFrom: jest.fn(), getAsHeader: jest.fn(), + getAsLabels: jest.fn(), }; mock.withContext.mockImplementation(withContextMock); return mock; @@ -38,6 +39,7 @@ const createInternalSetupContractMock = () => { const createSetupContractMock = () => { const mock: jest.Mocked = { withContext: jest.fn(), + getAsLabels: jest.fn(), }; mock.withContext.mockImplementation(withContextMock); return mock; diff --git a/src/core/server/execution_context/execution_context_service.ts b/src/core/server/execution_context/execution_context_service.ts index 6e2b809e2304..03ae2cb36c9e 100644 --- a/src/core/server/execution_context/execution_context_service.ts +++ b/src/core/server/execution_context/execution_context_service.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ import { AsyncLocalStorage } from 'async_hooks'; +import apm from 'elastic-apm-node'; +import { isUndefined, omitBy } from 'lodash'; import type { Subscription } from 'rxjs'; import type { CoreService, KibanaExecutionContext } from '../../types'; @@ -39,6 +41,10 @@ export interface IExecutionContext { * returns serialized representation to send as a header **/ getAsHeader(): string | undefined; + /** + * returns apm labels + **/ + getAsLabels(): apm.Labels; } /** @@ -61,6 +67,7 @@ export interface ExecutionContextSetup { * The nested calls stack the registered context on top of each other. **/ withContext(context: KibanaExecutionContext | undefined, fn: (...args: any[]) => R): R; + getAsLabels(): apm.Labels; } /** @@ -97,6 +104,7 @@ export class ExecutionContextService setRequestId: this.setRequestId.bind(this), get: this.get.bind(this), getAsHeader: this.getAsHeader.bind(this), + getAsLabels: this.getAsLabels.bind(this), }; } @@ -108,6 +116,7 @@ export class ExecutionContextService withContext: this.withContext.bind(this), get: this.get.bind(this), getAsHeader: this.getAsHeader.bind(this), + getAsLabels: this.getAsLabels.bind(this), }; } @@ -161,4 +170,18 @@ export class ExecutionContextService return `${requestId}${executionContextStr}`; } + + private getAsLabels() { + if (!this.enabled) return {}; + const executionContext = this.contextStore.getStore()?.toJSON(); + + return omitBy( + { + name: executionContext?.name, + id: executionContext?.id, + page: executionContext?.page, + }, + isUndefined + ); + } } diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 4623b09b19e2..813f8e978433 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -21,6 +21,7 @@ import agent from 'elastic-apm-node'; import type { Duration } from 'moment'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; +import apm from 'elastic-apm-node'; import { Logger, LoggerFactory } from '../logging'; import { HttpConfig } from './http_config'; import type { InternalExecutionContextSetup } from '../execution_context'; @@ -338,7 +339,11 @@ export class HttpServer { const requestId = getRequestId(request, config.requestId); const parentContext = executionContext?.getParentContextFrom(request.headers); - if (parentContext) executionContext?.set(parentContext); + + if (executionContext && parentContext) { + executionContext.set(parentContext); + apm.addLabels(executionContext.getAsLabels()); + } executionContext?.setRequestId(requestId); diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 14baf9ba9257..f251d3fb64ca 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -179,10 +179,6 @@ const createSetupContractMock = () => { csp: CspConfig.DEFAULT, createRouter: jest.fn(), registerRouteHandlerContext: jest.fn(), - auth: { - get: internalMock.auth.get, - isAuthenticated: internalMock.auth.isAuthenticated, - }, getServerInfo: internalMock.getServerInfo, }; diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index f12533dba428..e6d951410779 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -312,14 +312,6 @@ export interface HttpServiceSetup { */ basePath: IBasePath; - /** - * Auth status. - * See {@link HttpAuth} - * - * @deprecated use {@link HttpServiceStart.auth | the start contract} instead. - */ - auth: HttpAuth; - /** * The CSP config used for Kibana. */ diff --git a/src/core/server/metrics/integration_tests/server_collector.test.ts b/src/core/server/metrics/integration_tests/server_collector.test.ts index b64e7d55560d..713d3ed1dc96 100644 --- a/src/core/server/metrics/integration_tests/server_collector.test.ts +++ b/src/core/server/metrics/integration_tests/server_collector.test.ts @@ -17,10 +17,7 @@ import { executionContextServiceMock } from '../../execution_context/execution_c import { ServerMetricsCollector } from '../collectors/server'; import { setTimeout as setTimeoutPromise } from 'timers/promises'; -const requestWaitDelay = 25; - -// FLAKY: https://github.com/elastic/kibana/issues/59234 -describe.skip('ServerMetricsCollector', () => { +describe('ServerMetricsCollector', () => { let server: HttpService; let collector: ServerMetricsCollector; let hapiServer: HapiServer; @@ -79,30 +76,32 @@ describe.skip('ServerMetricsCollector', () => { it('collect disconnects requests infos', async () => { const never = new Promise((resolve) => undefined); - const hitSubject = new BehaviorSubject(0); + const disconnectRequested$ = new Subject(); // Controls the number of requests in the /disconnect endpoint + const disconnectAborted$ = new Subject(); // Controls the abort event in the /disconnect endpoint router.get({ path: '/', validate: false }, async (ctx, req, res) => { return res.ok({ body: '' }); }); router.get({ path: '/disconnect', validate: false }, async (ctx, req, res) => { - hitSubject.next(hitSubject.value + 1); - await never; + disconnectRequested$.next(); + req.events.aborted$.subscribe(() => { + disconnectAborted$.next(); + }); + await never; // Never resolve the request return res.ok({ body: '' }); }); await server.start(); await sendGet('/'); - // superTest.get(path).end needs to be called with a callback to actually send the request. - const discoReq1 = sendGet('/disconnect').end(() => null); - const discoReq2 = sendGet('/disconnect').end(() => null); - - await hitSubject - .pipe( - filter((count) => count >= 2), - take(1) - ) - .toPromise(); - await delay(requestWaitDelay); // wait for the requests to send + + // Subscribe to expect 2 requests to /disconnect + const waitFor2Requests = disconnectRequested$.pipe(take(2)).toPromise(); + + const discoReq1 = sendGet('/disconnect').end(); + const discoReq2 = sendGet('/disconnect').end(); + + // Wait for 2 requests to /disconnect + await waitFor2Requests; let metrics = await collector.collect(); expect(metrics.requests).toEqual( @@ -113,8 +112,13 @@ describe.skip('ServerMetricsCollector', () => { }) ); + // Subscribe to the aborted$ event + const waitFor1stAbort = disconnectAborted$.pipe(take(1)).toPromise(); + discoReq1.abort(); - await delay(requestWaitDelay); + + // Wait for the aborted$ event + await waitFor1stAbort; metrics = await collector.collect(); expect(metrics.requests).toEqual( @@ -124,8 +128,13 @@ describe.skip('ServerMetricsCollector', () => { }) ); + // Subscribe to the aborted$ event + const waitFor2ndAbort = disconnectAborted$.pipe(take(1)).toPromise(); + discoReq2.abort(); - await delay(requestWaitDelay); + + // Wait for the aborted$ event + await waitFor2ndAbort; metrics = await collector.collect(); expect(metrics.requests).toEqual( diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 18abbe88c491..56d749b79289 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -161,6 +161,7 @@ export function createPluginSetupContext( }, executionContext: { withContext: deps.executionContext.withContext, + getAsLabels: deps.executionContext.getAsLabels, }, http: { createCookieSessionStorageFactory: deps.http.createCookieSessionStorageFactory, @@ -180,10 +181,6 @@ export function createPluginSetupContext( registerOnPostAuth: deps.http.registerOnPostAuth, registerOnPreResponse: deps.http.registerOnPreResponse, basePath: deps.http.basePath, - auth: { - get: deps.http.auth.get, - isAuthenticated: deps.http.auth.isAuthenticated, - }, csp: deps.http.csp, getServerInfo: deps.http.getServerInfo, }, @@ -245,7 +242,6 @@ export function createPluginStartContext( elasticsearch: { client: deps.elasticsearch.client, createClient: deps.elasticsearch.createClient, - legacy: deps.elasticsearch.legacy, }, executionContext: deps.executionContext, http: { diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index 2f31b4cf3ead..046b9d3505cd 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -112,7 +112,6 @@ describe('#importSavedObjectsFromStream', () => { return { type: 'foo-type', id: uuidv4(), - title: 'some-title', meta: { title }, error: { type: 'conflict' }, }; diff --git a/src/core/server/saved_objects/import/lib/check_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_conflicts.test.ts index b2de6f11d5cb..e11e88001655 100644 --- a/src/core/server/saved_objects/import/lib/check_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_conflicts.test.ts @@ -108,13 +108,11 @@ describe('#checkConflicts', () => { errors: [ { ...obj2Error, - title: obj2.attributes.title, meta: { title: obj2.attributes.title }, error: { type: 'conflict' }, }, { ...obj4Error, - title: obj4.attributes.title, meta: { title: obj4.attributes.title }, error: { ...obj4Error.error, type: 'unknown' }, }, @@ -136,7 +134,6 @@ describe('#checkConflicts', () => { errors: [ { ...obj4Error, - title: obj4.attributes.title, meta: { title: obj4.attributes.title }, error: { ...obj4Error.error, type: 'unknown' }, }, @@ -174,13 +171,11 @@ describe('#checkConflicts', () => { errors: [ { ...obj2Error, - title: obj2.attributes.title, meta: { title: obj2.attributes.title }, error: { type: 'conflict', destinationId: 'some-object-id' }, }, { ...obj4Error, - title: obj4.attributes.title, meta: { title: obj4.attributes.title }, error: { ...obj4Error.error, type: 'unknown' }, }, diff --git a/src/core/server/saved_objects/import/lib/check_conflicts.ts b/src/core/server/saved_objects/import/lib/check_conflicts.ts index c15c4302491b..3ab4b03e791b 100644 --- a/src/core/server/saved_objects/import/lib/check_conflicts.ts +++ b/src/core/server/saved_objects/import/lib/check_conflicts.ts @@ -80,10 +80,10 @@ export async function checkConflicts({ importStateMap.set(`${type}:${id}`, { destinationId: uuidv4(), omitOriginId }); filteredObjects.push(object); } else if (errorObj && errorObj.statusCode !== 409) { - errors.push({ type, id, title, meta: { title }, error: { ...errorObj, type: 'unknown' } }); + errors.push({ type, id, meta: { title }, error: { ...errorObj, type: 'unknown' } }); } else if (errorObj?.statusCode === 409 && !ignoreRegularConflicts && !overwrite) { const error = { type: 'conflict' as 'conflict', ...(destinationId && { destinationId }) }; - errors.push({ type, id, title, meta: { title }, error }); + errors.push({ type, id, meta: { title }, error }); } else { filteredObjects.push(object); if (errorObj?.statusCode === 409) { diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts index 12aa9f668a58..30f40e5e84c6 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts @@ -176,7 +176,6 @@ describe('#checkOriginConflicts', () => { ): SavedObjectsImportFailure => ({ type: object.type, id: object.id, - title: object.attributes.title, meta: { title: object.attributes.title }, error: { type: 'ambiguous_conflict', @@ -189,7 +188,6 @@ describe('#checkOriginConflicts', () => { ): SavedObjectsImportFailure => ({ type: object.type, id: object.id, - title: object.attributes?.title, meta: { title: object.attributes.title }, error: { type: 'conflict', diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts index 46d38f7c44e5..207eb59b126c 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts @@ -229,7 +229,6 @@ export async function checkOriginConflicts({ errors.push({ type, id, - title, meta: { title }, error: { type: 'conflict', @@ -253,7 +252,6 @@ export async function checkOriginConflicts({ errors.push({ type, id, - title, meta: { title }, error: { type: 'ambiguous_conflict', diff --git a/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts b/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts index b401d71ffe49..c0c2d9e6bfed 100644 --- a/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts @@ -157,7 +157,7 @@ describe('collectSavedObjects()', () => { const error = { type: 'unsupported_type' }; const { title } = obj1.attributes; - const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; + const errors = [{ error, type: obj1.type, id: obj1.id, meta: { title } }]; expect(result).toEqual({ collectedObjects: [], errors, importStateMap: new Map() }); }); @@ -174,7 +174,7 @@ describe('collectSavedObjects()', () => { ]); const error = { type: 'unsupported_type' }; const { title } = obj2.attributes; - const errors = [{ error, type: obj2.type, id: obj2.id, title, meta: { title } }]; + const errors = [{ error, type: obj2.type, id: obj2.id, meta: { title } }]; expect(result).toEqual({ collectedObjects, errors, importStateMap }); }); @@ -192,7 +192,7 @@ describe('collectSavedObjects()', () => { const error = { type: 'unsupported_type' }; const { title } = obj1.attributes; - const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; + const errors = [{ error, type: obj1.type, id: obj1.id, meta: { title } }]; expect(result).toEqual({ collectedObjects: [], errors, importStateMap: new Map() }); }); @@ -215,7 +215,7 @@ describe('collectSavedObjects()', () => { ]); const error = { type: 'unsupported_type' }; const { title } = obj1.attributes; - const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; + const errors = [{ error, type: obj1.type, id: obj1.id, meta: { title } }]; expect(result).toEqual({ collectedObjects, errors, importStateMap }); }); }); diff --git a/src/core/server/saved_objects/import/lib/collect_saved_objects.ts b/src/core/server/saved_objects/import/lib/collect_saved_objects.ts index 209ae5ecf283..66d75d7af507 100644 --- a/src/core/server/saved_objects/import/lib/collect_saved_objects.ts +++ b/src/core/server/saved_objects/import/lib/collect_saved_objects.ts @@ -49,7 +49,6 @@ export async function collectSavedObjects({ errors.push({ id: obj.id, type: obj.type, - title, meta: { title }, error: { type: 'unsupported_type', diff --git a/src/core/server/saved_objects/import/lib/extract_errors.test.ts b/src/core/server/saved_objects/import/lib/extract_errors.test.ts index f3a9c2d89537..a355094f0711 100644 --- a/src/core/server/saved_objects/import/lib/extract_errors.test.ts +++ b/src/core/server/saved_objects/import/lib/extract_errors.test.ts @@ -51,45 +51,42 @@ describe('extractErrors()', () => { ]; const result = extractErrors(savedObjects, savedObjects); expect(result).toMatchInlineSnapshot(` -Array [ - Object { - "error": Object { - "type": "conflict", - }, - "id": "2", - "meta": Object { - "title": "My Dashboard 2", - }, - "title": "My Dashboard 2", - "type": "dashboard", - }, - Object { - "error": Object { - "error": "Bad Request", - "message": "Bad Request", - "statusCode": 400, - "type": "unknown", - }, - "id": "3", - "meta": Object { - "title": "My Dashboard 3", - }, - "title": "My Dashboard 3", - "type": "dashboard", - }, - Object { - "error": Object { - "destinationId": "foo", - "type": "conflict", - }, - "id": "4", - "meta": Object { - "title": "My Dashboard 4", - }, - "title": "My Dashboard 4", - "type": "dashboard", - }, -] -`); + Array [ + Object { + "error": Object { + "type": "conflict", + }, + "id": "2", + "meta": Object { + "title": "My Dashboard 2", + }, + "type": "dashboard", + }, + Object { + "error": Object { + "error": "Bad Request", + "message": "Bad Request", + "statusCode": 400, + "type": "unknown", + }, + "id": "3", + "meta": Object { + "title": "My Dashboard 3", + }, + "type": "dashboard", + }, + Object { + "error": Object { + "destinationId": "foo", + "type": "conflict", + }, + "id": "4", + "meta": Object { + "title": "My Dashboard 4", + }, + "type": "dashboard", + }, + ] + `); }); }); diff --git a/src/core/server/saved_objects/import/lib/extract_errors.ts b/src/core/server/saved_objects/import/lib/extract_errors.ts index dbc88b01f9ae..12d7612a4f96 100644 --- a/src/core/server/saved_objects/import/lib/extract_errors.ts +++ b/src/core/server/saved_objects/import/lib/extract_errors.ts @@ -30,7 +30,6 @@ export function extractErrors( errors.push({ id: savedObject.id, type: savedObject.type, - title, meta: { title }, error: { type: 'conflict', @@ -42,7 +41,6 @@ export function extractErrors( errors.push({ id: savedObject.id, type: savedObject.type, - title, meta: { title }, error: { ...savedObject.error, diff --git a/src/core/server/saved_objects/import/lib/validate_references.ts b/src/core/server/saved_objects/import/lib/validate_references.ts index 69e036cf77a3..b454bf8c887a 100644 --- a/src/core/server/saved_objects/import/lib/validate_references.ts +++ b/src/core/server/saved_objects/import/lib/validate_references.ts @@ -116,7 +116,6 @@ export async function validateReferences(params: ValidateReferencesParams) { errorMap[`${type}:${id}`] = { id, type, - title, meta: { title }, error: { type: 'missing_references', references: missingReferences }, }; diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.ts index 5218def19852..2a228df80f2d 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.ts @@ -138,7 +138,6 @@ describe('#importSavedObjectsFromStream', () => { return { type: 'foo-type', id: uuidv4(), - title: 'some-title', meta: { title }, error: { type: 'conflict' }, }; diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index be1a67c93183..07ddc97db8a4 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -89,10 +89,6 @@ export interface SavedObjectsImportMissingReferencesError { export interface SavedObjectsImportFailure { id: string; type: string; - /** - * @deprecated Use `meta.title` instead - */ - title?: string; meta: { title?: string; icon?: string }; /** * If `overwrite` is specified, an attempt was made to overwrite an existing object. diff --git a/src/core/server/saved_objects/migrations/README.md b/src/core/server/saved_objects/migrations/README.md index 60bf84eef87a..d8382e4f0e06 100644 --- a/src/core/server/saved_objects/migrations/README.md +++ b/src/core/server/saved_objects/migrations/README.md @@ -133,6 +133,14 @@ is left out of the description for brevity. ## INIT ### Next action +`initAction` + +Check that replica allocation is enabled from cluster settings (`cluster.routing.allocation.enabled`). Migrations will fail when replica allocation is disabled during the bulk index operation that waits for all active shards. Migrations wait for all active shards to ensure that saved objects are replicated to protect against data loss. + +The Elasticsearch documentation mentions switching off replica allocation when restoring a cluster and this is a setting that might be overlooked when a restore is done. Migrations will fail early if replica allocation is incorrectly set to avoid adding a write block to the old index before running into a failure later. + +If replica allocation is set to 'all', the migration continues to fetch the saved object indices: + `fetchIndices` Fetch the saved object indices, mappings and aliases to find the source index @@ -140,17 +148,21 @@ and determine whether we’re migrating from a legacy index or a v1 migrations index. ### New control state -1. If `.kibana` and the version specific aliases both exists and are pointing +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` + + 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 + `.kibana_7.12.0_001` fail the migration + → `FATAL` + +2. If `.kibana` and the version specific aliases both exists and are pointing to the same index. This version's migration has already been completed. Since the same version could have plugins enabled at any time that would introduce new transforms or mappings. → `OUTDATED_DOCUMENTS_SEARCH` -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 -`.kibana_7.12.0_001` fail the migration - → `FATAL` - 3. If the `.kibana` alias exists we’re migrating from either a v1 or v2 index and the migration source index is the index the `.kibana` alias points to. → `WAIT_FOR_YELLOW_SOURCE` diff --git a/src/core/server/saved_objects/migrations/actions/index.ts b/src/core/server/saved_objects/migrations/actions/index.ts index 4e88e9c448d4..1123588309de 100644 --- a/src/core/server/saved_objects/migrations/actions/index.ts +++ b/src/core/server/saved_objects/migrations/actions/index.ts @@ -20,6 +20,9 @@ export { export type { RetryableEsClientError }; // actions/* imports +export type { InitActionParams, UnsupportedClusterRoutingAllocation } from './initialize_action'; +export { initAction } from './initialize_action'; + export type { FetchIndexResponse, FetchIndicesParams } from './fetch_indices'; export { fetchIndices } from './fetch_indices'; @@ -81,6 +84,8 @@ export type { export { updateAndPickupMappings } from './update_and_pickup_mappings'; import type { UnknownDocsFound } from './check_for_unknown_docs'; +import type { UnsupportedClusterRoutingAllocation } from './initialize_action'; + export type { CheckForUnknownDocsParams, UnknownDocsFound, @@ -143,6 +148,7 @@ export interface ActionErrorTypeMap { documents_transform_failed: DocumentsTransformFailed; request_entity_too_large_exception: RequestEntityTooLargeException; unknown_docs_found: UnknownDocsFound; + unsupported_cluster_routing_allocation: UnsupportedClusterRoutingAllocation; } /** 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 new file mode 100644 index 000000000000..7c75470b890a --- /dev/null +++ b/src/core/server/saved_objects/migrations/actions/initialize_action.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { initAction } from './initialize_action'; + +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 task = initAction({ client, indices: ['my_index'] }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrations/actions/initialize_action.ts b/src/core/server/saved_objects/migrations/actions/initialize_action.ts new file mode 100644 index 000000000000..73502382c9ca --- /dev/null +++ b/src/core/server/saved_objects/migrations/actions/initialize_action.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 * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Either from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +import { FetchIndexResponse, fetchIndices } from './fetch_indices'; + +const routingAllocationEnable = 'cluster.routing.allocation.enable'; +export interface ClusterRoutingAllocationEnabled { + clusterRoutingAllocationEnabled: boolean; +} + +export interface InitActionParams { + client: ElasticsearchClient; + indices: string[]; +} + +export interface UnsupportedClusterRoutingAllocation { + type: 'unsupported_cluster_routing_allocation'; +} + +export const checkClusterRoutingAllocationEnabledTask = + ({ + client, + }: { + client: ElasticsearchClient; + }): TaskEither.TaskEither => + () => { + return client.cluster + .getSettings({ + flat_settings: true, + }) + .then((settings) => { + const clusterRoutingAllocations: string[] = + settings?.transient?.[routingAllocationEnable] ?? + settings?.persistent?.[routingAllocationEnable] ?? + []; + + const clusterRoutingAllocationEnabled = + [...clusterRoutingAllocations].length === 0 || + [...clusterRoutingAllocations].every((s: string) => s === 'all'); // if set, only allow 'all' + + if (!clusterRoutingAllocationEnabled) { + return Either.left({ type: 'unsupported_cluster_routing_allocation' as const }); + } else { + return Either.right({}); + } + }) + .catch(catchRetryableEsClientErrors); + }; + +export const initAction = ({ + client, + indices, +}: InitActionParams): TaskEither.TaskEither< + RetryableEsClientError | UnsupportedClusterRoutingAllocation, + FetchIndexResponse +> => { + return pipe( + checkClusterRoutingAllocationEnabledTask({ client }), + TaskEither.chainW((value) => { + return fetchIndices({ client, indices }); + }) + ); +}; 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 ef84f0cb4923..bac8f491534f 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 @@ -14,7 +14,6 @@ import { cloneIndex, closePit, createIndex, - fetchIndices, openPit, OpenPitResponse, reindex, @@ -35,6 +34,7 @@ import { removeWriteBlock, transformDocs, waitForIndexStatusYellow, + initAction, } from '../../actions'; import * as Either from 'fp-ts/lib/Either'; import * as Option from 'fp-ts/lib/Option'; @@ -111,10 +111,20 @@ describe('migration actions', () => { await esServer.stop(); }); - describe('fetchIndices', () => { + describe('initAction', () => { + afterAll(async () => { + await client.cluster.putSettings({ + body: { + persistent: { + // Remove persistent test settings + cluster: { routing: { allocation: { enable: null } } }, + }, + }, + }); + }); it('resolves right empty record if no indices were found', async () => { expect.assertions(1); - const task = fetchIndices({ client, indices: ['no_such_index'] }); + const task = initAction({ client, indices: ['no_such_index'] }); await expect(task()).resolves.toMatchInlineSnapshot(` Object { "_tag": "Right", @@ -124,7 +134,7 @@ describe('migration actions', () => { }); it('resolves right record with found indices', async () => { expect.assertions(1); - const res = (await fetchIndices({ + const res = (await initAction({ client, indices: ['no_such_index', 'existing_index_with_docs'], })()) as Either.Right; @@ -139,6 +149,69 @@ describe('migration actions', () => { }) ); }); + it('resolves left with cluster routing allocation disabled', async () => { + expect.assertions(3); + await client.cluster.putSettings({ + body: { + persistent: { + // Disable all routing allocation + cluster: { routing: { allocation: { enable: 'none' } } }, + }, + }, + }); + const task = initAction({ + client, + indices: ['existing_index_with_docs'], + }); + await expect(task()).resolves.toMatchInlineSnapshot(` + Object { + "_tag": "Left", + "left": Object { + "type": "unsupported_cluster_routing_allocation", + }, + } + `); + await client.cluster.putSettings({ + body: { + persistent: { + // Allow routing to existing primaries only + cluster: { routing: { allocation: { enable: 'primaries' } } }, + }, + }, + }); + const task2 = initAction({ + client, + indices: ['existing_index_with_docs'], + }); + await expect(task2()).resolves.toMatchInlineSnapshot(` + Object { + "_tag": "Left", + "left": Object { + "type": "unsupported_cluster_routing_allocation", + }, + } + `); + await client.cluster.putSettings({ + body: { + persistent: { + // Allow routing to new primaries only + cluster: { routing: { allocation: { enable: 'new_primaries' } } }, + }, + }, + }); + const task3 = initAction({ + client, + indices: ['existing_index_with_docs'], + }); + await expect(task3()).resolves.toMatchInlineSnapshot(` + Object { + "_tag": "Left", + "left": Object { + "type": "unsupported_cluster_routing_allocation", + }, + } + `); + }); }); describe('setWriteBlock', () => { 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/cluster_routing_allocation_disabled.test.ts new file mode 100644 index 000000000000..0f4522b156fe --- /dev/null +++ b/src/core/server/saved_objects/migrations/integration_tests/cluster_routing_allocation_disabled.test.ts @@ -0,0 +1,155 @@ +/* + * Copyright 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 Path from 'path'; +import fs from 'fs/promises'; +import JSON5 from 'json5'; +import * as kbnTestServer from '../../../../test_helpers/kbn_server'; +import { Root } from '../../../root'; +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'); + +async function removeLogFile() { + // ignore errors if it doesn't exist + await fs.unlink(logFilePath).catch(() => void 0); +} + +const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + es: { + license: 'basic', + dataArchive: Path.join(__dirname, 'archives', '7.7.2_xpack_100k_obj.zip'), + }, + }, +}); + +function createKbnRoot() { + return kbnTestServer.createRootWithCorePlugins( + { + migrations: { + skip: false, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + level: 'info', + appenders: ['file'], + }, + ], + }, + }, + { + oss: false, + } + ); +} +const getClusterRoutingAllocations = (settings: Record) => { + const routingAllocations = + settings?.transient?.['cluster.routing.allocation.enable'] ?? + settings?.persistent?.['cluster.routing.allocation.enable'] ?? + []; + return ( + [...routingAllocations].length === 0 || + [...routingAllocations].every((s: string) => s === 'all') + ); // if set, only allow 'all'; +}; +let esServer: kbnTestServer.TestElasticsearchUtils; + +async function updateRoutingAllocations( + esClient: ElasticsearchClient, + settingType: string = 'persistent', + value: string = 'none' +) { + return await esClient.cluster.putSettings({ + [settingType]: { cluster: { routing: { allocation: { enable: value } } } }, + }); +} + +describe('unsupported_cluster_routing_allocation', () => { + let client: ElasticsearchClient; + let root: Root; + + beforeAll(async () => { + await removeLogFile(); + esServer = await startES(); + client = esServer.es.getClient(); + }); + afterAll(async () => { + await esServer.stop(); + }); + + it('fails with a descriptive message when persistent replica allocation is not enabled', async () => { + const initialSettings = await client.cluster.getSettings({ flat_settings: true }); + + expect(getClusterRoutingAllocations(initialSettings)).toBe(true); + + await updateRoutingAllocations(client, 'persistent', 'none'); + + 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(); + + await expect(root.start()).rejects.toMatchInlineSnapshot( + `[Error: Unable to complete saved object migrations for the [.kibana] index: 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}}]` + ); + + 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[]; + expect( + records.find((rec) => + rec.message.startsWith( + `Unable to complete saved object migrations for the [.kibana] index: The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue.` + ) + ) + ).toBeDefined(); + }, + { retryAttempts: 10, retryDelayMs: 200 } + ); + }); + + it('fails with a descriptive message when persistent replica allocation is set to "primaries"', async () => { + await updateRoutingAllocations(client, 'persistent', 'primaries'); + + 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(); + + await expect(root.start()).rejects.toMatchInlineSnapshot( + `[Error: Unable to complete saved object migrations for the [.kibana] index: 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}}]` + ); + }); +}); diff --git a/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts b/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts index 962d32b44eb3..46ef14b1bb33 100644 --- a/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts +++ b/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts @@ -17,6 +17,7 @@ const previouslyRegisteredTypes = [ 'api_key_pending_invalidation', 'apm-indices', 'apm-server-schema', + 'apm-service-group', 'apm-services-telemetry', 'apm-telemetry', 'app_search_telemetry', @@ -68,6 +69,7 @@ const previouslyRegisteredTypes = [ 'maps-telemetry', 'metrics-explorer-view', 'ml-job', + 'ml-trained-model', 'ml-module', 'ml-telemetry', 'monitoring-telemetry', diff --git a/src/core/server/saved_objects/migrations/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana_migrator.test.ts index 4bb24a3f8240..2adf4d5dee18 100644 --- a/src/core/server/saved_objects/migrations/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana_migrator.test.ts @@ -110,10 +110,16 @@ describe('KibanaMigrator', () => { it('only runs migrations once if called multiple times', async () => { const options = mockOptions(); - options.client.indices.get.mockResponse({}, { statusCode: 404 }); options.client.indices.getAlias.mockResponse({}, { statusCode: 404 }); + options.client.cluster.getSettings.mockResponse( + { + transient: {}, + persistent: {}, + }, + { statusCode: 404 } + ); const migrator = new KibanaMigrator(options); migrator.prepareMigrations(); @@ -197,6 +203,13 @@ type MockedOptions = KibanaMigratorOptions & { const mockV2MigrationOptions = () => { const options = mockOptions(); + options.client.cluster.getSettings.mockResponse( + { + transient: {}, + persistent: {}, + }, + { statusCode: 200 } + ); options.client.indices.get.mockResponse( { 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 5ca6713ca163..de8483bb4abc 100644 --- a/src/core/server/saved_objects/migrations/model/model.test.ts +++ b/src/core/server/saved_objects/migrations/model/model.test.ts @@ -291,6 +291,17 @@ describe('migrations v2 model', () => { expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); + test('INIT -> FATAL when cluster routing allocation is not enabled', () => { + const res: ResponseType<'INIT'> = Either.left({ + type: 'unsupported_cluster_routing_allocation', + }); + const newState = model(initState, res) as FatalState; + + expect(newState.controlState).toEqual('FATAL'); + expect(newState.reason).toMatchInlineSnapshot( + `"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}}"` + ); + }); test("INIT -> FATAL when .kibana points to newer version's index", () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_7.12.0_001': { diff --git a/src/core/server/saved_objects/migrations/model/model.ts b/src/core/server/saved_objects/migrations/model/model.ts index e9efb72bca6f..c2f11ba18069 100644 --- a/src/core/server/saved_objects/migrations/model/model.ts +++ b/src/core/server/saved_objects/migrations/model/model.ts @@ -72,7 +72,26 @@ export const model = (currentState: State, resW: ResponseType): if (stateP.controlState === 'INIT') { const res = resW as ExcludeRetryableEsError>; - if (Either.isRight(res)) { + if (Either.isLeft(res)) { + const left = res.left; + if (isLeftTypeof(left, 'unsupported_cluster_routing_allocation')) { + return { + ...stateP, + controlState: 'FATAL', + reason: `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}}`, + logs: [ + ...stateP.logs, + { + level: 'error', + message: `The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue. Ensure that the persistent and transient Elasticsearch configuration option 'cluster.routing.allocation.enable' is not set or set it to a value of 'all'.`, + }, + ], + }; + } else { + return throwBadResponse(stateP, left); + } + } else if (Either.isRight(res)) { + // cluster routing allocation is enabled and we can continue with the migration as normal const indices = res.right; const aliases = getAliases(indices); diff --git a/src/core/server/saved_objects/migrations/next.ts b/src/core/server/saved_objects/migrations/next.ts index 419b350a0b5f..24a4204c3009 100644 --- a/src/core/server/saved_objects/migrations/next.ts +++ b/src/core/server/saved_objects/migrations/next.ts @@ -59,7 +59,7 @@ export type ResponseType = Awaited< export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: TransformRawDocs) => { return { INIT: (state: InitState) => - Actions.fetchIndices({ client, indices: [state.currentAlias, state.versionAlias] }), + Actions.initAction({ client, indices: [state.currentAlias, state.versionAlias] }), WAIT_FOR_YELLOW_SOURCE: (state: WaitForYellowSourceState) => Actions.waitForIndexStatusYellow({ client, index: state.sourceIndex.value }), CHECK_UNKNOWN_DOCUMENTS: (state: CheckUnknownDocumentsState) => diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index d5f994a3e01e..6f2dfe86a152 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -229,7 +229,6 @@ describe(`POST ${URL}`, () => { { id: mockIndexPattern.id, type: mockIndexPattern.type, - title: mockIndexPattern.attributes.title, meta: { title: mockIndexPattern.attributes.title, icon: 'index-pattern-icon' }, error: { type: 'conflict' }, }, @@ -322,7 +321,6 @@ describe(`POST ${URL}`, () => { { id: 'my-vis', type: 'visualization', - title: 'my-vis', meta: { title: 'my-vis', icon: 'visualization-icon' }, error: { type: 'missing_references', @@ -386,7 +384,6 @@ describe(`POST ${URL}`, () => { { id: 'my-vis', type: 'visualization', - title: 'my-vis', meta: { title: 'my-vis', icon: 'visualization-icon' }, error: { type: 'missing_references', @@ -396,7 +393,6 @@ describe(`POST ${URL}`, () => { { id: 'my-vis', type: 'visualization', - title: 'my-vis', meta: { title: 'my-vis', icon: 'visualization-icon' }, error: { type: 'conflict' }, }, @@ -457,7 +453,6 @@ describe(`POST ${URL}`, () => { { id: 'my-vis', type: 'visualization', - title: 'my-vis', meta: { title: 'my-vis', icon: 'visualization-icon' }, overwrite: true, error: { diff --git a/src/core/server/saved_objects/service/lib/filter_utils.test.ts b/src/core/server/saved_objects/service/lib/filter_utils.test.ts index e9de6e77f0ed..92eb0d041cc2 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.test.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.test.ts @@ -36,6 +36,9 @@ const mockMappings = { }, bar: { properties: { + _id: { + type: 'keyword', + }, foo: { type: 'text', }, @@ -193,6 +196,76 @@ describe('Filter Utils', () => { ).toEqual(esKuery.fromKueryExpression('alert.params.foo:bar')); }); + test('Assemble filter with just "id" and one type', () => { + expect(validateConvertFilterToKueryNode(['foo'], 'foo.id: 0123456789', mockMappings)).toEqual( + esKuery.fromKueryExpression('type: foo and _id: 0123456789') + ); + }); + + test('Assemble filter with saved object attribute "id" and one type and more', () => { + expect( + validateConvertFilterToKueryNode( + ['foo'], + 'foo.id: 0123456789 and (foo.updated_at: 5678654567 or foo.attributes.bytes > 1000)', + mockMappings + ) + ).toEqual( + esKuery.fromKueryExpression( + '(type: foo and _id: 0123456789) and ((type: foo and updated_at: 5678654567) or foo.bytes > 1000)' + ) + ); + }); + + test('Assemble filter with saved object attribute "id" and multi type and more', () => { + expect( + validateConvertFilterToKueryNode( + ['foo', 'bar'], + 'foo.id: 0123456789 and bar.id: 9876543210', + mockMappings + ) + ).toEqual( + esKuery.fromKueryExpression( + '(type: foo and _id: 0123456789) and (type: bar and _id: 9876543210)' + ) + ); + }); + + test('Allow saved object type to defined "_id" attributes and filter on it', () => { + expect( + validateConvertFilterToKueryNode( + ['foo', 'bar'], + 'foo.id: 0123456789 and bar.attributes._id: 9876543210', + mockMappings + ) + ).toEqual( + esKuery.fromKueryExpression('(type: foo and _id: 0123456789) and (bar._id: 9876543210)') + ); + }); + + test('Lets make sure that we are throwing an exception if we are using id outside of saved object attribute when it does not belong', () => { + expect(() => { + validateConvertFilterToKueryNode( + ['foo'], + 'foo.attributes.id: 0123456789 and (foo.updated_at: 5678654567 or foo.attributes.bytes > 1000)', + mockMappings + ); + }).toThrowErrorMatchingInlineSnapshot( + `"This key 'foo.attributes.id' does NOT exist in foo saved object index patterns: Bad Request"` + ); + }); + + test('Lets make sure that we are throwing an exception if we are using _id', () => { + expect(() => { + validateConvertFilterToKueryNode( + ['foo'], + 'foo._id: 0123456789 and (foo.updated_at: 5678654567 or foo.attributes.bytes > 1000)', + mockMappings + ); + }).toThrowErrorMatchingInlineSnapshot( + `"This key 'foo._id' does NOT exist in foo saved object index patterns: Bad Request"` + ); + }); + test('Lets make sure that we are throwing an exception if we get an error', () => { expect(() => { validateConvertFilterToKueryNode( diff --git a/src/core/server/saved_objects/service/lib/filter_utils.ts b/src/core/server/saved_objects/service/lib/filter_utils.ts index 6c8aee832457..27ff1c201cbd 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.ts @@ -22,7 +22,7 @@ export const validateConvertFilterToKueryNode = ( indexMapping: IndexMapping ): KueryNode | undefined => { if (filter && indexMapping) { - const filterKueryNode = + let filterKueryNode = typeof filter === 'string' ? esKuery.fromKueryExpression(filter) : cloneDeep(filter); const validationFilterKuery = validateFilterKueryNode({ @@ -54,17 +54,20 @@ export const validateConvertFilterToKueryNode = ( const existingKueryNode: KueryNode = path.length === 0 ? filterKueryNode : get(filterKueryNode, path); if (item.isSavedObjectAttr) { - existingKueryNode.arguments[0].value = existingKueryNode.arguments[0].value.split('.')[1]; + const keySavedObjectAttr = existingKueryNode.arguments[0].value.split('.')[1]; + existingKueryNode.arguments[0].value = + keySavedObjectAttr === 'id' ? '_id' : keySavedObjectAttr; const itemType = allowedTypes.filter((t) => t === item.type); if (itemType.length === 1) { - set( - filterKueryNode, - path, - esKuery.nodeTypes.function.buildNode('and', [ - esKuery.nodeTypes.function.buildNode('is', 'type', itemType[0]), - existingKueryNode, - ]) - ); + const kueryToAdd = esKuery.nodeTypes.function.buildNode('and', [ + esKuery.nodeTypes.function.buildNode('is', 'type', itemType[0]), + existingKueryNode, + ]); + if (path.length > 0) { + set(filterKueryNode, path, kueryToAdd); + } else { + filterKueryNode = kueryToAdd; + } } } else { existingKueryNode.arguments[0].value = existingKueryNode.arguments[0].value.replace( @@ -171,6 +174,8 @@ export const isSavedObjectAttr = (key: string | null | undefined, indexMapping: const keySplit = key != null ? key.split('.') : []; if (keySplit.length === 1 && fieldDefined(indexMapping, keySplit[0])) { return true; + } else if (keySplit.length === 2 && keySplit[1] === 'id') { + return true; } else if (keySplit.length === 2 && fieldDefined(indexMapping, keySplit[1])) { return true; } else { @@ -219,6 +224,10 @@ export const fieldDefined = (indexMappings: IndexMapping, key: string): boolean return true; } + if (mappingKey === 'properties.id') { + return true; + } + // If the `mappingKey` does not match a valid path, before returning false, // we want to check and see if the intended path was for a multi-field // such as `x.attributes.field.text` where `field` is mapped to both text diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index d7ed4928e1cf..fcf505a13ddb 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -7,6 +7,7 @@ /// import { AddConfigDeprecation } from '@kbn/config'; +import apm from 'elastic-apm-node'; import Boom from '@hapi/boom'; import { ByteSizeValue } from '@kbn/config-schema'; import { CliArgs } from '@kbn/config'; @@ -885,7 +886,7 @@ export { EcsEventOutcome } export { EcsEventType } // @public -export type ElasticsearchClient = Omit; +export type ElasticsearchClient = Omit; // @public export type ElasticsearchClientConfig = Pick & { @@ -958,10 +959,6 @@ export interface ElasticsearchServiceSetup { export interface ElasticsearchServiceStart { readonly client: IClusterClient; readonly createClient: (type: string, clientConfig?: Partial) => ICustomClusterClient; - // @deprecated (undocumented) - legacy: { - readonly config$: Observable; - }; } // @public (undocumented) @@ -994,6 +991,8 @@ export class EventLoopDelaysMonitor { // @public (undocumented) export interface ExecutionContextSetup { + // (undocumented) + getAsLabels(): apm.Labels; withContext(context: KibanaExecutionContext | undefined, fn: (...args: any[]) => R): R; } @@ -1124,8 +1123,6 @@ export interface HttpServicePreboot { // @public export interface HttpServiceSetup { - // @deprecated - auth: HttpAuth; basePath: IBasePath; createCookieSessionStorageFactory: (cookieOptions: SessionStorageCookieOptions) => Promise>; createRouter: () => IRouter; @@ -1319,9 +1316,10 @@ export interface IUiSettingsClient { // @public export type KibanaExecutionContext = { - readonly type: string; - readonly name: string; - readonly id: string; + readonly type?: string; + readonly name?: string; + readonly page?: string; + readonly id?: string; readonly description?: string; readonly url?: string; child?: KibanaExecutionContext; @@ -2497,8 +2495,6 @@ export interface SavedObjectsImportFailure { icon?: string; }; overwrite?: boolean; - // @deprecated (undocumented) - title?: string; // (undocumented) type: string; } diff --git a/src/core/server/ui_settings/settings/date_formats.ts b/src/core/server/ui_settings/settings/date_formats.ts index c626c4a83cc4..039ead326a23 100644 --- a/src/core/server/ui_settings/settings/date_formats.ts +++ b/src/core/server/ui_settings/settings/date_formats.ts @@ -31,7 +31,7 @@ export const getDateFormatSettings = (): Record => { }), value: 'MMM D, YYYY @ HH:mm:ss.SSS', description: i18n.translate('core.ui_settings.params.dateFormatText', { - defaultMessage: 'When displaying a pretty formatted date, use this {formatLink}', + defaultMessage: 'The {formatLink} for pretty formatted dates.', description: 'Part of composite text: core.ui_settings.params.dateFormatText + ' + 'core.ui_settings.params.dateFormat.optionsLinkText', @@ -48,15 +48,11 @@ export const getDateFormatSettings = (): Record => { }, 'dateFormat:tz': { name: i18n.translate('core.ui_settings.params.dateFormat.timezoneTitle', { - defaultMessage: 'Timezone for date formatting', + defaultMessage: 'Time zone', }), value: 'Browser', description: i18n.translate('core.ui_settings.params.dateFormat.timezoneText', { - defaultMessage: - 'Which timezone should be used. {defaultOption} will use the timezone detected by your browser.', - values: { - defaultOption: '"Browser"', - }, + defaultMessage: 'The default time zone.', }), type: 'select', options: timezones, @@ -115,7 +111,7 @@ export const getDateFormatSettings = (): Record => { }), value: defaultWeekday, description: i18n.translate('core.ui_settings.params.dateFormat.dayOfWeekText', { - defaultMessage: 'What day should weeks start on?', + defaultMessage: 'The day that starts the week.', }), type: 'select', options: weekdays, @@ -141,7 +137,7 @@ export const getDateFormatSettings = (): Record => { }), value: 'MMM D, YYYY @ HH:mm:ss.SSSSSSSSS', description: i18n.translate('core.ui_settings.params.dateNanosFormatText', { - defaultMessage: 'Used for the {dateNanosLink} datatype of Elasticsearch', + defaultMessage: 'The format for {dateNanosLink} data.', values: { dateNanosLink: '' + diff --git a/src/core/test_helpers/http_test_setup.ts b/src/core/test_helpers/http_test_setup.ts index 468034dffceb..67b340898aab 100644 --- a/src/core/test_helpers/http_test_setup.ts +++ b/src/core/test_helpers/http_test_setup.ts @@ -9,6 +9,7 @@ import { HttpService } from '../public/http'; import { fatalErrorsServiceMock } from '../public/fatal_errors/fatal_errors_service.mock'; import { injectedMetadataServiceMock } from '../public/injected_metadata/injected_metadata_service.mock'; +import { executionContextServiceMock } from '../public/execution_context/execution_context_service.mock'; export type SetupTap = ( injectedMetadata: ReturnType, @@ -28,7 +29,8 @@ export function setup(tap: SetupTap = defaultTap) { tap(injectedMetadata, fatalErrors); const httpService = new HttpService(); - const http = httpService.setup({ fatalErrors, injectedMetadata }); + const executionContext = executionContextServiceMock.createSetupContract(); + const http = httpService.setup({ fatalErrors, injectedMetadata, executionContext }); return { httpService, injectedMetadata, fatalErrors, http }; } diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index a63a8d406db4..87026fc3fb9b 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -229,10 +229,6 @@ export function createTestServers({ writeTo: process.stdout, }); - log.indent(6); - log.info('starting elasticsearch'); - log.indent(4); - const es = createTestEsCluster( defaultsDeep({}, settings.es ?? {}, { log, @@ -240,8 +236,6 @@ export function createTestServers({ }) ); - log.indent(-4); - // Add time for KBN and adding users adjustTimeout(es.getStartTimeout() + 100000); diff --git a/src/core/types/execution_context.ts b/src/core/types/execution_context.ts index 1b985a73f410..d790b8d855fd 100644 --- a/src/core/types/execution_context.ts +++ b/src/core/types/execution_context.ts @@ -16,11 +16,13 @@ export type KibanaExecutionContext = { /** * Kibana application initated an operation. * */ - readonly type: string; // 'visualization' | 'actions' | 'server' | ..; - /** public name of a user-facing feature */ - readonly name: string; // 'TSVB' | 'Lens' | 'action_execution' | ..; + readonly type?: string; // 'visualization' | 'actions' | 'server' | ..; + /** public name of an application or a user-facing feature */ + readonly name?: string; // 'TSVB' | 'Lens' | 'action_execution' | ..; + /** a stand alone, logical unit such as an application page or tab */ + readonly page?: string; /** unique value to identify the source */ - readonly id: string; + readonly id?: string; /** human readable description. For example, a vis title, action name */ readonly description?: string; /** in browser - url to navigate to a current page, on server - endpoint path, for task: task SO url */ diff --git a/src/dev/bazel/index.bzl b/src/dev/bazel/index.bzl index fcd4212bd532..cca81dfcbcd5 100644 --- a/src/dev/bazel/index.bzl +++ b/src/dev/bazel/index.bzl @@ -12,7 +12,7 @@ Please do not import from any other files when looking to use a custom rule load("//src/dev/bazel:jsts_transpiler.bzl", _jsts_transpiler = "jsts_transpiler") load("//src/dev/bazel:pkg_npm.bzl", _pkg_npm = "pkg_npm") -load("//src/dev/bazel/pkg_npm_types:index.bzl", _pkg_npm_types = "pkg_npm_types") +load("//src/dev/bazel:pkg_npm_types.bzl", _pkg_npm_types = "pkg_npm_types") load("//src/dev/bazel:ts_project.bzl", _ts_project = "ts_project") jsts_transpiler = _jsts_transpiler diff --git a/src/dev/bazel/pkg_npm_types/pkg_npm_types.bzl b/src/dev/bazel/pkg_npm_types.bzl similarity index 83% rename from src/dev/bazel/pkg_npm_types/pkg_npm_types.bzl rename to src/dev/bazel/pkg_npm_types.bzl index ed48228bc958..e5caba514905 100644 --- a/src/dev/bazel/pkg_npm_types/pkg_npm_types.bzl +++ b/src/dev/bazel/pkg_npm_types.bzl @@ -72,32 +72,22 @@ def _pkg_npm_types_impl(ctx): inputs = ctx.files.srcs[:] inputs.extend(tsconfig_inputs) inputs.extend(deps_inputs) - inputs.append(ctx.file._generated_package_json_template) # output dir declaration package_path = ctx.label.package package_dir = ctx.actions.declare_directory(ctx.label.name) outputs = [package_dir] - # gathering template args - template_args = [ - "NAME", _get_type_package_name(ctx.attr.package_name) - ] - # layout api extractor arguments extractor_args = ctx.actions.args() - ## general args layout - ### [0] = base output dir - ### [1] = generated package json template input file path - ### [2] = stringified template args - ### [3] = tsconfig input file path - ### [4] = entry point from provided types to summarise - extractor_args.add(package_dir.path) - extractor_args.add(ctx.file._generated_package_json_template.path) - extractor_args.add_joined(template_args, join_with = ",", omit_if_empty = False) - extractor_args.add(tsconfig_inputs[0]) - extractor_args.add(_calculate_entrypoint_path(ctx)) + extractor_args.add(struct( + packageName = ctx.attr.package_name, + outputDir = package_dir.path, + buildFilePath = ctx.build_file_path, + tsconfigPath = tsconfig_inputs[0].path, + inputPath = _calculate_entrypoint_path(ctx), + ).to_json()) run_node( ctx, @@ -141,7 +131,9 @@ pkg_npm_types = rule( doc = """Entrypoint name of the types files group to summarise""", default = "index.d.ts", ), - "package_name": attr.string(), + "package_name": attr.string( + mandatory = True + ), "srcs": attr.label_list( doc = """Files inside this directory which are inputs for the types to summarise.""", allow_files = True, @@ -151,11 +143,7 @@ pkg_npm_types = rule( doc = "Target that executes the npm types package assembler binary", executable = True, cfg = "host", - default = Label("//src/dev/bazel/pkg_npm_types:_packager"), - ), - "_generated_package_json_template": attr.label( - allow_single_file = True, - default = "package_json.mustache", + default = Label("//packages/kbn-type-summarizer:bazel-cli"), ), }, ) diff --git a/src/dev/bazel/pkg_npm_types/BUILD.bazel b/src/dev/bazel/pkg_npm_types/BUILD.bazel deleted file mode 100644 index f30d0f8cb832..000000000000 --- a/src/dev/bazel/pkg_npm_types/BUILD.bazel +++ /dev/null @@ -1,28 +0,0 @@ -package(default_visibility = ["//visibility:public"]) - -load("@build_bazel_rules_nodejs//internal/node:node.bzl", "nodejs_binary") - -filegroup( - name = "packager_all_files", - srcs = glob([ - "packager/*", - ]), -) - -exports_files( - [ - "package_json.mustache", - ], - visibility = ["//visibility:public"] -) - -nodejs_binary( - name = "_packager", - data = [ - "@npm//@bazel/typescript", - "@npm//@microsoft/api-extractor", - "@npm//mustache", - ":packager_all_files" - ], - entry_point = ":packager/index.js", -) diff --git a/src/dev/bazel/pkg_npm_types/index.bzl b/src/dev/bazel/pkg_npm_types/index.bzl deleted file mode 100644 index 578ecdd885d1..000000000000 --- a/src/dev/bazel/pkg_npm_types/index.bzl +++ /dev/null @@ -1,15 +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. -# - -"""Public API interface for pkg_npm_types rule. -Please do not import from any other files when looking to this rule -""" - -load(":pkg_npm_types.bzl", _pkg_npm_types = "pkg_npm_types") - -pkg_npm_types = _pkg_npm_types diff --git a/src/dev/bazel/pkg_npm_types/package_json.mustache b/src/dev/bazel/pkg_npm_types/package_json.mustache deleted file mode 100644 index 2229345252e3..000000000000 --- a/src/dev/bazel/pkg_npm_types/package_json.mustache +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "{{{NAME}}}", - "description": "Generated by Bazel", - "types": "./index.d.ts", - "private": true, - "license": "MIT", - "version": "1.1.0" -} diff --git a/src/dev/bazel/pkg_npm_types/packager/create_api_extraction.js b/src/dev/bazel/pkg_npm_types/packager/create_api_extraction.js deleted file mode 100644 index d5f7e0c33ff1..000000000000 --- a/src/dev/bazel/pkg_npm_types/packager/create_api_extraction.js +++ /dev/null @@ -1,90 +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. - */ - -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -const { format, parseTsconfig } = require('@bazel/typescript'); -const { Extractor, ExtractorConfig } = require('@microsoft/api-extractor'); -const fs = require('fs'); -const path = require('path'); - -function createApiExtraction( - tsConfig, - entryPoint, - dtsBundleOut, - apiReviewFolder, - acceptApiUpdates = false -) { - const [parsedConfig, errors] = parseTsconfig(tsConfig); - if (errors && errors.length) { - console.error(format('', errors)); - return 1; - } - const pkgJson = path.resolve(path.dirname(entryPoint), 'package.json'); - if (!fs.existsSync(pkgJson)) { - fs.writeFileSync( - pkgJson, - JSON.stringify({ - name: 'GENERATED-BY-BAZEL', - description: 'This is a dummy package.json as API Extractor always requires one.', - types: './index.d.ts', - private: true, - license: 'SSPL-1.0 OR Elastic License 2.0', - version: '1.0.0', - }) - ); - } - // API extractor doesn't always support the version of TypeScript used in the repo - // example: at the moment it is not compatable with 3.2 - // to use the internal TypeScript we shall not create a program but rather pass a parsed tsConfig. - const parsedTsConfig = parsedConfig.config; - const extractorOptions = { - localBuild: acceptApiUpdates, - }; - const configObject = { - compiler: { - overrideTsconfig: parsedTsConfig, - }, - projectFolder: path.resolve(path.dirname(tsConfig)), - mainEntryPointFilePath: path.resolve(entryPoint), - apiReport: { - enabled: !!apiReviewFolder, - // TODO(alan-agius4): remove this folder name when the below issue is solved upstream - // See: https://github.com/microsoft/web-build-tools/issues/1470 - reportFileName: (apiReviewFolder && path.resolve(apiReviewFolder)) || 'invalid', - }, - docModel: { - enabled: false, - }, - dtsRollup: { - enabled: !!dtsBundleOut, - untrimmedFilePath: dtsBundleOut && path.resolve(dtsBundleOut), - }, - tsdocMetadata: { - enabled: false, - }, - }; - const options = { - configObject, - packageJson: undefined, - packageJsonFullPath: pkgJson, - configObjectFullPath: undefined, - }; - const extractorConfig = ExtractorConfig.prepare(options); - const { succeeded } = Extractor.invoke(extractorConfig, extractorOptions); - // API extractor errors are emitted by it's logger. - return succeeded ? 0 : 1; -} - -module.exports.createApiExtraction = createApiExtraction; diff --git a/src/dev/bazel/pkg_npm_types/packager/generate_package_json.js b/src/dev/bazel/pkg_npm_types/packager/generate_package_json.js deleted file mode 100644 index d4a478a262e5..000000000000 --- a/src/dev/bazel/pkg_npm_types/packager/generate_package_json.js +++ /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. - */ - -const fs = require('fs'); -const Mustache = require('mustache'); -const path = require('path'); - -function generatePackageJson(outputBasePath, packageJsonTemplatePath, rawPackageJsonTemplateArgs) { - const packageJsonTemplateArgsInTuples = rawPackageJsonTemplateArgs.reduce( - (a, v) => { - const lastTupleIdx = a.length - 1; - const lastTupleSize = a[lastTupleIdx].length; - - if (lastTupleSize < 2) { - a[lastTupleIdx].push(v); - - return a; - } - - return a.push([v]); - }, - [[]] - ); - const packageJsonTemplateArgs = Object.fromEntries(new Map(packageJsonTemplateArgsInTuples)); - - try { - const template = fs.readFileSync(packageJsonTemplatePath); - const renderedTemplate = Mustache.render(template.toString(), packageJsonTemplateArgs); - fs.writeFileSync(path.resolve(outputBasePath, 'package.json'), renderedTemplate); - } catch (e) { - console.error(e); - return 1; - } - - return 0; -} - -module.exports.generatePackageJson = generatePackageJson; diff --git a/src/dev/bazel/pkg_npm_types/packager/index.js b/src/dev/bazel/pkg_npm_types/packager/index.js deleted file mode 100644 index cda299a99d76..000000000000 --- a/src/dev/bazel/pkg_npm_types/packager/index.js +++ /dev/null @@ -1,46 +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. - */ - -const { createApiExtraction } = require('./create_api_extraction'); -const { generatePackageJson } = require('./generate_package_json'); -const path = require('path'); - -const DEBUG = false; - -if (require.main === module) { - if (DEBUG) { - console.error(` -pkg_npm_types packager: running with - cwd: ${process.cwd()} - argv: - ${process.argv.join('\n ')} - `); - } - - // layout args - const [ - outputBasePath, - packageJsonTemplatePath, - stringifiedPackageJsonTemplateArgs, - tsConfig, - entryPoint, - ] = process.argv.slice(2); - const dtsBundleOutput = path.resolve(outputBasePath, 'index.d.ts'); - - // generate pkg json output - const generatePackageJsonRValue = generatePackageJson( - outputBasePath, - packageJsonTemplatePath, - stringifiedPackageJsonTemplateArgs.split(',') - ); - // create api extraction output - const createApiExtractionRValue = createApiExtraction(tsConfig, entryPoint, dtsBundleOutput); - - // setup correct exit code - process.exitCode = generatePackageJsonRValue || createApiExtractionRValue; -} diff --git a/src/dev/build/lib/runner.ts b/src/dev/build/lib/runner.ts index 1fccd884cc4f..e12f7d24cfc4 100644 --- a/src/dev/build/lib/runner.ts +++ b/src/dev/build/lib/runner.ts @@ -33,29 +33,30 @@ export interface Task { export function createRunner({ config, log }: Options) { async function execTask(desc: string, task: Task | GlobalTask, lastArg: any) { log.info(desc); - log.indent(4); - - const start = Date.now(); - const time = () => { - const sec = (Date.now() - start) / 1000; - const minStr = sec > 60 ? `${Math.floor(sec / 60)} min ` : ''; - const secStr = `${Math.round(sec % 60)} sec`; - return chalk.dim(`${minStr}${secStr}`); - }; - try { - await task.run(config, log, lastArg); - log.success(chalk.green('✓'), time()); - } catch (error) { - if (!isErrorLogged(error)) { - log.error(`failure ${time()}`); - log.error(error); - markErrorLogged(error); - } + await log.indent(4, async () => { + const start = Date.now(); + const time = () => { + const sec = (Date.now() - start) / 1000; + const minStr = sec > 60 ? `${Math.floor(sec / 60)} min ` : ''; + const secStr = `${Math.round(sec % 60)} sec`; + return chalk.dim(`${minStr}${secStr}`); + }; + + try { + await task.run(config, log, lastArg); + log.success(chalk.green('✓'), time()); + } catch (error) { + if (!isErrorLogged(error)) { + log.error(`failure ${time()}`); + log.error(error); + markErrorLogged(error); + } - throw error; + throw error; + } + }); } finally { - log.indent(-4); log.write(''); } } diff --git a/src/dev/build/tasks/build_kibana_example_plugins.ts b/src/dev/build/tasks/build_kibana_example_plugins.ts index 7eb696ffdd3b..0208ba2ed61b 100644 --- a/src/dev/build/tasks/build_kibana_example_plugins.ts +++ b/src/dev/build/tasks/build_kibana_example_plugins.ts @@ -13,17 +13,26 @@ import { exec, mkdirp, copyAll, Task } from '../lib'; export const BuildKibanaExamplePlugins: Task = { description: 'Building distributable versions of Kibana example plugins', - async run(config, log, build) { - const examplesDir = Path.resolve(REPO_ROOT, 'examples'); + async run(config, log) { const args = [ - '../../scripts/plugin_helpers', + Path.resolve(REPO_ROOT, 'scripts/plugin_helpers'), 'build', `--kibana-version=${config.getBuildVersion()}`, ]; - const folders = Fs.readdirSync(examplesDir, { withFileTypes: true }) - .filter((f) => f.isDirectory()) - .map((f) => Path.resolve(REPO_ROOT, 'examples', f.name)); + const getExampleFolders = (dir: string) => { + return Fs.readdirSync(dir, { withFileTypes: true }) + .filter((f) => f.isDirectory()) + .map((f) => Path.resolve(dir, f.name)); + }; + + // https://github.com/elastic/kibana/issues/127338 + const skipExamples = ['alerting_example']; + + const folders = [ + ...getExampleFolders(Path.resolve(REPO_ROOT, 'examples')), + ...getExampleFolders(Path.resolve(REPO_ROOT, 'x-pack/examples')), + ].filter((p) => !skipExamples.includes(Path.basename(p))); for (const examplePlugin of folders) { try { @@ -40,8 +49,8 @@ export const BuildKibanaExamplePlugins: Task = { const pluginsDir = config.resolveFromTarget('example_plugins'); await mkdirp(pluginsDir); - await copyAll(examplesDir, pluginsDir, { - select: ['*/build/*.zip'], + await copyAll(REPO_ROOT, pluginsDir, { + select: ['examples/*/build/*.zip', 'x-pack/examples/*/build/*.zip'], }); }, }; diff --git a/src/dev/build/tasks/bundle_fleet_packages.ts b/src/dev/build/tasks/bundle_fleet_packages.ts index 7d0dc6a25a47..b2faed818b55 100644 --- a/src/dev/build/tasks/bundle_fleet_packages.ts +++ b/src/dev/build/tasks/bundle_fleet_packages.ts @@ -11,7 +11,7 @@ import JSON5 from 'json5'; import { readCliArgs } from '../args'; import { Task, read, downloadToDisk } from '../lib'; -const BUNDLED_PACKAGES_DIR = 'x-pack/plugins/fleet/server/bundled_packages'; +const BUNDLED_PACKAGES_DIR = 'x-pack/plugins/fleet/target/bundled_packages'; interface FleetPackage { name: string; diff --git a/src/dev/build/tasks/generate_packages_optimized_assets.ts b/src/dev/build/tasks/generate_packages_optimized_assets.ts index 982aedb9cfa2..c8f215ad7632 100644 --- a/src/dev/build/tasks/generate_packages_optimized_assets.ts +++ b/src/dev/build/tasks/generate_packages_optimized_assets.ts @@ -24,6 +24,7 @@ import terser from 'terser'; import vfs from 'vinyl-fs'; import globby from 'globby'; import del from 'del'; +import zlib from 'zlib'; import { Task, write } from '../lib'; @@ -55,21 +56,29 @@ async function optimizeAssets(log: ToolingLog, assetDir: string) { log.debug('Minify JS'); await asyncPipeline( vfs.src(['**/*.js'], { cwd: assetDir }), - gulpTerser({ compress: true, mangle: true }, terser.minify), + gulpTerser({ compress: { passes: 2 }, mangle: true }, terser.minify), vfs.dest(assetDir) ); log.debug('Brotli compress'); await asyncPipeline( vfs.src(['**/*.{js,css}'], { cwd: assetDir }), - gulpBrotli(), + gulpBrotli({ + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY, + }, + }), vfs.dest(assetDir) ); log.debug('GZip compress'); await asyncPipeline( vfs.src(['**/*.{js,css}'], { cwd: assetDir }), - gulpGzip(), + gulpGzip({ + gzipOptions: { + level: 9, + }, + }), vfs.dest(assetDir) ); } finally { diff --git a/src/dev/build/tasks/notice_file_task.ts b/src/dev/build/tasks/notice_file_task.ts index 43d95858e7b8..2a446e73723e 100644 --- a/src/dev/build/tasks/notice_file_task.ts +++ b/src/dev/build/tasks/notice_file_task.ts @@ -18,13 +18,15 @@ export const CreateNoticeFile: Task = { async run(config, log, build) { log.info('Generating notice from source'); - log.indent(4); - const noticeFromSource = await generateNoticeFromSource({ - productName: 'Kibana', - directory: build.resolvePath(), - log, - }); - log.indent(-4); + const noticeFromSource = await log.indent( + 4, + async () => + await generateNoticeFromSource({ + productName: 'Kibana', + directory: build.resolvePath(), + log, + }) + ); log.info('Discovering installed packages'); const packages = await getInstalledPackages({ diff --git a/src/dev/build/tasks/patch_native_modules_task.ts b/src/dev/build/tasks/patch_native_modules_task.ts index be7fa5b50a07..4bdb5ba7284e 100644 --- a/src/dev/build/tasks/patch_native_modules_task.ts +++ b/src/dev/build/tasks/patch_native_modules_task.ts @@ -41,40 +41,50 @@ interface Package { const packages: Package[] = [ { name: 're2', - version: '1.16.0', + version: '1.17.4', destinationPath: 'node_modules/re2/build/Release/re2.node', extractMethod: 'gunzip', archives: { 'darwin-x64': { - url: 'https://github.com/uhop/node-re2/releases/download/1.16.0/darwin-x64-93.gz', - sha256: 'a267c6202d86d08170eb4a833acf81d83660ce33e8981fcd5b7f6e0310961d56', + url: 'https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/node-re2/uhop/node-re2/releases/download/1.17.4/darwin-x64-93.gz', + sha256: '9558c5cb39622e9b3653203e772b129d6c634e7dbd7af1b244352fc1d704601f', }, 'linux-x64': { - url: 'https://github.com/uhop/node-re2/releases/download/1.16.0/linux-x64-93.gz', - sha256: 'e0ca5d6527fe7ec0fe98b6960c47b66a5bb2823c3bebb3bf4ed4d58eed3d23c5', + url: 'https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/node-re2/uhop/node-re2/releases/download/1.17.4/linux-x64-93.gz', + sha256: '4d06747b266c75b6f7ced93977692c0586ce6a52924cabb569bd966378941aa1', }, - // ARM build is currently done manually as Github Actions used in upstream project + // ARM builds are currently done manually as Github Actions used in upstream project // do not natively support an ARM target. - // From a AWS Graviton instance: - // * checkout the node-re2 project, - // * install Node using the same minor used by Kibana - // * git submodule update --init --recursive to download re2 - // * npm install, which will also create a build - // * gzip -c build/Release/re2.node > linux-arm64-83.gz - // * upload to kibana-ci-proxy-cache bucket + // From a AWS Graviton instance running Ubuntu: + // * install build-essential package + // * install nvm and the node version used by the Kibana repository + // * `npm install re2@1.17.4` + // * re2 will build itself on install + // * `cp node_modules/re2/build/Release/re2.node > linux-arm64-$(node -e "console.log(process.versions.modules)") + // * `gzip linux-arm64-*` + // * capture the sha256 with: `shasum -a 256 linux-arm64-*` + // * upload the `linux-arm64-*.gz` artifact to the `yarn-prebuilt-assets` bucket in GCS using the correct version number 'linux-arm64': { - url: 'https://storage.googleapis.com/kibana-ci-proxy-cache/node-re2/uhop/node-re2/releases/download/1.16.0/linux-arm64-93.gz', - sha256: '7a786e0b75985e5aafdefa9af55cad8e85e69a3326f16d8c63d21d6b5b3bff1b', + url: 'https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/node-re2/uhop/node-re2/releases/download/1.17.4/linux-arm64-93.gz', + sha256: '25409584f76f3d6ed85463d84adf094eb6e256ed1cb0b754b95bcbda6691fc26', }, + + // A similar process is necessary for building on ARM macs: + // * bootstrap and re2 will build itself on install + // * `cp node_modules/re2/build/Release/re2.node > darwin-arm64-$(node -e "console.log(process.versions.modules)") + // * `gzip darwin-arm64-*` + // * capture the sha256 with: `shasum -a 256 darwin-arm64-*` + // * upload the `darwin-arm64-*.gz` artifact to the `yarn-prebuilt-assets` bucket in GCS using the correct version number 'darwin-arm64': { - url: 'https://storage.googleapis.com/kibana-ci-proxy-cache/node-re2/uhop/node-re2/releases/download/1.16.0/darwin-arm64-93.gz', - sha256: '28b540cdddf13578f1bd28a03e29ffdc26a7f00ec859c369987b8d51ec6357c8', + url: 'https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/node-re2/uhop/node-re2/releases/download/1.17.4/darwin-arm64-93.gz', + sha256: 'd4b708749ddef1c87019f6b80e051ed0c29ccd1de34f233c47d8dcaddf803872', }, + 'win32-x64': { - url: 'https://github.com/uhop/node-re2/releases/download/1.16.0/win32-x64-93.gz', - sha256: '37245ceb59a086b5e7e9de8746a3cdf148c383be9ae2580f92baea90d0d39947', + url: 'https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/node-re2/uhop/node-re2/releases/download/1.17.4/win32-x64-93.gz', + sha256: '0320d0c0385432944c6fb3c8c8fcd78d440ce5626f7618f9ec71d88e44820674', }, }, }, diff --git a/src/dev/eslint/run_eslint_with_types.ts b/src/dev/eslint/run_eslint_with_types.ts index 0f2a10d07d68..d7f2482fcb26 100644 --- a/src/dev/eslint/run_eslint_with_types.ts +++ b/src/dev/eslint/run_eslint_with_types.ts @@ -109,9 +109,9 @@ export function runEslintWithTypes() { return undefined; } else { log.error(`${project.name} failed`); - log.indent(4); - log.write(proc.all); - log.indent(-4); + log.indent(4, () => { + log.write(proc.all); + }); return project; } }, concurrency), diff --git a/src/dev/file.ts b/src/dev/file.ts index b532a7bb7060..16d64d8c0c21 100644 --- a/src/dev/file.ts +++ b/src/dev/file.ts @@ -50,9 +50,17 @@ export class File { } public isFixture() { - return ( - this.relativePath.split(sep).includes('__fixtures__') || this.path.endsWith('.test-d.ts') - ); + const parts = this.relativePath.split(sep); + if (parts.includes('__fixtures__') || this.path.endsWith('.test-d.ts')) { + return true; + } + + const i = parts.indexOf('kbn-generate'); + if (i >= 0 && parts[i + 1] === 'templates') { + return true; + } + + return false; } public getRelativeParentDirs() { diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index 38cc61387e92..d8e9f9cc7e11 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -76,7 +76,7 @@ export const DEV_ONLY_LICENSE_ALLOWED = ['MPL-2.0']; export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint - '@elastic/ems-client@8.0.0': ['Elastic License 2.0'], + '@elastic/ems-client@8.1.0': ['Elastic License 2.0'], '@elastic/eui@48.1.1': ['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 860886811da5..86448be7c3e5 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -61,9 +61,6 @@ export const IGNORE_FILE_GLOBS = [ 'x-pack/plugins/maps/server/fonts/**/*', - // Bundled package names typically use a format like ${pkgName}-${pkgVersion}, so don't lint them - 'x-pack/plugins/fleet/server/bundled_packages/**/*', - // Bazel default files '**/WORKSPACE.bazel', '**/BUILD.bazel', @@ -110,7 +107,10 @@ export const IGNORE_DIRECTORY_GLOBS = [ * * @type {Array} */ -export const REMOVE_EXTENSION = ['packages/kbn-plugin-generator/template/**/*.ejs']; +export const REMOVE_EXTENSION = [ + 'packages/kbn-plugin-generator/template/**/*.ejs', + 'packages/kbn-generate/templates/**/*.ejs', +]; /** * DO NOT ADD FILES TO THIS LIST!! diff --git a/src/dev/prs/run_update_prs_cli.ts b/src/dev/prs/run_update_prs_cli.ts index 4d82c704cad2..cde7f495b1eb 100644 --- a/src/dev/prs/run_update_prs_cli.ts +++ b/src/dev/prs/run_update_prs_cli.ts @@ -148,12 +148,9 @@ run( await init(); for (const pr of prs) { log.info('pr #%s', pr.number); - log.indent(4); - try { + await log.indent(4, async () => { await updatePr(pr); - } finally { - log.indent(-4); - } + }); } }, { diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index e5657dd4663a..6b47d9b805af 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -45,7 +45,7 @@ export const PROJECTS = [ { name: 'enterprise_search/shared/cypress' } ), createProject( - 'x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/tsconfig.json', + 'x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/cypress/tsconfig.json', { name: 'enterprise_search/overview/cypress' } ), createProject( @@ -82,4 +82,5 @@ export const PROJECTS = [ ...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'), ]; diff --git a/src/fixtures/telemetry_collectors/imported_interface_from_export/index.ts b/src/fixtures/telemetry_collectors/imported_interface_from_export/index.ts index 222a89bf3229..991f8336e702 100644 --- a/src/fixtures/telemetry_collectors/imported_interface_from_export/index.ts +++ b/src/fixtures/telemetry_collectors/imported_interface_from_export/index.ts @@ -11,7 +11,7 @@ import { createUsageCollectionSetupMock } from '../../../plugins/usage_collectio const { makeUsageCollector } = createUsageCollectionSetupMock(); -export const myCollector = makeUsageCollector({ +export const myCollector = makeUsageCollector({ type: 'importing_from_export_collector', isReady: () => true, fetch() { diff --git a/src/fixtures/telemetry_collectors/stats_collector.ts b/src/fixtures/telemetry_collectors/stats_collector.ts index c8f513a07253..6046973f42e8 100644 --- a/src/fixtures/telemetry_collectors/stats_collector.ts +++ b/src/fixtures/telemetry_collectors/stats_collector.ts @@ -19,7 +19,7 @@ interface Usage { * We should collect them when the schema is defined. */ -export const myCollectorWithSchema = makeStatsCollector({ +export const myCollectorWithSchema = makeStatsCollector({ type: 'my_stats_collector_with_schema', isReady: () => true, fetch() { diff --git a/src/plugins/advanced_settings/kibana.json b/src/plugins/advanced_settings/kibana.json index 033d5e9da9ea..98b7ec6d7e6d 100644 --- a/src/plugins/advanced_settings/kibana.json +++ b/src/plugins/advanced_settings/kibana.json @@ -7,7 +7,7 @@ "optionalPlugins": ["home", "usageCollection"], "requiredBundles": ["kibanaReact", "kibanaUtils", "home"], "owner": { - "name": "Vis Editors", - "githubTeam": "kibana-vis-editors" + "name": "Kibana Core", + "githubTeam": "kibana-core" } } 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 e43f30e52ee7..070520187249 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 @@ -616,9 +616,7 @@ export class Field extends PureComponent { const isInvalid = unsavedChanges?.isInvalid; const className = classNames('mgtAdvancedSettings__field', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'mgtAdvancedSettings__field--unsaved': unsavedChanges, - // eslint-disable-next-line @typescript-eslint/naming-convention 'mgtAdvancedSettings__field--invalid': isInvalid, }); const groupId = `${setting.name}-group`; diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/__snapshots__/gauge_function.test.ts.snap b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/__snapshots__/gauge_function.test.ts.snap index 3ff6f1608eb0..c640ed8884d9 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/__snapshots__/gauge_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/__snapshots__/gauge_function.test.ts.snap @@ -47,6 +47,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "arc", "ticksPosition": "auto", }, @@ -96,6 +97,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "arc", "ticksPosition": "auto", }, @@ -143,6 +145,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "horizontalBullet", "ticksPosition": "auto", }, @@ -190,6 +193,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "horizontalBullet", "ticksPosition": "auto", }, @@ -237,6 +241,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "horizontalBullet", "ticksPosition": "bands", }, @@ -286,6 +291,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "circle", "ticksPosition": "auto", }, @@ -335,6 +341,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "circle", "ticksPosition": "auto", }, @@ -382,6 +389,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "horizontalBullet", "ticksPosition": "auto", }, @@ -429,6 +437,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "horizontalBullet", "ticksPosition": "hidden", }, @@ -476,6 +485,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "horizontalBullet", "ticksPosition": "auto", }, @@ -523,6 +533,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "horizontalBullet", "ticksPosition": "auto", }, @@ -570,6 +581,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "horizontalBullet", "ticksPosition": "auto", }, @@ -617,6 +629,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "horizontalBullet", "ticksPosition": "auto", }, @@ -664,6 +677,7 @@ Object { "metric": "col-0-1", "min": "col-1-2", "palette": undefined, + "percentageMode": false, "shape": "verticalBullet", "ticksPosition": "auto", }, @@ -698,11 +712,3 @@ Object { exports[`interpreter/functions#gauge throws error if centralMajor or centralMajorMode are provided for the horizontalBullet shape 1`] = `"Fields \\"centralMajor\\" and \\"centralMajorMode\\" are not supported by the shape \\"horizontalBullet\\""`; exports[`interpreter/functions#gauge throws error if centralMajor or centralMajorMode are provided for the vertical shape 1`] = `"Fields \\"centralMajor\\" and \\"centralMajorMode\\" are not supported by the shape \\"verticalBullet\\""`; - -exports[`interpreter/functions#gauge throws error on wrong colorMode type 1`] = `"Invalid color mode is specified. Supported color modes: palette, none"`; - -exports[`interpreter/functions#gauge throws error on wrong labelMajorMode type 1`] = `"Invalid label major mode is specified. Supported label major modes: auto, custom, none"`; - -exports[`interpreter/functions#gauge throws error on wrong shape type 1`] = `"Invalid shape is specified. Supported shapes: horizontalBullet, verticalBullet, arc, circle"`; - -exports[`interpreter/functions#gauge throws error on wrong ticksPosition type 1`] = `"Invalid ticks position is specified. Supported ticks positions: hidden, auto, bands"`; 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 54c7fed1a9b9..40d95d5f44af 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 @@ -36,28 +36,19 @@ describe('interpreter/functions#gauge', () => { min: 'col-1-2', metric: 'col-0-1', }; - const checkArg = ( - arg: keyof GaugeArguments, - options: Record, - invalidValue: string - ) => { + const checkArg = (arg: keyof GaugeArguments, options: Record) => { Object.values(options).forEach((option) => { it(`returns an object with the correct structure for the ${option} ${arg}`, () => { const actual = fn(context, { ...args, [arg]: option }, undefined); expect(actual).toMatchSnapshot(); }); }); - - it(`throws error on wrong ${arg} type`, () => { - const actual = () => fn(context, { ...args, [arg]: invalidValue as any }, undefined); - expect(actual).toThrowErrorMatchingSnapshot(); - }); }; - checkArg('shape', GaugeShapes, 'invalid_shape'); - checkArg('colorMode', GaugeColorModes, 'invalid_color_mode'); - checkArg('ticksPosition', GaugeTicksPositions, 'invalid_ticks_position'); - checkArg('labelMajorMode', GaugeLabelMajorModes, 'invalid_label_major_mode'); + checkArg('shape', GaugeShapes); + checkArg('colorMode', GaugeColorModes); + checkArg('ticksPosition', GaugeTicksPositions); + checkArg('labelMajorMode', GaugeLabelMajorModes); it(`returns an object with the correct structure for the circle if centralMajor and centralMajorMode are passed`, () => { const actual = fn( 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 ae9da90ffb57..89d32940808c 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 @@ -7,10 +7,7 @@ */ import { i18n } from '@kbn/i18n'; -import { findAccessorOrFail } from '../../../../visualizations/common/utils'; -import type { ExpressionValueVisDimension } from '../../../../visualizations/common'; -import { prepareLogTable } from '../../../../visualizations/common/utils'; -import type { DatatableColumn } from '../../../../expressions'; +import { prepareLogTable, validateAccessor } from '../../../../visualizations/common/utils'; import { GaugeExpressionFunctionDefinition } from '../types'; import { EXPRESSION_GAUGE_NAME, @@ -23,26 +20,6 @@ import { import { isRoundShape } from '../utils'; export const errors = { - invalidShapeError: () => - i18n.translate('expressionGauge.functions.gauge.errors.invalidShapeError', { - defaultMessage: `Invalid shape is specified. Supported shapes: {shapes}`, - values: { shapes: Object.values(GaugeShapes).join(', ') }, - }), - invalidColorModeError: () => - i18n.translate('expressionGauge.functions.gauge.errors.invalidColorModeError', { - defaultMessage: `Invalid color mode is specified. Supported color modes: {colorModes}`, - values: { colorModes: Object.values(GaugeColorModes).join(', ') }, - }), - invalidTicksPositionError: () => - i18n.translate('expressionGauge.functions.gauge.errors.invalidTicksPositionError', { - defaultMessage: `Invalid ticks position is specified. Supported ticks positions: {ticksPositions}`, - values: { ticksPositions: Object.values(GaugeTicksPositions).join(', ') }, - }), - invalidLabelMajorModeError: () => - i18n.translate('expressionGauge.functions.gauge.errors.invalidLabelMajorModeError', { - defaultMessage: `Invalid label major mode is specified. Supported label major modes: {labelMajorModes}`, - values: { labelMajorModes: Object.values(GaugeLabelMajorModes).join(', ') }, - }), centralMajorNotSupportedForShapeError: (shape: string) => i18n.translate('expressionGauge.functions.gauge.errors.centralMajorNotSupportedForShapeError', { defaultMessage: @@ -70,25 +47,6 @@ const strings = { }), }; -const validateAccessor = ( - accessor: string | undefined | ExpressionValueVisDimension, - columns: DatatableColumn[] -) => { - if (accessor && typeof accessor === 'string') { - findAccessorOrFail(accessor, columns); - } -}; - -const validateOptions = ( - value: string, - availableOptions: Record, - getErrorMessage: () => string -) => { - if (!Object.values(availableOptions).includes(value)) { - throw new Error(getErrorMessage()); - } -}; - export const gaugeFunction = (): GaugeExpressionFunctionDefinition => ({ name: EXPRESSION_GAUGE_NAME, type: 'render', @@ -189,6 +147,14 @@ export const gaugeFunction = (): GaugeExpressionFunctionDefinition => ({ defaultMessage: 'Specifies the mode of centralMajor', }), }, + // used only in legacy gauge, consider it as @deprecated + percentageMode: { + types: ['boolean'], + default: false, + help: i18n.translate('expressionGauge.functions.gauge.percentageMode.help', { + defaultMessage: 'Enables relative precentage mode', + }), + }, ariaLabel: { types: ['string'], help: i18n.translate('expressionGauge.functions.gaugeChart.config.ariaLabel.help', { @@ -198,11 +164,6 @@ export const gaugeFunction = (): GaugeExpressionFunctionDefinition => ({ }, fn(data, args, handlers) { - validateOptions(args.shape, GaugeShapes, errors.invalidShapeError); - validateOptions(args.colorMode, GaugeColorModes, errors.invalidColorModeError); - validateOptions(args.ticksPosition, GaugeTicksPositions, errors.invalidTicksPositionError); - validateOptions(args.labelMajorMode, GaugeLabelMajorModes, errors.invalidLabelMajorModeError); - validateAccessor(args.metric, data.columns); validateAccessor(args.min, data.columns); validateAccessor(args.max, data.columns); diff --git a/src/plugins/chart_expressions/expression_gauge/common/index.ts b/src/plugins/chart_expressions/expression_gauge/common/index.ts index afd8f6105d8f..395aa3ed6086 100755 --- a/src/plugins/chart_expressions/expression_gauge/common/index.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/index.ts @@ -10,6 +10,7 @@ export const PLUGIN_ID = 'expressionGauge'; export const PLUGIN_NAME = 'expressionGauge'; export type { + GaugeExpressionFunctionDefinition, GaugeExpressionProps, FormatFactory, GaugeRenderProps, diff --git a/src/plugins/chart_expressions/expression_gauge/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_gauge/common/types/expression_functions.ts index e71b5b28c7dc..3cd6d566d487 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/types/expression_functions.ts @@ -45,6 +45,8 @@ export interface GaugeState { colorMode?: GaugeColorMode; palette?: PaletteOutput; shape: GaugeShape; + /** @deprecated This field is deprecated and going to be removed in the futher release versions. */ + percentageMode?: boolean; } export type GaugeArguments = GaugeState & { diff --git a/src/plugins/chart_expressions/expression_gauge/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_gauge/common/types/expression_renderers.ts index 277071b416a1..4c2133e8572f 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/types/expression_renderers.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import type { ChartsPluginSetup } from '../../../../charts/public'; +import { PersistedState } from '../../../../visualizations/public'; +import type { ChartsPluginSetup, PaletteRegistry } from '../../../../charts/public'; import type { IFieldFormat, SerializedFieldFormat } from '../../../../field_formats/common'; import type { GaugeExpressionProps } from './expression_functions'; @@ -15,6 +16,8 @@ export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; export type GaugeRenderProps = GaugeExpressionProps & { formatFactory: FormatFactory; chartsThemeService: ChartsPluginSetup['theme']; + paletteService: PaletteRegistry; + uiState: PersistedState; }; export interface ColorStop { diff --git a/src/plugins/chart_expressions/expression_gauge/public/components/__snapshots__/gauge_component.test.tsx.snap b/src/plugins/chart_expressions/expression_gauge/public/components/__snapshots__/gauge_component.test.tsx.snap index 0bd72c606dd9..59aaa3677e9b 100644 --- a/src/plugins/chart_expressions/expression_gauge/public/components/__snapshots__/gauge_component.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_gauge/public/components/__snapshots__/gauge_component.test.tsx.snap @@ -41,6 +41,7 @@ exports[`GaugeComponent renders the chart 1`] = ` 4, ] } + tooltipValueFormatter={[Function]} /> `; diff --git a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.test.tsx b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.test.tsx index 488d5f57a645..e7e1e47ca65f 100644 --- a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.test.tsx +++ b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.test.tsx @@ -54,6 +54,7 @@ jest.mock('@elastic/charts', () => { }); const chartsThemeService = chartPluginMock.createSetupContract().theme; +const paletteThemeService = chartPluginMock.createSetupContract().palettes; const formatService = fieldFormatsServiceMock.createStartContract(); const args: GaugeArguments = { labelMajor: 'Gauge', @@ -78,15 +79,27 @@ const createData = ( }; }; +const mockState = new Map(); +const uiState = { + get: jest + .fn() + .mockImplementation((key, fallback) => (mockState.has(key) ? mockState.get(key) : fallback)), + set: jest.fn().mockImplementation((key, value) => mockState.set(key, value)), + emit: jest.fn(), + setSilent: jest.fn(), +} as any; + describe('GaugeComponent', function () { let wrapperProps: GaugeRenderProps; - beforeAll(() => { + beforeAll(async () => { wrapperProps = { data: createData(), chartsThemeService, args, formatFactory: formatService.deserialize, + paletteService: await paletteThemeService.getPalettes(), + uiState, }; }); diff --git a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx index 99342edbdbc6..9db6b81acefc 100644 --- a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx +++ b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx @@ -5,11 +5,13 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { FC, memo } from 'react'; +import React, { FC, memo, useCallback } from 'react'; import { Chart, Goal, Settings } from '@elastic/charts'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { CustomPaletteState } from '../../../../charts/public'; +import { FieldFormat } from '../../../../field_formats/common'; +import type { CustomPaletteState, PaletteOutput } from '../../../../charts/public'; import { EmptyPlaceholder } from '../../../../charts/public'; +import { isVisDimension } from '../../../../visualizations/common/utils'; import { GaugeRenderProps, GaugeLabelMajorMode, @@ -41,37 +43,7 @@ declare global { } } -function normalizeColors( - { colors, stops, range, rangeMin, rangeMax }: CustomPaletteState, - min: number, - max: number -) { - if (!colors) { - return; - } - const colorsOutOfRangeSmaller = Math.max( - stops.filter((stop, i) => (range === 'percent' ? stop < 0 : stop < min)).length, - 0 - ); - let updatedColors = colors.slice(colorsOutOfRangeSmaller); - - let correctMin = rangeMin; - let correctMax = rangeMax; - if (range === 'percent') { - correctMin = min + rangeMin * ((max - min) / 100); - correctMax = min + rangeMax * ((max - min) / 100); - } - - if (correctMin > min && isFinite(correctMin)) { - updatedColors = [`rgba(255,255,255,0)`, ...updatedColors]; - } - - if (correctMax < max && isFinite(correctMax)) { - updatedColors = [...updatedColors, `rgba(255,255,255,0)`]; - } - - return updatedColors; -} +const TRANSPARENT = `rgba(255,255,255,0)`; function normalizeBands( { colors, stops, range, rangeMax, rangeMin }: CustomPaletteState, @@ -110,6 +82,28 @@ function normalizeBands( return [...firstRanges, ...orderedStops, ...lastRanges]; } +const toPercents = (min: number, max: number) => (v: number) => (v - min) / (max - min); + +function normalizeBandsLegacy({ colors, stops }: CustomPaletteState, value: number) { + const min = stops[0]; + const max = stops[stops.length - 1]; + const convertToPercents = toPercents(min, max); + const normalizedStops = stops.map(convertToPercents); + + if (max < value) { + normalizedStops.push(convertToPercents(value)); + } + + return normalizedStops; +} + +function actualValueToPercentsLegacy({ stops }: CustomPaletteState, value: number) { + const min = stops[0]; + const max = stops[stops.length - 1]; + const convertToPercents = toPercents(min, max); + return convertToPercents(value); +} + function getTitle( majorMode?: GaugeLabelMajorMode | GaugeCentralMajorMode, major?: string, @@ -143,7 +137,8 @@ function getTicksLabels(baseStops: number[]) { function getTicks( ticksPosition: GaugeTicksPosition, range: [number, number], - colorBands?: number[] + colorBands?: number[], + percentageMode?: boolean ) { if (ticksPosition === GaugeTicksPositions.HIDDEN) { return []; @@ -157,16 +152,55 @@ function getTicks( const min = Math.min(...(colorBands || []), ...range); const max = Math.max(...(colorBands || []), ...range); const step = (max - min) / TICKS_NO; - return [ + + const ticks = [ ...Array(TICKS_NO) .fill(null) .map((_, i) => Number((min + step * i).toFixed(2))), max, ]; + const convertToPercents = toPercents(min, max); + return percentageMode ? ticks.map(convertToPercents) : ticks; } +const calculateRealRangeValueMin = ( + relativeRangeValue: number, + { min, max }: { min: number; max: number } +) => { + if (isFinite(relativeRangeValue)) { + return relativeRangeValue * ((max - min) / 100); + } + return min; +}; + +const calculateRealRangeValueMax = ( + relativeRangeValue: number, + { min, max }: { min: number; max: number } +) => { + if (isFinite(relativeRangeValue)) { + return relativeRangeValue * ((max - min) / 100); + } + + return max; +}; + +const getPreviousSectionValue = (value: number, bands: number[]) => { + // bands value is equal to the stop. The purpose of this value is coloring the previous section, which is smaller, then the band. + // So, the smaller value should be taken. For the first element -1, for the next - middle value of the previous section. + + let prevSectionValue = value - 1; + const valueIndex = bands.indexOf(value); + const prevBand = bands[valueIndex - 1]; + const curBand = bands[valueIndex]; + if (valueIndex > 0) { + prevSectionValue = value - (curBand - prevBand) / 2; + } + + return prevSectionValue; +}; + export const GaugeComponent: FC = memo( - ({ data, args, formatFactory, chartsThemeService }) => { + ({ data, args, uiState, formatFactory, paletteService, chartsThemeService }) => { const { shape: gaugeType, palette, @@ -178,6 +212,68 @@ export const GaugeComponent: FC = memo( centralMajorMode, ticksPosition, } = args; + + const getColor = useCallback( + ( + value, + paletteConfig: PaletteOutput, + bands: number[], + percentageMode?: boolean + ) => { + const { rangeMin, rangeMax, range }: CustomPaletteState = paletteConfig.params!; + const minRealValue = bands[0]; + const maxRealValue = bands[bands.length - 1]; + let min = rangeMin; + let max = rangeMax; + + let stops = paletteConfig.params?.stops ?? []; + + if (percentageMode) { + stops = bands.map((v) => v * 100); + } + + if (range === 'percent') { + const minMax = { min: minRealValue, max: maxRealValue }; + + min = calculateRealRangeValueMin(min, minMax); + max = calculateRealRangeValueMax(max, minMax); + } + + return paletteService + .get(paletteConfig?.name ?? 'custom') + .getColorForValue?.(value, { ...paletteConfig.params, stops }, { min, max }); + }, + [paletteService] + ); + + // Legacy chart was not formatting numbers, when was forming overrideColors. + // To support the behavior of the color overriding, it is required to skip all the formatting, except percent. + const overrideColor = useCallback( + (value: number, bands: number[], formatter?: FieldFormat) => { + const overrideColors = uiState?.get('vis.colors') ?? {}; + const valueIndex = bands.findIndex((band, index, allBands) => { + if (index === allBands.length - 1) { + return false; + } + + return value >= band && value < allBands[index + 1]; + }); + + if (valueIndex < 0 || valueIndex === bands.length - 1) { + return undefined; + } + const curValue = bands[valueIndex]; + const nextValue = bands[valueIndex + 1]; + + return overrideColors[ + `${formatter?.convert(curValue) ?? curValue} - ${ + formatter?.convert(nextValue) ?? nextValue + }` + ]; + }, + [uiState] + ); + const table = data; const accessors = getAccessorsFromArgs(args, table.columns); @@ -234,29 +330,39 @@ export const GaugeComponent: FC = memo( /> ); } + const customMetricFormatParams = isVisDimension(args.metric) ? args.metric.format : undefined; + const tableMetricFormatParams = metricColumn?.meta?.params?.params + ? metricColumn?.meta?.params + : undefined; + + const defaultMetricFormatParams = { + id: 'number', + params: { + pattern: max - min > 5 ? `0,0` : `0,0.0`, + }, + }; const tickFormatter = formatFactory( - metricColumn?.meta?.params?.params - ? metricColumn?.meta?.params - : { - id: 'number', - params: { - pattern: max - min > 5 ? `0,0` : `0,0.0`, - }, - } + customMetricFormatParams ?? tableMetricFormatParams ?? defaultMetricFormatParams ); - const colors = palette?.params?.colors ? normalizeColors(palette.params, min, max) : undefined; - const bands: number[] = (palette?.params as CustomPaletteState) - ? normalizeBands(args.palette?.params as CustomPaletteState, { min, max }) + + let bands: number[] = (palette?.params as CustomPaletteState) + ? normalizeBands(palette?.params as CustomPaletteState, { min, max }) : [min, max]; // TODO: format in charts - const formattedActual = Math.round(Math.min(Math.max(metricValue, min), max) * 1000) / 1000; - const goalConfig = getGoalConfig(gaugeType); - const totalTicks = getTicks(ticksPosition, [min, max], bands); + let actualValue = Math.round(Math.min(Math.max(metricValue, min), max) * 1000) / 1000; + const totalTicks = getTicks(ticksPosition, [min, max], bands, args.percentageMode); const ticks = gaugeType === GaugeShapes.CIRCLE ? totalTicks.slice(0, totalTicks.length - 1) : totalTicks; + if (args.percentageMode && palette?.params && palette?.params.stops?.length) { + bands = normalizeBandsLegacy(palette?.params as CustomPaletteState, actualValue); + actualValue = actualValueToPercentsLegacy(palette?.params as CustomPaletteState, actualValue); + } + + const goalConfig = getGoalConfig(gaugeType); + const labelMajorTitle = getTitle(labelMajorMode, labelMajor, metricColumn?.name); // added extra space for nice rendering @@ -265,7 +371,7 @@ export const GaugeComponent: FC = memo( const extraTitles = isRoundShape(gaugeType) ? { - centralMinor: tickFormatter.convert(metricValue), + centralMinor: tickFormatter.convert(actualValue), centralMajor: getTitle(centralMajorMode, centralMajor, metricColumn?.name), } : {}; @@ -283,21 +389,31 @@ export const GaugeComponent: FC = memo( subtype={getSubtypeByGaugeType(gaugeType)} base={bands[0]} target={goal && goal >= bands[0] && goal <= bands[bands.length - 1] ? goal : undefined} - actual={formattedActual} + actual={actualValue} tickValueFormatter={({ value: tickValue }) => tickFormatter.convert(tickValue)} + tooltipValueFormatter={(tooltipValue) => tickFormatter.convert(tooltipValue)} bands={bands} ticks={ticks} bandFillColor={ - colorMode === GaugeColorModes.PALETTE && colors + colorMode === GaugeColorModes.PALETTE ? (val) => { - const index = bands && bands.indexOf(val.value) - 1; - return colors && index >= 0 && colors[index] - ? colors[index] - : val.value <= bands[0] - ? colors[0] - : colors[colors.length - 1]; + const value = getPreviousSectionValue(val.value, bands); + + const overridedColor = overrideColor( + value, + args.percentageMode ? bands : args.palette?.params?.stops ?? [], + args.percentageMode ? tickFormatter : undefined + ); + + if (overridedColor) { + return overridedColor; + } + + return args.palette + ? getColor(value, args.palette, bands, args.percentageMode) ?? TRANSPARENT + : TRANSPARENT; } - : () => `rgba(255,255,255,0)` + : () => TRANSPARENT } labelMajor={labelMajorTitle ? `${labelMajorTitle}${majorExtraSpaces}` : labelMajorTitle} labelMinor={labelMinor ? `${labelMinor}${minorExtraSpaces}` : ''} diff --git a/src/plugins/chart_expressions/expression_gauge/public/expression_renderers/gauge_renderer.tsx b/src/plugins/chart_expressions/expression_gauge/public/expression_renderers/gauge_renderer.tsx index f762ddd5458b..c75f60f75c00 100644 --- a/src/plugins/chart_expressions/expression_gauge/public/expression_renderers/gauge_renderer.tsx +++ b/src/plugins/chart_expressions/expression_gauge/public/expression_renderers/gauge_renderer.tsx @@ -8,11 +8,12 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; +import { PersistedState } from '../../../../visualizations/public'; import { ThemeServiceStart } from '../../../../../core/public'; import { KibanaThemeProvider } from '../../../../kibana_react/public'; import { ExpressionRenderDefinition } from '../../../../expressions/common/expression_renderers'; import { EXPRESSION_GAUGE_NAME, GaugeExpressionProps } from '../../common'; -import { getFormatService, getThemeService } from '../services'; +import { getFormatService, getPaletteService, getThemeService } from '../services'; interface ExpressionGaugeRendererDependencies { theme: ThemeServiceStart; @@ -39,6 +40,8 @@ export const gaugeRenderer: ( {...config} formatFactory={getFormatService().deserialize} chartsThemeService={getThemeService()} + paletteService={getPaletteService()} + uiState={handlers.uiState as PersistedState} /> , diff --git a/src/plugins/chart_expressions/expression_gauge/public/plugin.ts b/src/plugins/chart_expressions/expression_gauge/public/plugin.ts index 8769af43a03d..7cc6ebec0d5d 100644 --- a/src/plugins/chart_expressions/expression_gauge/public/plugin.ts +++ b/src/plugins/chart_expressions/expression_gauge/public/plugin.ts @@ -9,7 +9,7 @@ import { ChartsPluginSetup } from '../../../charts/public'; import { CoreSetup, CoreStart } from '../../../../core/public'; import { Plugin as ExpressionsPublicPlugin } from '../../../expressions/public'; import { gaugeFunction } from '../common'; -import { setFormatService, setThemeService } from './services'; +import { setFormatService, setThemeService, setPaletteService } from './services'; import { gaugeRenderer } from './expression_renderers'; import type { FieldFormatsStart } from '../../../field_formats/public'; @@ -28,6 +28,10 @@ export interface ExpressionGaugePluginStart { export class ExpressionGaugePlugin { public setup(core: CoreSetup, { expressions, charts }: ExpressionGaugePluginSetup) { setThemeService(charts.theme); + charts.palettes.getPalettes().then((palettes) => { + setPaletteService(palettes); + }); + expressions.registerFunction(gaugeFunction); expressions.registerRenderer(gaugeRenderer({ theme: core.theme })); } diff --git a/src/plugins/chart_expressions/expression_gauge/public/services/index.ts b/src/plugins/chart_expressions/expression_gauge/public/services/index.ts index ef6cffcf6ec3..a4d0adc9f6a2 100644 --- a/src/plugins/chart_expressions/expression_gauge/public/services/index.ts +++ b/src/plugins/chart_expressions/expression_gauge/public/services/index.ts @@ -7,4 +7,5 @@ */ export { getFormatService, setFormatService } from './format_service'; -export { setThemeService, getThemeService } from './theme_service'; +export { getThemeService, setThemeService } from './theme_service'; +export { getPaletteService, setPaletteService } from './palette_service'; diff --git a/src/plugins/chart_expressions/expression_gauge/public/services/palette_service.ts b/src/plugins/chart_expressions/expression_gauge/public/services/palette_service.ts new file mode 100644 index 000000000000..cfcf2a818c5b --- /dev/null +++ b/src/plugins/chart_expressions/expression_gauge/public/services/palette_service.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 { createGetterSetter } from '../../../../kibana_utils/public'; +import { PaletteRegistry } from '../../../../charts/public'; + +export const [getPaletteService, setPaletteService] = + createGetterSetter('palette'); 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 b3eeedac201d..44520a30a9b8 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 @@ -9,7 +9,11 @@ import { i18n } from '@kbn/i18n'; import type { DatatableColumn } from '../../../../expressions/public'; import { ExpressionValueVisDimension } from '../../../../visualizations/common'; -import { prepareLogTable, Dimension } from '../../../../visualizations/common/utils'; +import { + prepareLogTable, + Dimension, + validateAccessor, +} from '../../../../visualizations/common/utils'; import { HeatmapExpressionFunctionDefinition } from '../types'; import { EXPRESSION_HEATMAP_NAME, @@ -150,6 +154,12 @@ export const heatmapFunction = (): HeatmapExpressionFunctionDefinition => ({ }, }, fn(data, args, handlers) { + validateAccessor(args.xAccessor, data.columns); + validateAccessor(args.yAccessor, data.columns); + validateAccessor(args.valueAccessor, data.columns); + validateAccessor(args.splitRowAccessor, data.columns); + validateAccessor(args.splitColumnAccessor, data.columns); + if (handlers?.inspectorAdapters?.tables) { const argsTable: Dimension[] = []; if (args.valueAccessor) { 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 efbc251f6360..29d5d2a0ca8c 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 @@ -31,6 +31,7 @@ export const heatmapLegendConfig: ExpressionFunctionDefinition< }, position: { types: ['string'], + default: Position.Right, options: [Position.Top, Position.Right, Position.Bottom, Position.Left], help: i18n.translate('expressionHeatmap.function.args.legend.position.help', { defaultMessage: 'Specifies the legend position.', @@ -49,6 +50,12 @@ export const heatmapLegendConfig: ExpressionFunctionDefinition< defaultMessage: 'Specifies whether or not the legend items should be truncated.', }), }, + legendSize: { + types: ['number'], + help: i18n.translate('expressionHeatmap.function.args.legendSize.help', { + defaultMessage: 'Specifies the legend size in pixels.', + }), + }, }, fn(input, args) { return { 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 10e43e426317..9208c8b48a29 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 @@ -38,6 +38,11 @@ export interface HeatmapLegendConfig { * Defines if the legend items should be truncated */ shouldTruncate?: boolean; + /** + * 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; } export type HeatmapLegendConfigResult = HeatmapLegendConfig & { 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 0adb06d91d26..7d0e8ad66511 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 @@ -25,8 +25,10 @@ import { import type { CustomPaletteState } from '../../../../charts/public'; import { search } from '../../../../data/public'; import { LegendToggle, EmptyPlaceholder } from '../../../../charts/public'; -import type { DatatableColumn } from '../../../../expressions/public'; -import { ExpressionValueVisDimension } from '../../../../visualizations/public'; +import { + getAccessorByDimension, + getFormatByAccessor, +} from '../../../../visualizations/common/utils'; import type { HeatmapRenderProps, FilterEvent, BrushEvent } from '../../common'; import { applyPaletteParams, findMinMaxByColumnId, getSortPredicate } from './helpers'; import { @@ -116,17 +118,6 @@ function computeColorRanges( return { colors, ranges }; } -const getAccessor = (value: string | ExpressionValueVisDimension, columns: DatatableColumn[]) => { - if (typeof value === 'string') { - return value; - } - const accessor = value.accessor; - if (typeof accessor === 'number') { - return columns[accessor].id; - } - return accessor.id; -}; - export const HeatmapComponent: FC = memo( ({ data, @@ -177,7 +168,7 @@ export const HeatmapComponent: FC = memo( const table = data; const valueAccessor = args.valueAccessor - ? getAccessor(args.valueAccessor, table.columns) + ? getAccessorByDimension(args.valueAccessor, table.columns) : undefined; const minMaxByColumnId = useMemo( () => findMinMaxByColumnId([valueAccessor!], table), @@ -185,8 +176,12 @@ export const HeatmapComponent: FC = memo( ); const paletteParams = args.palette?.params; - const xAccessor = args.xAccessor ? getAccessor(args.xAccessor, table.columns) : undefined; - const yAccessor = args.yAccessor ? getAccessor(args.yAccessor, table.columns) : undefined; + const xAccessor = args.xAccessor + ? getAccessorByDimension(args.xAccessor, table.columns) + : undefined; + const yAccessor = args.yAccessor + ? getAccessorByDimension(args.yAccessor, table.columns) + : undefined; const xAxisColumnIndex = table.columns.findIndex((v) => v.id === xAccessor); const yAxisColumnIndex = table.columns.findIndex((v) => v.id === yAccessor); @@ -310,9 +305,7 @@ export const HeatmapComponent: FC = memo( const { min, max } = minMaxByColumnId[valueAccessor!]; // formatters const xValuesFormatter = formatFactory(xAxisMeta?.params); - const metricFormatter = formatFactory( - typeof args.valueAccessor === 'string' ? valueColumn.meta.params : args?.valueAccessor?.format - ); + const metricFormatter = formatFactory(getFormatByAccessor(args.valueAccessor!, table.columns)); const dateHistogramMeta = xAxisColumn ? search.aggs.getDateHistogramMetaDataByDatatableColumn(xAxisColumn) : undefined; @@ -488,6 +481,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} 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.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts index 596b59b418b4..811ffb7ad3d9 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 @@ -9,33 +9,16 @@ import { i18n } from '@kbn/i18n'; import { visType } from '../types'; -import { prepareLogTable, Dimension } from '../../../../visualizations/common/utils'; +import { + prepareLogTable, + Dimension, + validateAccessor, +} from '../../../../visualizations/common/utils'; import { ColorMode } from '../../../../charts/common'; import { MetricVisExpressionFunctionDefinition } from '../types'; import { EXPRESSION_METRIC_NAME, LabelPosition } from '../constants'; -const validateOptions = ( - value: string, - availableOptions: Record, - getErrorMessage: () => string -) => { - if (!Object.values(availableOptions).includes(value)) { - throw new Error(getErrorMessage()); - } -}; - const errors = { - invalidColorModeError: () => - i18n.translate('expressionMetricVis.function.errors.invalidColorModeError', { - defaultMessage: 'Invalid color mode is specified. Supported color modes: {colorModes}', - values: { colorModes: Object.values(ColorMode).join(', ') }, - }), - invalidLabelPositionError: () => - i18n.translate('expressionMetricVis.function.errors.invalidLabelPositionError', { - defaultMessage: - 'Invalid label position is specified. Supported label positions: {labelPosition}', - values: { labelPosition: Object.values(LabelPosition).join(', ') }, - }), severalMetricsAndColorFullBackgroundSpecifiedError: () => i18n.translate( 'expressionMetricVis.function.errors.severalMetricsAndColorFullBackgroundSpecified', @@ -120,7 +103,7 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ default: LabelPosition.BOTTOM, }, metric: { - types: ['vis_dimension'], + types: ['string', 'vis_dimension'], help: i18n.translate('expressionMetricVis.function.metric.help', { defaultMessage: 'metric dimension configuration', }), @@ -128,7 +111,7 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ multi: true, }, bucket: { - types: ['vis_dimension'], + types: ['string', 'vis_dimension'], help: i18n.translate('expressionMetricVis.function.bucket.help', { defaultMessage: 'bucket dimension configuration', }), @@ -157,8 +140,8 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ } } - validateOptions(args.colorMode, ColorMode, errors.invalidColorModeError); - validateOptions(args.labelPosition, LabelPosition, errors.invalidLabelPositionError); + args.metric.forEach((metric) => validateAccessor(metric, input.columns)); + validateAccessor(args.bucket, input.columns); if (handlers?.inspectorAdapters?.tables) { const argsTable: Dimension[] = [ diff --git a/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts index ff8650eaa62d..894be5c4e651 100644 --- a/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts @@ -25,8 +25,8 @@ export interface MetricArguments { font: Style; labelFont: Style; labelPosition: LabelPositionType; - metric: ExpressionValueVisDimension[]; - bucket?: ExpressionValueVisDimension; + metric: Array; + bucket?: ExpressionValueVisDimension | string; colorFullBackground: boolean; autoScale?: boolean; } 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 86baf3fb47f8..f54fefd9a126 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 @@ -20,8 +20,8 @@ import { LabelPosition } from '../constants'; export const visType = 'metric'; export interface DimensionsVisParam { - metrics: ExpressionValueVisDimension[]; - bucket?: ExpressionValueVisDimension; + metrics: Array; + bucket?: ExpressionValueVisDimension | string; } export type LabelPositionType = $Values; 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 2118cd1c059b..ec13a41f86a6 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 @@ -10,12 +10,16 @@ import React, { Component } from 'react'; import { MetricVisValue } from './metric_value'; import { VisParams, MetricOptions } from '../../common/types'; import type { IFieldFormat } from '../../../../field_formats/common'; +import { + getColumnByAccessor, + getAccessor, + getFormatByAccessor, +} from '../../../../visualizations/common/utils'; import { Datatable } from '../../../../expressions/public'; import { CustomPaletteState } from '../../../../charts/public'; import { getFormatService, getPaletteService } from '../../../expression_metric/public/services'; import { ExpressionValueVisDimension } from '../../../../visualizations/public'; import { formatValue, shouldApplyColor } from '../utils'; -import { getColumnByAccessor } from '../utils/accessor'; import { needsLightText } from '../utils/palette'; import { withAutoScale } from './with_auto_scale'; @@ -49,17 +53,22 @@ class MetricVisComponent extends Component { let bucketFormatter: IFieldFormat; if (dimensions.bucket) { - bucketColumnId = getColumnByAccessor(dimensions.bucket.accessor, table.columns).id; - bucketFormatter = getFormatService().deserialize(dimensions.bucket.format); + const bucketColumn = getColumnByAccessor(dimensions.bucket!, table.columns); + bucketColumnId = bucketColumn?.id!; + bucketFormatter = getFormatService().deserialize( + getFormatByAccessor(dimensions.bucket, table.columns) + ); } return dimensions.metrics.reduce( - (acc: MetricOptions[], metric: ExpressionValueVisDimension) => { - const column = getColumnByAccessor(metric.accessor, table?.columns); - const formatter = getFormatService().deserialize(metric.format); + (acc: MetricOptions[], metric: string | ExpressionValueVisDimension) => { + const column = getColumnByAccessor(metric, table?.columns); + const formatter = getFormatService().deserialize( + getFormatByAccessor(metric, table.columns) + ); const metrics = table.rows.map((row, rowIndex) => { - let title = column.name; - let value: number = row[column.id]; + let title = column!.name; + let value: number = row[column!.id]; const color = palette ? this.getColor(value, palette) : undefined; if (isPercentageMode && stops.length) { @@ -102,7 +111,7 @@ class MetricVisComponent extends Component { data: [ { table, - column: dimensions.bucket.accessor, + column: getAccessor(dimensions.bucket), row, }, ], 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 35c754d8b0b2..e948b95af52f 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 @@ -28,11 +28,8 @@ export const MetricVisValue = ({ autoScale, }: MetricVisValueProps) => { const containerClassName = classNames('mtrVis__container', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'mtrVis__container--light': metric.lightText, - // eslint-disable-next-line @typescript-eslint/naming-convention 'mtrVis__container-isfilterable': onFilter, - // eslint-disable-next-line @typescript-eslint/naming-convention 'mtrVis__container-isfull': !autoScale && colorFullBackground, }); @@ -43,6 +40,7 @@ export const MetricVisValue = ({ style={autoScale && colorFullBackground ? {} : { backgroundColor: metric.bgColor }} >
{labelConfig.show && (
{ - if (typeof accessor === 'number') { - return columns[accessor]; - } - return columns.filter(({ id }) => accessor.id === id)[0]; -}; 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 f1bd7834e52f..e07e367d1078 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 @@ -120,6 +120,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", + "legendSize": undefined, "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 d73f53277a2b..28d5f35c89cb 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,6 +112,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", + "legendSize": undefined, "maxLegendLines": 2, "metric": Object { "accessor": 0, @@ -245,6 +246,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", + "legendSize": undefined, "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 b8d8032fa583..ff2a4ece368f 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 @@ -120,6 +120,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", + "legendSize": undefined, "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 7c6922cdff84..b0905139d3f1 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 @@ -102,6 +102,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", + "legendSize": undefined, "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 aa433b8eaee2..250d0f1033ff 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 @@ -45,6 +45,10 @@ export const strings = { i18n.translate('expressionPartitionVis.reusable.function.args.legendPositionHelpText', { defaultMessage: 'Position the legend on top, bottom, left, right of the chart', }), + getLegendSizeArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.legendSizeHelpText', { + defaultMessage: 'Specifies the legend size in pixels', + }), getNestedLegendArgHelp: () => i18n.translate('expressionPartitionVis.reusable.function.args.nestedLegendHelpText', { defaultMessage: 'Show a more detailed legend', 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 2f4c681ef336..609fa3a433cd 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 @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +import { Position } from '@elastic/charts'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; -import { prepareLogTable } from '../../../../visualizations/common/utils'; +import { prepareLogTable, validateAccessor } from '../../../../visualizations/common/utils'; import { ChartTypes, MosaicVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -24,22 +25,22 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ help: strings.getPieVisFunctionName(), args: { metric: { - types: ['vis_dimension'], + types: ['string', 'vis_dimension'], help: strings.getMetricArgHelp(), required: true, }, buckets: { - types: ['vis_dimension'], + types: ['string', 'vis_dimension'], help: strings.getBucketsArgHelp(), multi: true, }, splitColumn: { - types: ['vis_dimension'], + types: ['string', 'vis_dimension'], help: strings.getSplitColumnArgHelp(), multi: true, }, splitRow: { - types: ['vis_dimension'], + types: ['string', 'vis_dimension'], help: strings.getSplitRowArgHelp(), multi: true, }, @@ -56,7 +57,13 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ }, legendPosition: { types: ['string'], + default: Position.Right, help: strings.getLegendPositionArgHelp(), + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + }, + legendSize: { + types: ['number'], + help: strings.getLegendSizeArgHelp(), }, nestedLegend: { types: ['boolean'], @@ -98,6 +105,17 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); } + validateAccessor(args.metric, context.columns); + if (args.buckets) { + args.buckets.forEach((bucket) => validateAccessor(bucket, context.columns)); + } + if (args.splitColumn) { + args.splitColumn.forEach((splitColumn) => validateAccessor(splitColumn, context.columns)); + } + if (args.splitRow) { + args.splitRow.forEach((splitRow) => validateAccessor(splitRow, context.columns)); + } + const visConfig: PartitionVisParams = { ...args, ariaLabel: 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 d743637f44b8..46d564f15541 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,7 +8,7 @@ import { Position } from '@elastic/charts'; import { EmptySizeRatios, LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; -import { prepareLogTable } from '../../../../visualizations/common/utils'; +import { prepareLogTable, validateAccessor } from '../../../../visualizations/common/utils'; import { ChartTypes, PieVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -25,22 +25,22 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ help: strings.getPieVisFunctionName(), args: { metric: { - types: ['vis_dimension'], + types: ['vis_dimension', 'string'], help: strings.getMetricArgHelp(), required: true, }, buckets: { - types: ['vis_dimension'], + types: ['vis_dimension', 'string'], help: strings.getBucketsArgHelp(), multi: true, }, splitColumn: { - types: ['vis_dimension'], + types: ['vis_dimension', 'string'], help: strings.getSplitColumnArgHelp(), multi: true, }, splitRow: { - types: ['vis_dimension'], + types: ['vis_dimension', 'string'], help: strings.getSplitRowArgHelp(), multi: true, }, @@ -57,9 +57,14 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ }, legendPosition: { types: ['string'], + default: Position.Right, help: strings.getLegendPositionArgHelp(), options: [Position.Top, Position.Right, Position.Bottom, Position.Left], }, + legendSize: { + types: ['number'], + help: strings.getLegendSizeArgHelp(), + }, nestedLegend: { types: ['boolean'], help: strings.getNestedLegendArgHelp(), @@ -120,6 +125,17 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); } + validateAccessor(args.metric, context.columns); + if (args.buckets) { + args.buckets.forEach((bucket) => validateAccessor(bucket, context.columns)); + } + if (args.splitColumn) { + args.splitColumn.forEach((splitColumn) => validateAccessor(splitColumn, context.columns)); + } + if (args.splitRow) { + args.splitRow.forEach((splitRow) => validateAccessor(splitRow, context.columns)); + } + const visConfig: PartitionVisParams = { ...args, ariaLabel: 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 d2016b3ae0c8..4d3faa79c7a3 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 @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +import { Position } from '@elastic/charts'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; -import { prepareLogTable } from '../../../../visualizations/common/utils'; +import { prepareLogTable, validateAccessor } from '../../../../visualizations/common/utils'; import { ChartTypes, TreemapVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -56,7 +57,13 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => }, legendPosition: { types: ['string'], + default: Position.Right, help: strings.getLegendPositionArgHelp(), + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + }, + legendSize: { + types: ['number'], + help: strings.getLegendSizeArgHelp(), }, nestedLegend: { types: ['boolean'], @@ -98,6 +105,17 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); } + validateAccessor(args.metric, context.columns); + if (args.buckets) { + args.buckets.forEach((bucket) => validateAccessor(bucket, context.columns)); + } + if (args.splitColumn) { + args.splitColumn.forEach((splitColumn) => validateAccessor(splitColumn, context.columns)); + } + if (args.splitRow) { + args.splitRow.forEach((splitRow) => validateAccessor(splitRow, context.columns)); + } + const visConfig: PartitionVisParams = { ...args, ariaLabel: 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 242d8a2c9bac..303a39d1de43 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 @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +import { Position } from '@elastic/charts'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; -import { prepareLogTable } from '../../../../visualizations/common/utils'; +import { prepareLogTable, validateAccessor } from '../../../../visualizations/common/utils'; import { ChartTypes, WaffleVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -55,7 +56,13 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ }, legendPosition: { types: ['string'], + default: Position.Right, help: strings.getLegendPositionArgHelp(), + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + }, + legendSize: { + types: ['number'], + help: strings.getLegendSizeArgHelp(), }, truncateLegend: { types: ['boolean'], @@ -92,6 +99,17 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); } + validateAccessor(args.metric, context.columns); + if (args.bucket) { + validateAccessor(args.bucket, context.columns); + } + if (args.splitColumn) { + args.splitColumn.forEach((splitColumn) => validateAccessor(splitColumn, context.columns)); + } + if (args.splitRow) { + args.splitRow.forEach((splitRow) => validateAccessor(splitRow, context.columns)); + } + const buckets = args.bucket ? [args.bucket] : []; const visConfig: PartitionVisParams = { ...args, 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 01ca39c9cbb3..c6f29ef90c8e 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 @@ -28,10 +28,10 @@ export interface Dimension { } export interface Dimensions { - metric?: ExpressionValueVisDimension; - buckets?: ExpressionValueVisDimension[]; - splitRow?: ExpressionValueVisDimension[]; - splitColumn?: ExpressionValueVisDimension[]; + metric?: ExpressionValueVisDimension | string; + buckets?: Array; + splitRow?: Array; + splitColumn?: Array; } export interface LabelsParams { @@ -52,13 +52,14 @@ interface VisCommonParams { legendPosition: Position; truncateLegend: boolean; maxLegendLines: number; + legendSize?: number; ariaLabel?: string; } interface VisCommonConfig extends VisCommonParams { - metric: ExpressionValueVisDimension; - splitColumn?: ExpressionValueVisDimension[]; - splitRow?: ExpressionValueVisDimension[]; + metric: ExpressionValueVisDimension | string; + splitColumn?: Array; + splitRow?: Array; labels: ExpressionValuePartitionLabels; palette: PaletteOutput; } @@ -77,7 +78,7 @@ export interface PartitionVisParams extends VisCommonParams { } export interface PieVisConfig extends VisCommonConfig { - buckets?: ExpressionValueVisDimension[]; + buckets?: Array; isDonut: boolean; emptySizeRatio?: EmptySizeRatios; respectSourceOrder?: boolean; @@ -87,17 +88,17 @@ export interface PieVisConfig extends VisCommonConfig { } export interface TreemapVisConfig extends VisCommonConfig { - buckets?: ExpressionValueVisDimension[]; + buckets?: Array; nestedLegend: boolean; } export interface MosaicVisConfig extends VisCommonConfig { - buckets?: ExpressionValueVisDimension[]; + buckets?: Array; nestedLegend: boolean; } export interface WaffleVisConfig extends VisCommonConfig { - bucket?: ExpressionValueVisDimension; + bucket?: ExpressionValueVisDimension | string; showValuesInLegend: boolean; } 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 42a298d00d48..2b587c942f19 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,6 +22,7 @@ import { import { useEuiTheme } from '@elastic/eui'; import { LegendToggle, ChartsPluginSetup, PaletteRegistry } from '../../../../charts/public'; import type { PersistedState } from '../../../../visualizations/public'; +import { getColumnByAccessor } from '../../../../visualizations/common/utils'; import { Datatable, DatatableColumn, @@ -46,7 +47,6 @@ import { getPartitionTheme, getColumns, getSplitDimensionAccessor, - getColumnByAccessor, isLegendFlat, shouldShowLegend, generateFormatters, @@ -224,9 +224,21 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { const { splitColumn, splitRow } = visParams.dimensions; const splitChartFormatter = splitColumn - ? getFormatter(splitColumn[0], formatters, defaultFormatter) + ? getFormatter( + typeof splitColumn[0] === 'string' + ? getColumnByAccessor(splitColumn[0], visData.columns)! + : splitColumn[0], + formatters, + defaultFormatter + ) : splitRow - ? getFormatter(splitRow[0], formatters, defaultFormatter) + ? getFormatter( + typeof splitRow[0] === 'string' + ? getColumnByAccessor(splitRow[0], visData.columns)! + : splitRow[0], + formatters, + defaultFormatter + ) : undefined; const percentFormatter = services.fieldFormats.deserialize({ @@ -298,9 +310,9 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { : undefined; const splitChartDimension = splitColumn - ? getColumnByAccessor(splitColumn[0].accessor, visData.columns) + ? getColumnByAccessor(splitColumn[0], visData.columns) : splitRow - ? getColumnByAccessor(splitRow[0].accessor, visData.columns) + ? getColumnByAccessor(splitRow[0], visData.columns) : undefined; /** @@ -375,6 +387,7 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { showLegend ?? shouldShowLegend(visType, visParams.legendDisplay, bucketColumns) } legendPosition={legendPosition} + legendSize={visParams.legendSize} legendMaxDepth={visParams.nestedLegend ? undefined : 1} legendColorPicker={props.uiState ? LegendColorPickerWrapper : undefined} flatLegend={flatLegend} @@ -394,6 +407,7 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { getLegendActionEventData(visData), handleLegendAction, visParams, + visData, services.data.actions, services.fieldFormats )} diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/accessor.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/accessor.test.ts deleted file mode 100644 index f1023d478d40..000000000000 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/accessor.test.ts +++ /dev/null @@ -1,50 +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 { ExpressionValueVisDimension } from '../../../../visualizations/common'; -import { createMockVisData } from '../mocks'; -import { getColumnByAccessor } from './accessor'; - -const visData = createMockVisData(); - -describe('getColumnByAccessor', () => { - it('returns column by the index', () => { - const index = 1; - const column = getColumnByAccessor(index, visData.columns); - expect(column).toEqual(visData.columns[index]); - }); - - it('returns undefiend if the index is higher then amount of columns', () => { - const index = visData.columns.length; - const column = getColumnByAccessor(index, visData.columns); - expect(column).toBeUndefined(); - }); - - it('returns column by id', () => { - const column = visData.columns[1]; - const accessor: ExpressionValueVisDimension['accessor'] = { - id: column.id, - name: '', - meta: { type: column.meta.type }, - }; - - const foundColumn = getColumnByAccessor(accessor, visData.columns); - expect(foundColumn).toEqual(column); - }); - - it('returns undefined for the accessor to non-existent column', () => { - const accessor: ExpressionValueVisDimension['accessor'] = { - id: 'non-existent-column', - name: '', - meta: { type: 'number' }, - }; - - const column = getColumnByAccessor(accessor, visData.columns); - expect(column).toBeUndefined(); - }); -}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/accessor.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/accessor.ts deleted file mode 100644 index 679a1ca01aff..000000000000 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/accessor.ts +++ /dev/null @@ -1,20 +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 { Datatable } from '../../../../expressions'; -import { ExpressionValueVisDimension } from '../../../../visualizations/common'; - -export const getColumnByAccessor = ( - accessor: ExpressionValueVisDimension['accessor'], - columns: Datatable['columns'] = [] -) => { - if (typeof accessor === 'number') { - return columns[accessor]; - } - return columns.filter(({ id }) => accessor.id === id)[0]; -}; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.ts index 063315e3aab9..e365bce5737a 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.ts @@ -7,12 +7,12 @@ */ import { ExpressionValueVisDimension } from '../../../../visualizations/common'; +import { getColumnByAccessor, getFormatByAccessor } from '../../../../visualizations/common/utils'; import { DatatableColumn, Datatable } from '../../../../expressions/public'; import { BucketColumns, PartitionVisParams } from '../../common/types'; -import { getColumnByAccessor } from './accessor'; const getMetricColumn = ( - metricAccessor: ExpressionValueVisDimension['accessor'], + metricAccessor: ExpressionValueVisDimension | string, visData: Datatable ) => { return getColumnByAccessor(metricAccessor, visData.columns); @@ -27,20 +27,29 @@ export const getColumns = ( } => { const { metric, buckets } = visParams.dimensions; if (buckets && buckets.length > 0) { - const bucketColumns: Array> = buckets.map(({ accessor, format }) => ({ - ...getColumnByAccessor(accessor, visData.columns), - format, - })); + const bucketColumns: Array> = buckets.map((bucket) => { + const column = getColumnByAccessor(bucket, visData.columns); + return { + ...column, + format: getFormatByAccessor(bucket, visData.columns), + }; + }); const lastBucketId = bucketColumns[bucketColumns.length - 1].id; const matchingIndex = visData.columns.findIndex((col) => col.id === lastBucketId); return { bucketColumns, - metricColumn: getMetricColumn(metric?.accessor ?? matchingIndex + 1, visData), + metricColumn: getMetricColumn( + metric ?? { accessor: matchingIndex + 1, type: 'vis_dimension', format: {} }, + visData + )!, }; } - const metricColumn = getMetricColumn(metric?.accessor ?? 0, visData); + const metricColumn = getMetricColumn( + metric ?? { accessor: 0, type: 'vis_dimension', format: {} }, + visData + )!; return { metricColumn, bucketColumns: [{ name: metricColumn.name }], diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_legend_actions.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_legend_actions.tsx index 72793d771a0e..bf02a074dc18 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_legend_actions.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_legend_actions.tsx @@ -12,6 +12,8 @@ import { i18n } from '@kbn/i18n'; import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; import { LegendAction, SeriesIdentifier, useLegendAction } from '@elastic/charts'; import { DataPublicPluginStart } from '../../../../data/public'; +import { Datatable } from '../../../../expressions/public'; +import { getFormatByAccessor, getAccessor } from '../../../../visualizations/common/utils'; import { PartitionVisParams } from '../../common/types'; import { FieldFormatsStart } from '../../../../field_formats/public'; import { FilterEvent } from '../types'; @@ -24,6 +26,7 @@ export const getLegendActions = ( getFilterEventData: (series: SeriesIdentifier) => FilterEvent | null, onFilter: (data: FilterEvent, negate?: any) => void, visParams: PartitionVisParams, + visData: Datatable, actions: DataPublicPluginStart['actions'], formatter: FieldFormatsStart ): LegendAction => { @@ -43,10 +46,13 @@ export const getLegendActions = ( let formattedTitle = ''; if (visParams.dimensions.buckets) { - const column = visParams.dimensions.buckets.find( - (bucket) => bucket.accessor === filterData.data.data[0].column + const accessor = visParams.dimensions.buckets.find( + (bucket) => getAccessor(bucket) === filterData.data.data[0].column ); - formattedTitle = formatter.deserialize(column?.format).convert(pieSeries.key) ?? ''; + formattedTitle = + formatter + .deserialize(accessor ? getFormatByAccessor(accessor, visData.columns) : undefined) + .convert(pieSeries.key) ?? ''; } const title = formattedTitle || pieSeries.key; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_split_dimension_accessor.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_split_dimension_accessor.ts index 1a18a1134baf..6491d79a083a 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_split_dimension_accessor.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_split_dimension_accessor.ts @@ -6,19 +6,19 @@ * Side Public License, v 1. */ import { AccessorFn } from '@elastic/charts'; -import { getColumnByAccessor } from './accessor'; import { DatatableColumn } from '../../../../expressions/public'; import { FieldFormat, FormatFactory } from '../../../../field_formats/common'; import { ExpressionValueVisDimension } from '../../../../visualizations/common'; +import { getColumnByAccessor } from '../../../../visualizations/common/utils'; import { getFormatter } from './formatters'; export const getSplitDimensionAccessor = ( columns: DatatableColumn[], - splitDimension: ExpressionValueVisDimension, + splitDimension: ExpressionValueVisDimension | string, formatters: Record, defaultFormatFactory: FormatFactory ): AccessorFn => { - const splitChartColumn = getColumnByAccessor(splitDimension.accessor, columns); + const splitChartColumn = getColumnByAccessor(splitDimension, columns)!; const accessor = splitChartColumn.id; const formatter = getFormatter(splitChartColumn, formatters, defaultFormatFactory); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/index.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/index.ts index b0ce92f1205e..f0ec7e34b22b 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/index.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/index.ts @@ -14,7 +14,6 @@ export { getPartitionTheme } from './get_partition_theme'; export { getColumns } from './get_columns'; export { getSplitDimensionAccessor } from './get_split_dimension_accessor'; export { getDistinctSeries } from './get_distinct_series'; -export { getColumnByAccessor } from './accessor'; export { isLegendFlat, shouldShowLegend } from './legend'; export { generateFormatters, getAvailableFormatter, getFormatter } from './formatters'; export { getPartitionType } from './get_partition_type'; diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/constants.ts b/src/plugins/chart_expressions/expression_tagcloud/common/constants.ts index 3d834448a94e..8c4041c6a3a7 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/constants.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/constants.ts @@ -10,3 +10,15 @@ export const PLUGIN_ID = 'expressionTagcloud'; export const PLUGIN_NAME = 'expressionTagcloud'; export const EXPRESSION_NAME = 'tagcloud'; + +export const ScaleOptions = { + LINEAR: 'linear', + LOG: 'log', + SQUARE_ROOT: 'square root', +} as const; + +export const Orientation = { + SINGLE: 'single', + RIGHT_ANGLED: 'right angled', + MULTIPLE: 'multiple', +} as const; 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 86a371afd691..dc77848d37ce 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 @@ -11,6 +11,7 @@ import { tagcloudFunction } from './tagcloud_function'; import { functionWrapper } from '../../../../expressions/common/expression_functions/specs/tests/utils'; import { ExpressionValueVisDimension } from '../../../../visualizations/public'; import { Datatable } from '../../../../expressions/common/expression_types/specs'; +import { ScaleOptions, Orientation } from '../constants'; type Arguments = Parameters['fn']>[1]; @@ -30,8 +31,8 @@ describe('interpreter/functions#tagcloud', () => { ], } as unknown as Datatable; const visConfig = { - scale: 'linear', - orientation: 'single', + scale: ScaleOptions.LINEAR, + orientation: Orientation.SINGLE, minFontSize: 18, maxFontSize: 72, showLabel: true, diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts index 85f98b35cede..0b9bbc36e5c5 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts @@ -7,10 +7,14 @@ */ import { i18n } from '@kbn/i18n'; -import { prepareLogTable, Dimension } from '../../../../visualizations/common/utils'; +import { + prepareLogTable, + Dimension, + validateAccessor, +} from '../../../../visualizations/common/utils'; import { TagCloudRendererParams } from '../types'; import { ExpressionTagcloudFunction } from '../types'; -import { EXPRESSION_NAME } from '../constants'; +import { EXPRESSION_NAME, ScaleOptions, Orientation } from '../constants'; const strings = { help: i18n.translate('expressionTagcloud.functions.tagcloudHelpText', { @@ -87,14 +91,14 @@ export const tagcloudFunction: ExpressionTagcloudFunction = () => { args: { scale: { types: ['string'], - default: 'linear', - options: ['linear', 'log', 'square root'], + default: ScaleOptions.LINEAR, + options: [ScaleOptions.LINEAR, ScaleOptions.LOG, ScaleOptions.SQUARE_ROOT], help: argHelp.scale, }, orientation: { types: ['string'], - default: 'single', - options: ['single', 'right angled', 'multiple'], + default: Orientation.SINGLE, + options: [Orientation.SINGLE, Orientation.RIGHT_ANGLED, Orientation.MULTIPLE], help: argHelp.orientation, }, minFontSize: { @@ -118,12 +122,12 @@ export const tagcloudFunction: ExpressionTagcloudFunction = () => { default: '{palette}', }, metric: { - types: ['vis_dimension'], + types: ['vis_dimension', 'string'], help: argHelp.metric, required: true, }, bucket: { - types: ['vis_dimension'], + types: ['vis_dimension', 'string'], help: argHelp.bucket, }, ariaLabel: { @@ -133,6 +137,9 @@ export const tagcloudFunction: ExpressionTagcloudFunction = () => { }, }, fn(input, args, handlers) { + validateAccessor(args.metric, input.columns); + validateAccessor(args.bucket, input.columns); + const visParams: TagCloudRendererParams = { scale: args.scale, orientation: args.orientation, diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts index 44fc6f304879..7463b6735bfe 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts @@ -5,6 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + +import { $Values } from '@kbn/utility-types'; import { PaletteOutput } from '../../../../charts/common'; import { Datatable, @@ -12,11 +14,11 @@ import { ExpressionValueRender, } from '../../../../expressions'; import { ExpressionValueVisDimension } from '../../../../visualizations/common'; -import { EXPRESSION_NAME } from '../constants'; +import { EXPRESSION_NAME, ScaleOptions, Orientation } from '../constants'; interface TagCloudCommonParams { - scale: 'linear' | 'log' | 'square root'; - orientation: 'single' | 'right angled' | 'multiple'; + scale: $Values; + orientation: $Values; minFontSize: number; maxFontSize: number; showLabel: boolean; @@ -24,14 +26,14 @@ interface TagCloudCommonParams { } export interface TagCloudVisConfig extends TagCloudCommonParams { - metric: ExpressionValueVisDimension; - bucket?: ExpressionValueVisDimension; + metric: ExpressionValueVisDimension | string; + bucket?: ExpressionValueVisDimension | string; } export interface TagCloudRendererParams extends TagCloudCommonParams { palette: PaletteOutput; - metric: ExpressionValueVisDimension; - bucket?: ExpressionValueVisDimension; + metric: ExpressionValueVisDimension | string; + bucket?: ExpressionValueVisDimension | string; } export interface TagcloudRendererConfig { diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx index eca35918d728..9866ec644ae2 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx @@ -11,6 +11,7 @@ import { storiesOf } from '@storybook/react'; import { tagcloudRenderer } from '../expression_renderers'; import { Render } from '../../../../presentation_util/public/__stories__'; import { TagcloudRendererConfig } from '../../common/types'; +import { ScaleOptions, Orientation } from '../../common/constants'; import { palettes } from '../__mocks__/palettes'; import { theme } from '../__mocks__/theme'; @@ -39,8 +40,8 @@ const config: TagcloudRendererConfig = { ], }, visParams: { - scale: 'linear', - orientation: 'single', + scale: ScaleOptions.LINEAR, + orientation: Orientation.SINGLE, minFontSize: 18, maxFontSize: 72, showLabel: true, @@ -78,7 +79,7 @@ storiesOf('renderers/tag_cloud_vis', module) return ( tagcloudRenderer({ palettes, theme })} - config={{ ...config, visParams: { ...config.visParams, scale: 'log' } }} + config={{ ...config, visParams: { ...config.visParams, scale: ScaleOptions.LOG } }} {...containerSize} /> ); @@ -87,7 +88,7 @@ storiesOf('renderers/tag_cloud_vis', module) return ( tagcloudRenderer({ palettes, theme })} - config={{ ...config, visParams: { ...config.visParams, scale: 'square root' } }} + config={{ ...config, visParams: { ...config.visParams, scale: ScaleOptions.SQUARE_ROOT } }} {...containerSize} /> ); @@ -96,7 +97,10 @@ storiesOf('renderers/tag_cloud_vis', module) return ( tagcloudRenderer({ palettes, theme })} - config={{ ...config, visParams: { ...config.visParams, orientation: 'right angled' } }} + config={{ + ...config, + visParams: { ...config.visParams, orientation: Orientation.RIGHT_ANGLED }, + }} {...containerSize} /> ); @@ -105,7 +109,10 @@ storiesOf('renderers/tag_cloud_vis', module) return ( tagcloudRenderer({ palettes, theme })} - config={{ ...config, visParams: { ...config.visParams, orientation: 'multiple' } }} + config={{ + ...config, + visParams: { ...config.visParams, orientation: Orientation.MULTIPLE }, + }} {...containerSize} /> ); diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx index f65630e422cc..a85455b92401 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx @@ -13,6 +13,7 @@ import { mount } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; import TagCloudChart, { TagCloudChartProps } from './tagcloud_component'; import { TagCloudRendererParams } from '../../common/types'; +import { ScaleOptions, Orientation } from '../../common/constants'; jest.mock('../format_service', () => ({ getFormatService: jest.fn(() => { @@ -51,8 +52,8 @@ const visData: Datatable = { const visParams: TagCloudRendererParams = { bucket: { type: 'vis_dimension', accessor: 0, format: { params: {} } }, metric: { type: 'vis_dimension', accessor: 1, format: { params: {} } }, - scale: 'linear', - orientation: 'single', + scale: ScaleOptions.LINEAR, + orientation: Orientation.SINGLE, palette: { type: 'palette', name: 'default', @@ -166,7 +167,10 @@ describe('TagCloudChart', function () { }); it('sets the angles correctly', async () => { - const newVisParams: TagCloudRendererParams = { ...visParams, orientation: 'right angled' }; + const newVisParams: TagCloudRendererParams = { + ...visParams, + orientation: Orientation.RIGHT_ANGLED, + }; const newProps = { ...wrapperPropsWithIndexes, visParams: newVisParams }; const component = mount(); expect(component.find(Wordcloud).prop('endAngle')).toBe(90); 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 560507f84831..4c65c5837868 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 @@ -12,14 +12,15 @@ import { throttle } from 'lodash'; import { EuiIconTip, EuiResizeObserver } from '@elastic/eui'; import { Chart, Settings, Wordcloud, RenderChangeListener } from '@elastic/charts'; import type { PaletteRegistry, PaletteOutput } from '../../../../charts/public'; -import { - Datatable, - DatatableColumn, - IInterpreterRenderHandlers, -} from '../../../../expressions/public'; +import { IInterpreterRenderHandlers } from '../../../../expressions/public'; import { getFormatService } from '../format_service'; -import { ExpressionValueVisDimension } from '../../../../visualizations/public'; +import { + getColumnByAccessor, + getAccessor, + getFormatByAccessor, +} from '../../../../visualizations/common/utils'; import { TagcloudRendererConfig } from '../../common/types'; +import { ScaleOptions, Orientation } from '../../common/constants'; import './tag_cloud.scss'; @@ -60,31 +61,20 @@ const getColor = ( }; const ORIENTATIONS = { - single: { + [Orientation.SINGLE]: { endAngle: 0, angleCount: 360, }, - 'right angled': { + [Orientation.RIGHT_ANGLED]: { endAngle: 90, angleCount: 2, }, - multiple: { + [Orientation.MULTIPLE]: { endAngle: -90, angleCount: 12, }, }; -const getColumn = ( - accessor: ExpressionValueVisDimension['accessor'], - columns: Datatable['columns'] -): DatatableColumn => { - if (typeof accessor === 'number') { - return columns[accessor]; - } - - return columns.filter(({ id }) => id === accessor.id)[0]; -}; - export const TagCloudChart = ({ visData, visParams, @@ -95,11 +85,15 @@ export const TagCloudChart = ({ }: TagCloudChartProps) => { const [warning, setWarning] = useState(false); const { bucket, metric, scale, palette, showLabel, orientation } = visParams; - const bucketFormatter = bucket ? getFormatService().deserialize(bucket.format) : null; + + const bucketFormatter = bucket + ? getFormatService().deserialize(getFormatByAccessor(bucket, visData.columns)) + : null; const tagCloudData = useMemo(() => { - const tagColumn = bucket ? getColumn(bucket.accessor, visData.columns).id : null; - const metricColumn = getColumn(metric.accessor, visData.columns).id; + const bucketColumn = bucket ? getColumnByAccessor(bucket, visData.columns)! : null; + const tagColumn = bucket ? bucketColumn!.id : null; + const metricColumn = getColumnByAccessor(metric, visData.columns)!.id; const metrics = visData.rows.map((row) => row[metricColumn]); const values = bucket && tagColumn !== null ? visData.rows.map((row) => row[tagColumn]) : []; @@ -120,7 +114,7 @@ export const TagCloudChart = ({ }, [ bucket, bucketFormatter, - metric.accessor, + metric, palette, palettesRegistry, syncColors, @@ -129,8 +123,8 @@ export const TagCloudChart = ({ ]); const label = bucket - ? `${getColumn(bucket.accessor, visData.columns).name} - ${ - getColumn(metric.accessor, visData.columns).name + ? `${getColumnByAccessor(bucket, visData.columns)!.name} - ${ + getColumnByAccessor(metric, visData.columns)!.name }` : ''; @@ -156,7 +150,7 @@ export const TagCloudChart = ({ if (!bucket) { return; } - const termsBucketId = getColumn(bucket.accessor, visData.columns).id; + const termsBucketId = getColumnByAccessor(bucket, visData.columns)!.id; const clickedValue = elements[0][0].text; const rowIndex = visData.rows.findIndex((row) => { @@ -176,7 +170,7 @@ export const TagCloudChart = ({ data: [ { table: visData, - column: bucket.accessor, + column: getAccessor(bucket), row: rowIndex, }, ], @@ -210,7 +204,7 @@ export const TagCloudChart = ({ maxFontSize={visParams.maxFontSize} spiral="archimedean" data={tagCloudData} - weightFn={scale === 'square root' ? 'squareRoot' : scale} + weightFn={scale === ScaleOptions.SQUARE_ROOT ? 'squareRoot' : scale} outOfRoomCallback={() => { setWarning(true); }} diff --git a/src/plugins/charts/public/static/components/color_picker.tsx b/src/plugins/charts/public/static/components/color_picker.tsx index 6fcf9d19638f..25c210b87a0e 100644 --- a/src/plugins/charts/public/static/components/color_picker.tsx +++ b/src/plugins/charts/public/static/components/color_picker.tsx @@ -156,7 +156,6 @@ export const ColorPicker = ({ size="l" color={selectedColor} className={classNames('visColorPicker__valueDot', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'visColorPicker__valueDot-isSelected': color === selectedColor, })} style={{ color }} diff --git a/src/plugins/charts/public/static/components/legend_toggle.tsx b/src/plugins/charts/public/static/components/legend_toggle.tsx index 668971e6b9fc..8bb4f47838de 100644 --- a/src/plugins/charts/public/static/components/legend_toggle.tsx +++ b/src/plugins/charts/public/static/components/legend_toggle.tsx @@ -31,7 +31,6 @@ const LegendToggleComponent = ({ onClick, showLegend, legendPosition }: LegendTo color="text" onClick={onClick} className={classNames('echLegend__toggle', `echLegend__toggle--position-${legendPosition}`, { - // eslint-disable-next-line @typescript-eslint/naming-convention 'echLegend__toggle--isOpen': showLegend, })} aria-label={i18n.translate('charts.legend.toggleLegendButtonAriaLabel', { diff --git a/src/plugins/console/public/application/components/settings_modal.tsx b/src/plugins/console/public/application/components/settings_modal.tsx index c4be329dabcb..eafc2dea3f87 100644 --- a/src/plugins/console/public/application/components/settings_modal.tsx +++ b/src/plugins/console/public/application/components/settings_modal.tsx @@ -70,6 +70,7 @@ export function DevToolsSettingsModal(props: Props) { const [fields, setFields] = useState(props.settings.autocomplete.fields); const [indices, setIndices] = useState(props.settings.autocomplete.indices); const [templates, setTemplates] = useState(props.settings.autocomplete.templates); + const [dataStreams, setDataStreams] = useState(props.settings.autocomplete.dataStreams); const [polling, setPolling] = useState(props.settings.polling); const [pollInterval, setPollInterval] = useState(props.settings.pollInterval); const [tripleQuotes, setTripleQuotes] = useState(props.settings.tripleQuotes); @@ -97,12 +98,20 @@ export function DevToolsSettingsModal(props: Props) { }), stateSetter: setTemplates, }, + { + id: 'dataStreams', + label: i18n.translate('console.settingsPage.dataStreamsLabelText', { + defaultMessage: 'Data streams', + }), + stateSetter: setDataStreams, + }, ]; const checkboxIdToSelectedMap = { fields, indices, templates, + dataStreams, }; const onAutocompleteChange = (optionId: AutocompleteOptions) => { @@ -120,6 +129,7 @@ export function DevToolsSettingsModal(props: Props) { fields, indices, templates, + dataStreams, }, polling, pollInterval, @@ -170,6 +180,7 @@ export function DevToolsSettingsModal(props: Props) { fields, indices, templates, + dataStreams, }); }} > diff --git a/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js new file mode 100644 index 000000000000..015136b7670f --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { getDataStreams } from '../../mappings/mappings'; +import { ListComponent } from './list_component'; + +export class DataStreamAutocompleteComponent extends ListComponent { + constructor(name, parent, multiValued) { + super(name, getDataStreams, parent, multiValued); + } + + getContextKey() { + return 'data_stream'; + } +} diff --git a/src/plugins/console/public/lib/autocomplete/components/index.js b/src/plugins/console/public/lib/autocomplete/components/index.js index 32078ee2c151..4a8838a6fb82 100644 --- a/src/plugins/console/public/lib/autocomplete/components/index.js +++ b/src/plugins/console/public/lib/autocomplete/components/index.js @@ -23,4 +23,5 @@ export { IdAutocompleteComponent } from './id_autocomplete_component'; export { UsernameAutocompleteComponent } from './username_autocomplete_component'; export { IndexTemplateAutocompleteComponent } from './index_template_autocomplete_component'; export { ComponentTemplateAutocompleteComponent } from './component_template_autocomplete_component'; +export { DataStreamAutocompleteComponent } from './data_stream_autocomplete_component'; export * from './legacy'; diff --git a/src/plugins/console/public/lib/kb/kb.js b/src/plugins/console/public/lib/kb/kb.js index 5f02365a48fd..e268f55be558 100644 --- a/src/plugins/console/public/lib/kb/kb.js +++ b/src/plugins/console/public/lib/kb/kb.js @@ -16,6 +16,7 @@ import { UsernameAutocompleteComponent, IndexTemplateAutocompleteComponent, ComponentTemplateAutocompleteComponent, + DataStreamAutocompleteComponent, } from '../autocomplete/components'; import $ from 'jquery'; @@ -94,6 +95,9 @@ const parametrizedComponentFactories = { component_template: function (name, parent) { return new ComponentTemplateAutocompleteComponent(name, parent); }, + data_stream: function (name, parent) { + return new DataStreamAutocompleteComponent(name, parent); + }, }; export function getUnmatchedEndpointComponents() { diff --git a/src/plugins/console/public/lib/mappings/mapping.test.js b/src/plugins/console/public/lib/mappings/mapping.test.js index 9191eb736be3..e2def74e892c 100644 --- a/src/plugins/console/public/lib/mappings/mapping.test.js +++ b/src/plugins/console/public/lib/mappings/mapping.test.js @@ -266,4 +266,13 @@ describe('Mappings', () => { expect(mappings.getIndexTemplates()).toEqual(expectedResult); expect(mappings.getComponentTemplates()).toEqual(expectedResult); }); + + test('Data streams', function () { + mappings.loadDataStreams({ + data_streams: [{ name: 'test_index1' }, { name: 'test_index2' }, { name: 'test_index3' }], + }); + + const expectedResult = ['test_index1', 'test_index2', 'test_index3']; + expect(mappings.getDataStreams()).toEqual(expectedResult); + }); }); diff --git a/src/plugins/console/public/lib/mappings/mappings.js b/src/plugins/console/public/lib/mappings/mappings.js index 75b8a263e869..96a5665e730a 100644 --- a/src/plugins/console/public/lib/mappings/mappings.js +++ b/src/plugins/console/public/lib/mappings/mappings.js @@ -17,6 +17,7 @@ let perAliasIndexes = []; let legacyTemplates = []; let indexTemplates = []; let componentTemplates = []; +let dataStreams = []; const mappingObj = {}; @@ -60,6 +61,10 @@ export function getComponentTemplates() { return [...componentTemplates]; } +export function getDataStreams() { + return [...dataStreams]; +} + export function getFields(indices, types) { // get fields for indices and types. Both can be a list, a string or null (meaning all). let ret = []; @@ -128,7 +133,9 @@ export function getTypes(indices) { export function getIndices(includeAliases) { const ret = []; $.each(perIndexTypes, function (index) { - ret.push(index); + if (!index.startsWith('.ds')) { + ret.push(index); + } }); if (typeof includeAliases === 'undefined' ? true : includeAliases) { $.each(perAliasIndexes, function (alias) { @@ -204,6 +211,10 @@ export function loadComponentTemplates(data) { componentTemplates = (data.component_templates ?? []).map(({ name }) => name); } +export function loadDataStreams(data) { + dataStreams = (data.data_streams ?? []).map(({ name }) => name); +} + export function loadMappings(mappings) { perIndexTypes = {}; @@ -265,6 +276,7 @@ function retrieveSettings(settingsKey, settingsToRetrieve) { legacyTemplates: '_template', indexTemplates: '_index_template', componentTemplates: '_component_template', + dataStreams: '_data_stream', }; // Fetch autocomplete info if setting is set to true, and if user has made changes. @@ -326,14 +338,16 @@ export function retrieveAutoCompleteInfo(settings, settingsToRetrieve) { 'componentTemplates', templatesSettingToRetrieve ); + const dataStreamsPromise = retrieveSettings('dataStreams', settingsToRetrieve); $.when( mappingPromise, aliasesPromise, legacyTemplatesPromise, indexTemplatesPromise, - componentTemplatesPromise - ).done((mappings, aliases, legacyTemplates, indexTemplates, componentTemplates) => { + componentTemplatesPromise, + dataStreamsPromise + ).done((mappings, aliases, legacyTemplates, indexTemplates, componentTemplates, dataStreams) => { let mappingsResponse; try { if (mappings && mappings.length) { @@ -365,6 +379,10 @@ export function retrieveAutoCompleteInfo(settings, settingsToRetrieve) { loadComponentTemplates(JSON.parse(componentTemplates[0])); } + if (dataStreams) { + loadDataStreams(JSON.parse(dataStreams[0])); + } + if (mappings && aliases) { // Trigger an update event with the mappings, aliases $(mappingObj).trigger('update', [mappingsResponse, aliases[0]]); diff --git a/src/plugins/console/public/services/settings.ts b/src/plugins/console/public/services/settings.ts index 058f6c20c188..1a7eff3e7ca5 100644 --- a/src/plugins/console/public/services/settings.ts +++ b/src/plugins/console/public/services/settings.ts @@ -14,7 +14,7 @@ export const DEFAULT_SETTINGS = Object.freeze({ pollInterval: 60000, tripleQuotes: true, wrapMode: true, - autocomplete: Object.freeze({ fields: true, indices: true, templates: true }), + autocomplete: Object.freeze({ fields: true, indices: true, templates: true, dataStreams: true }), historyDisabled: false, }); @@ -25,6 +25,7 @@ export interface DevToolsSettings { fields: boolean; indices: boolean; templates: boolean; + dataStreams: boolean; }; polling: boolean; pollInterval: number; diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_data_stream.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_data_stream.json index 9b91e3deb3a0..fb5cb446fb77 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_data_stream.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_data_stream.json @@ -13,7 +13,7 @@ "DELETE" ], "patterns": [ - "_data_stream/{name}" + "_data_stream/{data_stream}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/data-streams.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_data_stream.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_data_stream.json index 45199a60f337..e383a1df4844 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_data_stream.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_data_stream.json @@ -14,7 +14,8 @@ ], "patterns": [ "_data_stream", - "_data_stream/{name}" + "_data_stream/{name}", + "{data_stream}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/data-streams.html" } diff --git a/src/plugins/controls/common/control_group/control_group_migrations.ts b/src/plugins/controls/common/control_group/control_group_migrations.ts new file mode 100644 index 000000000000..0060bda8b8cc --- /dev/null +++ b/src/plugins/controls/common/control_group/control_group_migrations.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 { ControlGroupInput, ControlsPanels } from '..'; + +export const makeControlOrdersZeroBased = (input: ControlGroupInput) => { + if ( + input.panels && + typeof input.panels === 'object' && + Object.keys(input.panels).length > 0 && + !Object.values(input.panels).find((panel) => (panel.order ?? 0) === 0) + ) { + // 0th element could not be found. Reorder all panels from 0; + const newPanels = Object.values(input.panels) + .sort((a, b) => (a.order > b.order ? 1 : -1)) + .map((panel, index) => { + panel.order = index; + return panel; + }) + .reduce((acc, currentPanel) => { + acc[currentPanel.explicitInput.id] = currentPanel; + return acc; + }, {} as ControlsPanels); + input.panels = newPanels; + } + return input; +}; diff --git a/src/plugins/controls/common/control_group/control_group_persistable_state.ts b/src/plugins/controls/common/control_group/control_group_persistable_state.ts index 0fd24bd23432..73f569bb7d24 100644 --- a/src/plugins/controls/common/control_group/control_group_persistable_state.ts +++ b/src/plugins/controls/common/control_group/control_group_persistable_state.ts @@ -13,6 +13,8 @@ import { } from '../../../embeddable/common/types'; import { ControlGroupInput, ControlPanelState } from './types'; import { SavedObjectReference } from '../../../../core/types'; +import { MigrateFunctionsObject } from '../../../kibana_utils/common'; +import { makeControlOrdersZeroBased } from './control_group_migrations'; type ControlGroupInputWithType = Partial & { type: string }; @@ -83,3 +85,11 @@ export const createControlGroupExtract = ( return { state: workingState as EmbeddableStateWithType, references }; }; }; + +export const migrations: MigrateFunctionsObject = { + '8.2.0': (state) => { + const controlInput = state as unknown as ControlGroupInput; + // for hierarchical chaining it is required that all control orders start at 0. + return makeControlOrdersZeroBased(controlInput); + }, +}; 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 93dd6bf81e29..f7b87ce7be9f 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 @@ -8,14 +8,7 @@ import '../control_group.scss'; -import { - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiToolTip, - EuiPanel, - EuiText, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import React, { useMemo, useState } from 'react'; import classNames from 'classnames'; import { @@ -35,24 +28,13 @@ import { useSensors, LayoutMeasuringStrategy, } from '@dnd-kit/core'; - import { ControlGroupInput } from '../types'; -import { pluginServices } from '../../services'; import { ViewMode } from '../../../../embeddable/public'; -import { ControlGroupStrings } from '../control_group_strings'; -import { CreateControlButton } from '../editor/create_control'; -import { EditControlGroup } from '../editor/edit_control_group'; -import { forwardAllContext } from '../editor/forward_all_context'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlClone, SortableControl } from './control_group_sortable_item'; import { useReduxContainerContext } from '../../../../presentation_util/public'; -import { ControlsIllustration } from './controls_illustration'; export const ControlGroup = () => { - // Controls Services Context - const { overlays } = pluginServices.getHooks(); - const { openFlyout } = overlays.useService(); - // Redux embeddable container Context const reduxContainerContext = useReduxContainerContext< ControlGroupInput, @@ -114,115 +96,69 @@ export const ControlGroup = () => { if (draggingId) panelBg = 'success'; return ( - + <> {idsInOrder.length > 0 ? ( - - - setDraggingId(active.id)} - onDragEnd={onDragEnd} - onDragCancel={() => setDraggingId(null)} - sensors={sensors} - collisionDetection={closestCenter} - layoutMeasuring={{ - strategy: LayoutMeasuringStrategy.Always, - }} - > - - - {idsInOrder.map( - (controlId, index) => - panels[controlId] && ( - - ) - )} - - - - {draggingId ? : null} - - - - {isEditable && ( - - - - - { - const flyoutInstance = openFlyout( - forwardAllContext( - flyoutInstance.close()} />, - reduxContainerContext - ) - ); - }} - /> - - - - - - - - - - )} - - ) : ( - <> - - - - - - - - {' '} - -

{ControlGroupStrings.emptyState.getCallToAction()}

-
-
-
-
- -
- -
+ + + setDraggingId(active.id)} + onDragEnd={onDragEnd} + onDragCancel={() => setDraggingId(null)} + sensors={sensors} + collisionDetection={closestCenter} + layoutMeasuring={{ + strategy: LayoutMeasuringStrategy.Always, + }} + > + + + {idsInOrder.map( + (controlId, index) => + panels[controlId] && ( + + ) + )} + + + + {draggingId ? : null} + + - +
+ ) : ( + <> )} - + ); }; diff --git a/src/plugins/controls/public/control_group/control_group_strings.ts b/src/plugins/controls/public/control_group/control_group_strings.ts index 91e857d083f7..73b81d8c5d45 100644 --- a/src/plugins/controls/public/control_group/control_group_strings.ts +++ b/src/plugins/controls/public/control_group/control_group_strings.ts @@ -13,6 +13,10 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.title', { defaultMessage: 'Control group', }), + getControlButtonTitle: () => + i18n.translate('controls.controlGroup.toolbarButtonTitle', { + defaultMessage: 'Controls', + }), emptyState: { getCallToAction: () => i18n.translate('controls.controlGroup.emptyState.callToAction', { @@ -26,6 +30,10 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.emptyState.twoLineLoadingTitle', { defaultMessage: '...', }), + getDismissButton: () => + i18n.translate('controls.controlGroup.emptyState.dismissButton', { + defaultMessage: 'Dismiss', + }), }, manageControl: { getFlyoutCreateTitle: () => @@ -60,11 +68,11 @@ export const ControlGroupStrings = { }), getManageButtonTitle: () => i18n.translate('controls.controlGroup.management.buttonTitle', { - defaultMessage: 'Configure controls', + defaultMessage: 'Settings', }), getFlyoutTitle: () => i18n.translate('controls.controlGroup.management.flyoutTitle', { - defaultMessage: 'Configure controls', + defaultMessage: 'Control settings', }), getDefaultWidthTitle: () => i18n.translate('controls.controlGroup.management.defaultWidthTitle', { 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 new file mode 100644 index 000000000000..6bb8b12d4f64 --- /dev/null +++ b/src/plugins/controls/public/control_group/editor/control_group_editor.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/* + * Copyright 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 { + EuiFlyoutHeader, + EuiButtonGroup, + EuiFlyoutBody, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiFlyoutFooter, + EuiButton, + EuiFormRow, + EuiButtonEmpty, + EuiSpacer, + EuiCheckbox, +} from '@elastic/eui'; + +import { ControlGroupStrings } from '../control_group_strings'; +import { ControlStyle, ControlWidth } from '../../types'; +import { CONTROL_LAYOUT_OPTIONS, CONTROL_WIDTH_OPTIONS } from './editor_constants'; + +interface EditControlGroupProps { + width: ControlWidth; + controlStyle: ControlStyle; + setAllWidths: boolean; + updateControlStyle: (controlStyle: ControlStyle) => void; + updateWidth: (newWidth: ControlWidth) => void; + updateAllControlWidths: (newWidth: ControlWidth) => void; + onCancel: () => void; + onClose: () => void; +} + +export const ControlGroupEditor = ({ + width, + controlStyle, + setAllWidths, + updateControlStyle, + updateWidth, + updateAllControlWidths, + onCancel, + onClose, +}: EditControlGroupProps) => { + const [currentControlStyle, setCurrentControlStyle] = useState(controlStyle); + const [currentWidth, setCurrentWidth] = useState(width); + const [applyToAll, setApplyToAll] = useState(setAllWidths); + + return ( + <> + + +

{ControlGroupStrings.management.getFlyoutTitle()}

+
+
+ + + { + setCurrentControlStyle(newControlStyle as ControlStyle); + }} + /> + + + + { + setCurrentWidth(newWidth as ControlWidth); + }} + /> + + + { + setApplyToAll(e.target.checked); + }} + /> + + + + {ControlGroupStrings.management.getDeleteAllButtonTitle()} + + + + + + { + onClose(); + }} + > + {ControlGroupStrings.manageControl.getCancelTitle()} + + + + { + if (currentControlStyle && currentControlStyle !== controlStyle) { + updateControlStyle(currentControlStyle); + } + if (currentWidth && currentWidth !== width) { + updateWidth(currentWidth); + } + if (applyToAll) { + updateAllControlWidths(currentWidth); + } + onClose(); + }} + > + {ControlGroupStrings.manageControl.getSaveChangesTitle()} + + + + + + ); +}; diff --git a/src/plugins/controls/public/control_group/editor/create_control.tsx b/src/plugins/controls/public/control_group/editor/create_control.tsx index b97ebb9aa519..57e74d7c1b5d 100644 --- a/src/plugins/controls/public/control_group/editor/create_control.tsx +++ b/src/plugins/controls/public/control_group/editor/create_control.tsx @@ -8,7 +8,6 @@ import { EuiButton, - EuiButtonIcon, EuiButtonIconColor, EuiContextMenuItem, EuiContextMenuPanel, @@ -16,42 +15,39 @@ import { } from '@elastic/eui'; import React, { useState, ReactElement } from 'react'; -import { ControlGroupInput } from '../types'; import { pluginServices } from '../../services'; import { ControlEditor } from './control_editor'; import { OverlayRef } from '../../../../../core/public'; -import { forwardAllContext } from './forward_all_context'; import { DEFAULT_CONTROL_WIDTH } from './editor_constants'; import { ControlGroupStrings } from '../control_group_strings'; -import { controlGroupReducers } from '../state/control_group_reducers'; import { EmbeddableFactoryNotFoundError } from '../../../../embeddable/public'; -import { useReduxContainerContext } from '../../../../presentation_util/public'; import { ControlWidth, IEditableControlFactory, ControlInput } from '../../types'; - -export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) => { +import { toMountPoint } from '../../../../kibana_react/public'; + +export type CreateControlButtonTypes = 'toolbar' | 'callout'; +export interface CreateControlButtonProps { + defaultControlWidth?: ControlWidth; + updateDefaultWidth: (defaultControlWidth: ControlWidth) => void; + addNewEmbeddable: (type: string, input: Omit) => void; + buttonType: CreateControlButtonTypes; + closePopover?: () => void; +} + +export const CreateControlButton = ({ + defaultControlWidth, + updateDefaultWidth, + addNewEmbeddable, + buttonType, + closePopover, +}: CreateControlButtonProps) => { // Controls Services Context - const { overlays, controls } = pluginServices.getHooks(); - const { getControlTypes, getControlFactory } = controls.useService(); - const { openFlyout, openConfirm } = overlays.useService(); - - // Redux embeddable container Context - const reduxContainerContext = useReduxContainerContext< - ControlGroupInput, - typeof controlGroupReducers - >(); - const { - containerActions: { addNewEmbeddable }, - actions: { setDefaultControlWidth }, - useEmbeddableSelector, - useEmbeddableDispatch, - } = reduxContainerContext; - const dispatch = useEmbeddableDispatch(); - - // current state - const { defaultControlWidth } = useEmbeddableSelector((state) => state); + const { overlays, controls } = pluginServices.getServices(); + const { getControlTypes, getControlFactory } = controls; + const { openFlyout, openConfirm } = overlays; const [isControlTypePopoverOpen, setIsControlTypePopoverOpen] = useState(false); const createNewControl = async (type: string) => { + const PresentationUtilProvider = pluginServices.getContextProvider(); const factory = getControlFactory(type); if (!factory) throw new EmbeddableFactoryNotFoundError(type); @@ -80,26 +76,27 @@ export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) const editableFactory = factory as IEditableControlFactory; const flyoutInstance = openFlyout( - forwardAllContext( - (inputToReturn.title = newTitle)} - updateWidth={(newWidth) => dispatch(setDefaultControlWidth(newWidth as ControlWidth))} - onTypeEditorChange={(partialInput) => - (inputToReturn = { ...inputToReturn, ...partialInput }) - } - onSave={() => { - if (editableFactory.presaveTransformFunction) { - inputToReturn = editableFactory.presaveTransformFunction(inputToReturn); + toMountPoint( + + (inputToReturn.title = newTitle)} + updateWidth={updateDefaultWidth} + onTypeEditorChange={(partialInput) => + (inputToReturn = { ...inputToReturn, ...partialInput }) } - resolve(inputToReturn); - flyoutInstance.close(); - }} - onCancel={() => onCancel(flyoutInstance)} - />, - reduxContainerContext + onSave={() => { + if (editableFactory.presaveTransformFunction) { + inputToReturn = editableFactory.presaveTransformFunction(inputToReturn); + } + resolve(inputToReturn); + flyoutInstance.close(); + }} + onCancel={() => onCancel(flyoutInstance)} + /> + ), { onClose: (flyout) => onCancel(flyout), @@ -117,48 +114,49 @@ export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) if (getControlTypes().length === 0) return null; - const onCreateButtonClick = () => { - if (getControlTypes().length > 1) { - setIsControlTypePopoverOpen(!isControlTypePopoverOpen); - return; - } - createNewControl(getControlTypes()[0]); - }; - const commonButtonProps = { - onClick: onCreateButtonClick, color: 'primary' as EuiButtonIconColor, 'data-test-subj': 'controls-create-button', 'aria-label': ControlGroupStrings.management.getManageButtonTitle(), }; - const createControlButton = isIconButton ? ( - - ) : ( - - {ControlGroupStrings.emptyState.getAddControlButtonTitle()} - - ); - - if (getControlTypes().length > 1) { - const items: ReactElement[] = []; - getControlTypes().forEach((type) => { - const factory = getControlFactory(type); - items.push( - { + const items: ReactElement[] = []; + getControlTypes().forEach((type) => { + const factory = getControlFactory(type); + items.push( + { + if (buttonType === 'callout' && isControlTypePopoverOpen) { setIsControlTypePopoverOpen(false); - createNewControl(type); - }} - > - {factory.getDisplayName()} - - ); - }); - + } else if (closePopover) { + closePopover(); + } + createNewControl(type); + }} + toolTipContent={factory.getDescription()} + > + {factory.getDisplayName()} + + ); + }); + + if (buttonType === 'callout') { + const onCreateButtonClick = () => { + if (getControlTypes().length > 1) { + setIsControlTypePopoverOpen(!isControlTypePopoverOpen); + return; + } + createNewControl(getControlTypes()[0]); + }; + + const createControlButton = ( + + {ControlGroupStrings.emptyState.getAddControlButtonTitle()} + + ); return ( setIsControlTypePopoverOpen(false)} > - + ); } - return createControlButton; + return ; }; diff --git a/src/plugins/controls/public/control_group/editor/edit_control_group.tsx b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx index 87a2a1407a76..57a5fb22b9d9 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control_group.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx @@ -6,165 +6,93 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; -import { - EuiTitle, - EuiSpacer, - EuiFormRow, - EuiFlexItem, - EuiFlexGroup, - EuiFlyoutBody, - EuiButtonGroup, - EuiButtonEmpty, - EuiFlyoutHeader, - EuiCheckbox, - EuiFlyoutFooter, - EuiButton, -} from '@elastic/eui'; +import React from 'react'; +import { EuiContextMenuItem } from '@elastic/eui'; -import { - CONTROL_LAYOUT_OPTIONS, - CONTROL_WIDTH_OPTIONS, - DEFAULT_CONTROL_WIDTH, -} from './editor_constants'; -import { ControlGroupInput } from '../types'; +import { DEFAULT_CONTROL_WIDTH, DEFAULT_CONTROL_STYLE } from './editor_constants'; +import { ControlsPanels } from '../types'; import { pluginServices } from '../../services'; import { ControlStyle, ControlWidth } from '../../types'; import { ControlGroupStrings } from '../control_group_strings'; -import { controlGroupReducers } from '../state/control_group_reducers'; -import { useReduxContainerContext } from '../../../../presentation_util/public'; +import { toMountPoint } from '../../../../kibana_react/public'; +import { OverlayRef } from '../../../../../core/public'; +import { ControlGroupEditor } from './control_group_editor'; -interface EditControlGroupState { - newControlStyle: ControlGroupInput['controlStyle']; - newDefaultWidth: ControlGroupInput['defaultControlWidth']; - setAllWidths: boolean; +export interface EditControlGroupButtonProps { + controlStyle: ControlStyle; + panels?: ControlsPanels; + defaultControlWidth?: ControlWidth; + setControlStyle: (setControlStyle: ControlStyle) => void; + setDefaultControlWidth: (defaultControlWidth: ControlWidth) => void; + setAllControlWidths: (defaultControlWidth: ControlWidth) => void; + removeEmbeddable?: (panelId: string) => void; + closePopover: () => void; } -export const EditControlGroup = ({ closeFlyout }: { closeFlyout: () => void }) => { - const { overlays } = pluginServices.getHooks(); - const { openConfirm } = overlays.useService(); +export const EditControlGroup = ({ + panels, + defaultControlWidth, + controlStyle, + setControlStyle, + setDefaultControlWidth, + setAllControlWidths, + removeEmbeddable, + closePopover, +}: EditControlGroupButtonProps) => { + const { overlays } = pluginServices.getServices(); + const { openConfirm, openFlyout } = overlays; - const { - containerActions, - useEmbeddableSelector, - useEmbeddableDispatch, - actions: { setControlStyle, setAllControlWidths, setDefaultControlWidth }, - } = useReduxContainerContext(); - const dispatch = useEmbeddableDispatch(); - const { panels, controlStyle, defaultControlWidth } = useEmbeddableSelector((state) => state); + const editControlGroup = () => { + const PresentationUtilProvider = pluginServices.getContextProvider(); - const [state, setState] = useState({ - newControlStyle: controlStyle, - newDefaultWidth: defaultControlWidth, - setAllWidths: false, - }); + const onCancel = (ref: OverlayRef) => { + if (!removeEmbeddable || !panels) return; + openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), + title: ControlGroupStrings.management.deleteControls.getDeleteAllTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) Object.keys(panels).forEach((panelId) => removeEmbeddable(panelId)); + ref.close(); + }); + }; - const onSave = () => { - const { newControlStyle, newDefaultWidth, setAllWidths } = state; - if (newControlStyle && newControlStyle !== controlStyle) { - dispatch(setControlStyle(newControlStyle)); - } - if (newDefaultWidth && newDefaultWidth !== defaultControlWidth) { - dispatch(setDefaultControlWidth(newDefaultWidth)); - } - if (setAllWidths && newDefaultWidth) { - dispatch(setAllControlWidths(newDefaultWidth)); - } - closeFlyout(); + const flyoutInstance = openFlyout( + toMountPoint( + + onCancel(flyoutInstance)} + onClose={() => flyoutInstance.close()} + /> + + ), + { + onClose: () => flyoutInstance.close(), + } + ); }; - return ( - <> - - -

{ControlGroupStrings.management.getFlyoutTitle()}

-
-
- - - - setState((s) => ({ ...s, newControlStyle: newControlStyle as ControlStyle })) - } - /> - - - - - setState((s) => ({ ...s, newDefaultWidth: newDefaultWidth as ControlWidth })) - } - /> - - - setState((s) => ({ ...s, setAllWidths: e.target.checked }))} - /> - + const commonButtonProps = { + key: 'manageControls', + onClick: () => { + editControlGroup(); + closePopover(); + }, + icon: 'gear', + 'data-test-subj': 'controls-sorting-button', + 'aria-label': ControlGroupStrings.management.getManageButtonTitle(), + }; - { - if (!containerActions?.removeEmbeddable) return; - closeFlyout(); - openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), - cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), - title: ControlGroupStrings.management.deleteControls.getDeleteAllTitle(), - buttonColor: 'danger', - }).then((confirmed) => { - if (confirmed) - Object.keys(panels).forEach((panelId) => - containerActions.removeEmbeddable(panelId) - ); - }); - }} - aria-label={'delete-all'} - iconType="trash" - color="danger" - flush="left" - size="s" - > - {ControlGroupStrings.management.getDeleteAllButtonTitle()} - - - - - - { - closeFlyout(); - }} - > - {ControlGroupStrings.manageControl.getCancelTitle()} - - - - { - onSave(); - }} - > - {ControlGroupStrings.manageControl.getSaveChangesTitle()} - - - - - + return ( + + {ControlGroupStrings.management.getManageButtonTitle()} + ); }; diff --git a/src/plugins/controls/public/control_group/editor/editor_constants.ts b/src/plugins/controls/public/control_group/editor/editor_constants.ts index 814e2a08cd93..f68110a7f2b9 100644 --- a/src/plugins/controls/public/control_group/editor/editor_constants.ts +++ b/src/plugins/controls/public/control_group/editor/editor_constants.ts @@ -6,10 +6,11 @@ * Side Public License, v 1. */ -import { ControlWidth } from '../../types'; +import { ControlStyle, ControlWidth } from '../../types'; import { ControlGroupStrings } from '../control_group_strings'; export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'auto'; +export const DEFAULT_CONTROL_STYLE: ControlStyle = 'oneLine'; export const CONTROL_WIDTH_OPTIONS = [ { diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index b420e026ac1d..734756b31aa2 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -11,56 +11,142 @@ import { uniqBy } from 'lodash'; import ReactDOM from 'react-dom'; import deepEqual from 'fast-deep-equal'; import { Filter, uniqFilters } from '@kbn/es-query'; -import { EMPTY, merge, pipe, Subscription } from 'rxjs'; -import { distinctUntilChanged, debounceTime, catchError, switchMap, map } from 'rxjs/operators'; +import { EMPTY, merge, pipe, Subject, Subscription } from 'rxjs'; +import { EuiContextMenuPanel, EuiHorizontalRule } from '@elastic/eui'; +import { + distinctUntilChanged, + debounceTime, + catchError, + switchMap, + map, + skip, + mapTo, +} from 'rxjs/operators'; import { ControlGroupInput, ControlGroupOutput, ControlPanelState, + ControlsPanels, CONTROL_GROUP_TYPE, } from '../types'; import { withSuspense, LazyReduxEmbeddableWrapper, ReduxEmbeddableWrapperPropsWithChildren, + SolutionToolbarPopover, } from '../../../../presentation_util/public'; import { pluginServices } from '../../services'; import { DataView } from '../../../../data_views/public'; +import { ControlGroupStrings } from '../control_group_strings'; +import { EditControlGroup } from '../editor/edit_control_group'; import { DEFAULT_CONTROL_WIDTH } from '../editor/editor_constants'; import { ControlGroup } from '../component/control_group_component'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types'; -import { Container, EmbeddableFactory } from '../../../../embeddable/public'; +import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control'; +import { Container, EmbeddableFactory, isErrorEmbeddable } from '../../../../embeddable/public'; const ControlGroupReduxWrapper = withSuspense< ReduxEmbeddableWrapperPropsWithChildren >(LazyReduxEmbeddableWrapper); +interface ChildEmbeddableOrderCache { + IdsToOrder: { [key: string]: number }; + idsInOrder: string[]; + lastChildId: string; +} + +const controlOrdersAreEqual = (panelsA: ControlsPanels, panelsB: ControlsPanels) => { + const ordersA = Object.values(panelsA).map((panel) => ({ + id: panel.explicitInput.id, + order: panel.order, + })); + const ordersB = Object.values(panelsB).map((panel) => ({ + id: panel.explicitInput.id, + order: panel.order, + })); + return deepEqual(ordersA, ordersB); +}; + export class ControlGroupContainer extends Container< ControlInput, ControlGroupInput, ControlGroupOutput > { public readonly type = CONTROL_GROUP_TYPE; + private subscriptions: Subscription = new Subscription(); private domNode?: HTMLElement; + private childOrderCache: ChildEmbeddableOrderCache; + private recalculateFilters$: Subject; - public untilReady = () => { - const panelsLoading = () => - Object.values(this.getOutput().embeddableLoaded).some((loaded) => !loaded); - if (panelsLoading()) { - return new Promise((resolve, reject) => { - const subscription = merge(this.getOutput$(), this.getInput$()).subscribe(() => { - if (this.destroyed) reject(); - if (!panelsLoading()) { - subscription.unsubscribe(); - resolve(); - } - }); - }); - } - return Promise.resolve(); + /** + * Returns a button that allows controls to be created externally using the embeddable + * @param buttonType Controls the button styling + * @param closePopover Closes the create control menu popover when flyout opens - only necessary if `buttonType === 'toolbar'` + * @return If `buttonType == 'toolbar'`, returns `EuiContextMenuPanel` with input control types as items. + * Otherwise, if `buttonType == 'callout'` returns `EuiButton` with popover containing input control types. + */ + public getCreateControlButton = ( + buttonType: CreateControlButtonTypes, + closePopover?: () => void + ) => { + return ( + this.updateInput({ defaultControlWidth })} + addNewEmbeddable={(type, input) => this.addNewEmbeddable(type, input)} + closePopover={closePopover} + /> + ); + }; + + private getEditControlGroupButton = (closePopover: () => void) => { + return ( + this.updateInput({ controlStyle })} + setDefaultControlWidth={(defaultControlWidth) => this.updateInput({ defaultControlWidth })} + setAllControlWidths={(defaultControlWidth) => { + Object.keys(this.getInput().panels).forEach( + (panelId) => (this.getInput().panels[panelId].width = defaultControlWidth) + ); + }} + removeEmbeddable={(id) => this.removeEmbeddable(id)} + closePopover={closePopover} + /> + ); + }; + + /** + * Returns the toolbar button that is used for creating controls and managing control settings + * @return `SolutionToolbarPopover` button for input controls + */ + public getToolbarButtons = () => { + return ( + + {({ closePopover }: { closePopover: () => void }) => ( + , + this.getEditControlGroupButton(closePopover), + ]} + /> + )} + + ); }; constructor(initialInput: ControlGroupInput, parent?: Container) { @@ -68,47 +154,150 @@ export class ControlGroupContainer extends Container< initialInput, { embeddableLoaded: {} }, pluginServices.getServices().controls.getControlFactory, - parent + parent, + { + childIdInitializeOrder: Object.values(initialInput.panels) + .sort((a, b) => (a.order > b.order ? 1 : -1)) + .map((panel) => panel.explicitInput.id), + initializeSequentially: true, + } ); - // when all children are ready start recalculating filters when any child's output changes + this.recalculateFilters$ = new Subject(); + + // set up order cache so that it is aligned on input changes. + this.childOrderCache = this.getEmbeddableOrderCache(); + + // when all children are ready setup subscriptions this.untilReady().then(() => { - this.recalculateOutput(); - - const anyChildChangePipe = pipe( - map(() => this.getChildIds()), - distinctUntilChanged(deepEqual), - - // children may change, so make sure we subscribe/unsubscribe with switchMap - switchMap((newChildIds: string[]) => - merge( - ...newChildIds.map((childId) => - this.getChild(childId) - .getOutput$() + this.recalculateDataViews(); + this.recalculateFilters(); + this.setupSubscriptions(); + }); + } + + private setupSubscriptions = () => { + /** + * refresh control order cache and make all panels refreshInputFromParent whenever panel orders change + */ + this.subscriptions.add( + this.getInput$() + .pipe( + skip(1), + distinctUntilChanged((a, b) => controlOrdersAreEqual(a.panels, b.panels)) + ) + .subscribe(() => { + this.recalculateDataViews(); + this.recalculateFilters(); + this.childOrderCache = this.getEmbeddableOrderCache(); + this.childOrderCache.idsInOrder.forEach((id) => + this.getChild(id)?.refreshInputFromParent() + ); + }) + ); + + /** + * Create a pipe that outputs the child's ID, any time any child's output changes. + */ + const anyChildChangePipe = pipe( + map(() => this.getChildIds()), + distinctUntilChanged(deepEqual), + + // children may change, so make sure we subscribe/unsubscribe with switchMap + switchMap((newChildIds: string[]) => + merge( + ...newChildIds.map((childId) => + this.getChild(childId) + .getOutput$() + .pipe( // Embeddables often throw errors into their output streams. - .pipe(catchError(() => EMPTY)) - ) + catchError(() => EMPTY), + mapTo(childId) + ) ) ) - ); + ) + ); - this.subscriptions.add( - merge(this.getOutput$(), this.getOutput$().pipe(anyChildChangePipe)) - .pipe(debounceTime(10)) - .subscribe(this.recalculateOutput) - ); - }); - } + /** + * run OnChildOutputChanged when any child's output has changed + */ + this.subscriptions.add( + this.getOutput$() + .pipe(anyChildChangePipe) + .subscribe((childOutputChangedId) => { + this.recalculateDataViews(); + if (childOutputChangedId === this.childOrderCache.lastChildId) { + // the last control's output has updated, recalculate filters + this.recalculateFilters$.next(); + return; + } + + // when output changes on a child which isn't the last - make the next embeddable updateInputFromParent + const nextOrder = this.childOrderCache.IdsToOrder[childOutputChangedId] + 1; + if (nextOrder >= Object.keys(this.children).length) return; + setTimeout( + () => + this.getChild(this.childOrderCache.idsInOrder[nextOrder]).refreshInputFromParent(), + 1 // run on next tick + ); + }) + ); + + /** + * debounce output recalculation + */ + this.subscriptions.add( + this.recalculateFilters$.pipe(debounceTime(10)).subscribe(() => this.recalculateFilters()) + ); + }; + + private getPrecedingFilters = (id: string) => { + let filters: Filter[] = []; + const order = this.childOrderCache.IdsToOrder?.[id]; + if (!order || order === 0) return filters; + for (let i = 0; i < order; i++) { + const embeddable = this.getChild(this.childOrderCache.idsInOrder[i]); + if (!embeddable || isErrorEmbeddable(embeddable)) return filters; + filters = [...filters, ...(embeddable.getOutput().filters ?? [])]; + } + return filters; + }; + + private getEmbeddableOrderCache = (): ChildEmbeddableOrderCache => { + const panels = this.getInput().panels; + const IdsToOrder: { [key: string]: number } = {}; + const idsInOrder: string[] = []; + Object.values(panels) + .sort((a, b) => (a.order > b.order ? 1 : -1)) + .forEach((panel) => { + IdsToOrder[panel.explicitInput.id] = panel.order; + idsInOrder.push(panel.explicitInput.id); + }); + const lastChildId = idsInOrder[idsInOrder.length - 1]; + return { IdsToOrder, idsInOrder, lastChildId }; + }; + + public getPanelCount = () => { + return Object.keys(this.getInput().panels).length; + }; - private recalculateOutput = () => { + private recalculateFilters = () => { const allFilters: Filter[] = []; - const allDataViews: DataView[] = []; Object.values(this.children).map((child) => { const childOutput = child.getOutput() as ControlOutput; allFilters.push(...(childOutput?.filters ?? [])); + }); + this.updateOutput({ filters: uniqFilters(allFilters) }); + }; + + private recalculateDataViews = () => { + const allDataViews: DataView[] = []; + Object.values(this.children).map((child) => { + const childOutput = child.getOutput() as ControlOutput; allDataViews.push(...(childOutput.dataViews ?? [])); }); - this.updateOutput({ filters: uniqFilters(allFilters), dataViews: uniqBy(allDataViews, 'id') }); + this.updateOutput({ dataViews: uniqBy(allDataViews, 'id') }); }; protected createNewPanelState( @@ -116,12 +305,16 @@ export class ControlGroupContainer extends Container< partial: Partial = {} ): ControlPanelState { const panelState = super.createNewPanelState(factory, partial); - const highestOrder = Object.values(this.getInput().panels).reduce((highestSoFar, panel) => { - if (panel.order > highestSoFar) highestSoFar = panel.order; - return highestSoFar; - }, 0); + let nextOrder = 0; + if (Object.keys(this.getInput().panels).length > 0) { + nextOrder = + Object.values(this.getInput().panels).reduce((highestSoFar, panel) => { + if (panel.order > highestSoFar) highestSoFar = panel.order; + return highestSoFar; + }, 0) + 1; + } return { - order: highestOrder + 1, + order: nextOrder, width: this.getInput().defaultControlWidth ?? DEFAULT_CONTROL_WIDTH, ...panelState, } as ControlPanelState; @@ -129,19 +322,38 @@ export class ControlGroupContainer extends Container< protected getInheritedInput(id: string): ControlInput { const { filters, query, ignoreParentSettings, timeRange } = this.getInput(); + + const precedingFilters = this.getPrecedingFilters(id); + const allFilters = [ + ...(ignoreParentSettings?.ignoreFilters ? [] : filters ?? []), + ...precedingFilters, + ]; return { - filters: ignoreParentSettings?.ignoreFilters ? undefined : filters, + filters: allFilters, query: ignoreParentSettings?.ignoreQuery ? undefined : query, timeRange: ignoreParentSettings?.ignoreTimerange ? undefined : timeRange, id, }; } - public destroy() { - super.destroy(); - this.subscriptions.unsubscribe(); - if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode); - } + public untilReady = () => { + const panelsLoading = () => + Object.keys(this.getInput().panels).some( + (panelId) => !this.getOutput().embeddableLoaded[panelId] + ); + if (panelsLoading()) { + return new Promise((resolve, reject) => { + const subscription = merge(this.getOutput$(), this.getInput$()).subscribe(() => { + if (this.destroyed) reject(); + if (!panelsLoading()) { + subscription.unsubscribe(); + resolve(); + } + }); + }); + } + return Promise.resolve(); + }; public render(dom: HTMLElement) { if (this.domNode) { @@ -158,4 +370,10 @@ export class ControlGroupContainer extends Container< dom ); } + + public destroy() { + super.destroy(); + this.subscriptions.unsubscribe(); + if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode); + } } diff --git a/src/plugins/controls/public/control_group/state/control_group_reducers.ts b/src/plugins/controls/public/control_group/state/control_group_reducers.ts index b7c0c62535d4..5ec4463c3bc1 100644 --- a/src/plugins/controls/public/control_group/state/control_group_reducers.ts +++ b/src/plugins/controls/public/control_group/state/control_group_reducers.ts @@ -25,12 +25,6 @@ export const controlGroupReducers = { ) => { state.defaultControlWidth = action.payload; }, - setAllControlWidths: ( - state: WritableDraft, - action: PayloadAction - ) => { - Object.keys(state.panels).forEach((panelId) => (state.panels[panelId].width = action.payload)); - }, setControlWidth: ( state: WritableDraft, action: PayloadAction<{ width: ControlWidth; embeddableId: string }> 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 971fe98b5266..2575d5724535 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 @@ -138,45 +138,36 @@ export class OptionsListEmbeddable extends Embeddable isEqual(a.validSelections, b.validSelections)), - skip(1) // skip the first input update because initial filters will be built by initialize. - ) - .subscribe(() => this.buildFilter()) - ); - /** - * when input selectedOptions changes, check all selectedOptions against the latest value of invalidSelections. + * when input selectedOptions changes, check all selectedOptions against the latest value of invalidSelections, and publish filter **/ this.subscriptions.add( this.getInput$() .pipe(distinctUntilChanged((a, b) => isEqual(a.selectedOptions, b.selectedOptions))) - .subscribe(({ selectedOptions: newSelectedOptions }) => { + .subscribe(async ({ selectedOptions: newSelectedOptions }) => { if (!newSelectedOptions || isEmpty(newSelectedOptions)) { this.updateComponentState({ validSelections: [], invalidSelections: [], }); - return; - } - const { invalidSelections } = this.componentStateSubject$.getValue(); - const newValidSelections: string[] = []; - const newInvalidSelections: string[] = []; - for (const selectedOption of newSelectedOptions) { - if (invalidSelections?.includes(selectedOption)) { - newInvalidSelections.push(selectedOption); - continue; + } else { + const { invalidSelections } = this.componentStateSubject$.getValue(); + const newValidSelections: string[] = []; + const newInvalidSelections: string[] = []; + for (const selectedOption of newSelectedOptions) { + if (invalidSelections?.includes(selectedOption)) { + newInvalidSelections.push(selectedOption); + continue; + } + newValidSelections.push(selectedOption); } - newValidSelections.push(selectedOption); + this.updateComponentState({ + validSelections: newValidSelections, + invalidSelections: newInvalidSelections, + }); } - this.updateComponentState({ - validSelections: newValidSelections, - invalidSelections: newInvalidSelections, - }); + const newFilters = await this.buildFilter(); + this.updateOutput({ filters: newFilters }); }) ); }; @@ -216,8 +207,9 @@ export class OptionsListEmbeddable extends Embeddable { - this.updateComponentState({ loading: true }); const { dataView, field } = await this.getCurrentDataViewAndField(); + this.updateComponentState({ loading: true }); + this.updateOutput({ loading: true, dataViews: [dataView] }); const { ignoreParentSettings, filters, query, selectedOptions, timeRange } = this.getInput(); if (this.abortController) this.abortController.abort(); @@ -244,30 +236,32 @@ export class OptionsListEmbeddable extends Embeddable { const { validSelections } = this.componentState; if (!validSelections || isEmpty(validSelections)) { - this.updateOutput({ filters: [] }); - return; + return []; } const { dataView, field } = await this.getCurrentDataViewAndField(); @@ -279,7 +273,7 @@ export class OptionsListEmbeddable extends Embeddable { diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable_factory.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable_factory.tsx index 98d616faadc5..8c6b533fa06e 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable_factory.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable_factory.tsx @@ -16,6 +16,7 @@ import { createOptionsListExtract, createOptionsListInject, } from '../../../common/control_types/options_list/options_list_persistable_state'; +import { OptionsListStrings } from './options_list_strings'; export class OptionsListEmbeddableFactory implements EmbeddableFactoryDefinition, IEditableControlFactory @@ -49,7 +50,9 @@ export class OptionsListEmbeddableFactory public isEditable = () => Promise.resolve(false); - public getDisplayName = () => 'Options List Control'; + public getDisplayName = () => OptionsListStrings.getDisplayName(); + public getIconType = () => 'list'; + public getDescription = () => OptionsListStrings.getDescription(); public inject = createOptionsListInject(); public extract = createOptionsListExtract(); diff --git a/src/plugins/controls/public/control_types/options_list/options_list_strings.ts b/src/plugins/controls/public/control_types/options_list/options_list_strings.ts index 537697804dd5..c7074eb478a5 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_strings.ts +++ b/src/plugins/controls/public/control_types/options_list/options_list_strings.ts @@ -9,6 +9,14 @@ import { i18n } from '@kbn/i18n'; export const OptionsListStrings = { + getDisplayName: () => + i18n.translate('controls.optionsList.displayName', { + defaultMessage: 'Options list', + }), + getDescription: () => + i18n.translate('controls.optionsList.description', { + defaultMessage: 'Add control that allows options to be selected from a dropdown.', + }), summary: { getSeparator: () => i18n.translate('controls.optionsList.summary.separator', { diff --git a/src/plugins/controls/public/controls_callout/controls_callout.tsx b/src/plugins/controls/public/controls_callout/controls_callout.tsx new file mode 100644 index 000000000000..096d47b470a9 --- /dev/null +++ b/src/plugins/controls/public/controls_callout/controls_callout.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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiButtonEmpty, EuiPanel } from '@elastic/eui'; +import React from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import classNames from 'classnames'; + +import { ControlGroupStrings } from '../control_group/control_group_strings'; +import { ControlsIllustration } from './controls_illustration'; + +const CONTROLS_CALLOUT_STATE_KEY = 'dashboard:controlsCalloutDismissed'; + +export interface CalloutProps { + getCreateControlButton?: () => JSX.Element; +} + +export const ControlsCallout = ({ getCreateControlButton }: CalloutProps) => { + const [controlsCalloutDismissed, setControlsCalloutDismissed] = useLocalStorage( + CONTROLS_CALLOUT_STATE_KEY, + false + ); + const dismissControls = () => { + setControlsCalloutDismissed(true); + }; + + if (controlsCalloutDismissed) return null; + + return ( + + + + + + + + + +

{ControlGroupStrings.emptyState.getCallToAction()}

+
+
+ {getCreateControlButton ? ( + {getCreateControlButton()} + ) : null} + + + {ControlGroupStrings.emptyState.getDismissButton()} + + +
+
+
+
+ ); +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default ControlsCallout; diff --git a/src/plugins/controls/public/control_group/component/controls_illustration.scss b/src/plugins/controls/public/controls_callout/controls_illustration.scss similarity index 100% rename from src/plugins/controls/public/control_group/component/controls_illustration.scss rename to src/plugins/controls/public/controls_callout/controls_illustration.scss diff --git a/src/plugins/controls/public/control_group/component/controls_illustration.tsx b/src/plugins/controls/public/controls_callout/controls_illustration.tsx similarity index 100% rename from src/plugins/controls/public/control_group/component/controls_illustration.tsx rename to src/plugins/controls/public/controls_callout/controls_illustration.tsx diff --git a/src/plugins/controls/public/controls_callout/index.ts b/src/plugins/controls/public/controls_callout/index.ts new file mode 100644 index 000000000000..5f6c7e553152 --- /dev/null +++ b/src/plugins/controls/public/controls_callout/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 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'; +export const LazyControlsCallout = React.lazy(() => import('./controls_callout')); +export type { CalloutProps } from './controls_callout'; diff --git a/src/plugins/controls/public/index.ts b/src/plugins/controls/public/index.ts index c8118fcdb1d7..7caf9d19e49b 100644 --- a/src/plugins/controls/public/index.ts +++ b/src/plugins/controls/public/index.ts @@ -39,6 +39,8 @@ export { type OptionsListEmbeddableInput, } from './control_types'; +export { LazyControlsCallout, type CalloutProps } from './controls_callout'; + export function plugin() { return new ControlsPlugin(); } diff --git a/src/plugins/controls/server/control_group/control_group_container_factory.ts b/src/plugins/controls/server/control_group/control_group_container_factory.ts index 39e1a9fbb12c..179b7ebd5598 100644 --- a/src/plugins/controls/server/control_group/control_group_container_factory.ts +++ b/src/plugins/controls/server/control_group/control_group_container_factory.ts @@ -12,6 +12,7 @@ import { CONTROL_GROUP_TYPE } from '../../common'; import { createControlGroupExtract, createControlGroupInject, + migrations, } from '../../common/control_group/control_group_persistable_state'; export const controlGroupContainerPersistableStateServiceFactory = ( @@ -21,5 +22,6 @@ export const controlGroupContainerPersistableStateServiceFactory = ( id: CONTROL_GROUP_TYPE, extract: createControlGroupExtract(persistableStateService), inject: createControlGroupInject(persistableStateService), + migrations, }; }; diff --git a/src/plugins/custom_integrations/common/index.ts b/src/plugins/custom_integrations/common/index.ts index 7881a4a0ca88..46cf778bf013 100755 --- a/src/plugins/custom_integrations/common/index.ts +++ b/src/plugins/custom_integrations/common/index.ts @@ -27,6 +27,7 @@ export const INTEGRATION_CATEGORY_DISPLAY = { kubernetes: 'Kubernetes', languages: 'Languages', message_queue: 'Message queue', + microsoft_365: 'Microsoft 365', monitoring: 'Monitoring', network: 'Network', notification: 'Notification', @@ -41,6 +42,7 @@ export const INTEGRATION_CATEGORY_DISPLAY = { // Kibana added communications: 'Communications', + enterprise_search: 'Enterprise search', file_storage: 'File storage', language_client: 'Language client', upload_file: 'Upload a file', diff --git a/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts b/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts new file mode 100644 index 000000000000..95cb6c38ee9d --- /dev/null +++ b/src/plugins/dashboard/common/embeddable/dashboard_control_group.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 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 { SerializableRecord } from '@kbn/utility-types'; +import { ControlGroupInput } from '../../../controls/common'; +import { ControlStyle } from '../../../controls/common/types'; +import { RawControlGroupAttributes } from '../types'; + +export const controlGroupInputToRawAttributes = ( + controlGroupInput: Omit +): Omit => { + return { + controlStyle: controlGroupInput.controlStyle, + panelsJSON: JSON.stringify(controlGroupInput.panels), + }; +}; + +export const getDefaultDashboardControlGroupInput = () => ({ + controlStyle: 'oneLine' as ControlGroupInput['controlStyle'], + panels: {}, +}); + +export const rawAttributesToControlGroupInput = ( + rawControlGroupAttributes: Omit +): Omit | undefined => { + const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); + return { + controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle, + panels: + rawControlGroupAttributes?.panelsJSON && + typeof rawControlGroupAttributes?.panelsJSON === 'string' + ? JSON.parse(rawControlGroupAttributes?.panelsJSON) + : defaultControlGroupInput.panels, + }; +}; + +export const rawAttributesToSerializable = ( + rawControlGroupAttributes: Omit +): SerializableRecord => { + const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); + return { + controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle, + panels: + rawControlGroupAttributes?.panelsJSON && + typeof rawControlGroupAttributes?.panelsJSON === 'string' + ? (JSON.parse(rawControlGroupAttributes?.panelsJSON) as SerializableRecord) + : defaultControlGroupInput.panels, + }; +}; + +export const serializableToRawAttributes = ( + controlGroupInput: SerializableRecord +): Omit => { + return { + controlStyle: controlGroupInput.controlStyle as ControlStyle, + panelsJSON: JSON.stringify(controlGroupInput.panels), + }; +}; diff --git a/src/plugins/dashboard/common/index.ts b/src/plugins/dashboard/common/index.ts index 73e01693977d..e99fe82ffabd 100644 --- a/src/plugins/dashboard/common/index.ts +++ b/src/plugins/dashboard/common/index.ts @@ -28,3 +28,11 @@ export { migratePanelsTo730 } from './migrate_to_730_panels'; export const UI_SETTINGS = { ENABLE_LABS_UI: 'labs:dashboard:enable_ui', }; + +export { + controlGroupInputToRawAttributes, + getDefaultDashboardControlGroupInput, + rawAttributesToControlGroupInput, + rawAttributesToSerializable, + serializableToRawAttributes, +} from './embeddable/dashboard_control_group'; diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index 7aedbe9e1100..a32e6643a4e3 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -11,7 +11,7 @@ import React, { useEffect, useMemo } from 'react'; import { useDashboardSelector } from './state'; import { useDashboardAppState } from './hooks'; -import { useKibana } from '../../../kibana_react/public'; +import { useKibana, useExecutionContext } from '../../../kibana_react/public'; import { getDashboardBreadcrumb, getDashboardTitle, @@ -48,6 +48,12 @@ export function DashboardApp({ [core.notifications.toasts, history, uiSettings] ); + useExecutionContext(core.executionContext, { + type: 'application', + page: 'app', + id: savedDashboardId || 'new', + }); + const dashboardState = useDashboardSelector((state) => state.dashboardStateReducer); const dashboardAppState = useDashboardAppState({ history, diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx index 761db3ca47ff..a26a4d4977a8 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx @@ -134,26 +134,27 @@ test('DashboardContainer.replacePanel', async (done) => { const container = new DashboardContainer(initialInput, options); let counter = 0; - const subscriptionHandler = jest.fn(({ panels }) => { - counter++; - expect(panels[ID]).toBeDefined(); - // It should be called exactly 2 times and exit the second time - switch (counter) { - case 1: - return expect(panels[ID].type).toBe(CONTACT_CARD_EMBEDDABLE); - - case 2: { - expect(panels[ID].type).toBe(EMPTY_EMBEDDABLE); - subscription.unsubscribe(); - done(); + const subscription = container.getInput$().subscribe( + jest.fn(({ panels }) => { + counter++; + expect(panels[ID]).toBeDefined(); + // It should be called exactly 2 times and exit the second time + switch (counter) { + case 1: + return expect(panels[ID].type).toBe(CONTACT_CARD_EMBEDDABLE); + + case 2: { + expect(panels[ID].type).toBe(EMPTY_EMBEDDABLE); + subscription.unsubscribe(); + done(); + return; + } + + default: + throw Error('Called too many times!'); } - - default: - throw Error('Called too many times!'); - } - }); - - const subscription = container.getInput$().subscribe(subscriptionHandler); + }) + ); // replace the panel now container.replacePanel(container.getInput().panels[ID], { @@ -162,7 +163,7 @@ test('DashboardContainer.replacePanel', async (done) => { }); }); -test('Container view mode change propagates to existing children', async () => { +test('Container view mode change propagates to existing children', async (done) => { const initialInput = getSampleDashboardInput({ panels: { '123': getSampleDashboardPanel({ @@ -172,12 +173,12 @@ test('Container view mode change propagates to existing children', async () => { }, }); const container = new DashboardContainer(initialInput, options); - await nextTick(); - const embeddable = await container.getChild('123'); + const embeddable = await container.untilEmbeddableLoaded('123'); expect(embeddable.getInput().viewMode).toBe(ViewMode.VIEW); container.updateInput({ viewMode: ViewMode.EDIT }); expect(embeddable.getInput().viewMode).toBe(ViewMode.EDIT); + done(); }); test('Container view mode change propagates to new children', async () => { diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx index 0ec7ad21bce3..2595824e8b02 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx @@ -29,7 +29,8 @@ import { ControlGroupOutput, CONTROL_GROUP_TYPE, } from '../../../../controls/public'; -import { getDefaultDashboardControlGroupInput } from '../../dashboard_constants'; + +import { getDefaultDashboardControlGroupInput } from '../../../common/embeddable/dashboard_control_group'; export type DashboardContainerFactory = EmbeddableFactory< DashboardContainerInput, diff --git a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid_item.tsx b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid_item.tsx index 2d1da7a1f7d6..57b03cac7b16 100644 --- a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid_item.tsx +++ b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid_item.tsx @@ -52,9 +52,7 @@ const Item = React.forwardRef( const expandPanel = expandedPanelId !== undefined && expandedPanelId === id; const hidePanel = expandedPanelId !== undefined && expandedPanelId !== id; const classes = classNames({ - // eslint-disable-next-line @typescript-eslint/naming-convention 'dshDashboardGrid__item--expanded': expandPanel, - // eslint-disable-next-line @typescript-eslint/naming-convention 'dshDashboardGrid__item--hidden': hidePanel, // eslint-disable-next-line @typescript-eslint/naming-convention printViewport__vis: container.getInput().viewMode === ViewMode.PRINT, diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss b/src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss index 5dad462803b3..f9fc2c0a2163 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss +++ b/src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss @@ -6,8 +6,8 @@ width: 100%; } -.dshDashboardViewport-controlGroup { - margin: 0 $euiSizeS 0 $euiSizeS; +.dshDashboardViewport-controls { + margin: 0 $euiSizeS 0 $euiSizeS; padding-bottom: $euiSizeXS; } diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx index 43cc00f928c6..0e700e058eef 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx +++ b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx @@ -13,7 +13,12 @@ import { DashboardContainer, DashboardReactContextValue } from '../dashboard_con import { DashboardGrid } from '../grid'; import { context } from '../../../services/kibana_react'; import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen'; -import { ControlGroupContainer } from '../../../../../controls/public'; +import { + CalloutProps, + ControlGroupContainer, + LazyControlsCallout, +} from '../../../../../controls/public'; +import { withSuspense } from '../../../services/presentation_util'; export interface DashboardViewportProps { container: DashboardContainer; @@ -31,6 +36,8 @@ interface State { isEmbeddedExternally?: boolean; } +const ControlsCallout = withSuspense(LazyControlsCallout); + export class DashboardViewport extends React.Component { static contextType = context; public declare readonly context: DashboardReactContextValue; @@ -94,14 +101,24 @@ export class DashboardViewport extends React.Component {controlsEnabled ? ( -
+ <> + {isEditMode && panelCount !== 0 && controlGroup?.getPanelCount() === 0 ? ( + { + return controlGroup?.getCreateControlButton('callout'); + }} + /> + ) : null} +
+ ) : null}
| undefined => { if (!dashboardSavedObject.controlGroupInput) return; - - const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); - return { - controlStyle: - dashboardSavedObject.controlGroupInput?.controlStyle ?? defaultControlGroupInput.controlStyle, - panels: dashboardSavedObject.controlGroupInput?.panelsJSON - ? JSON.parse(dashboardSavedObject.controlGroupInput?.panelsJSON) - : {}, - }; + return rawAttributesToControlGroupInput(dashboardSavedObject.controlGroupInput); }; export const combineDashboardFiltersWithControlGroupFilters = ( diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_url_state.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_url_state.ts index 392b37bb4d8e..227430142325 100644 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_url_state.ts +++ b/src/plugins/dashboard/public/application/lib/sync_dashboard_url_state.ts @@ -7,6 +7,7 @@ */ import _ from 'lodash'; +import { debounceTime } from 'rxjs/operators'; import { migrateAppState } from '.'; import { DashboardSavedObject } from '../..'; @@ -25,8 +26,6 @@ import { convertSavedPanelsToPanelMap } from './convert_dashboard_panels'; type SyncDashboardUrlStateProps = DashboardBuildContext & { savedDashboard: DashboardSavedObject }; -let awaitingRemoval = false; - export const syncDashboardUrlState = ({ dispatchDashboardStateChange, getLatestDashboardState, @@ -36,14 +35,44 @@ export const syncDashboardUrlState = ({ savedDashboard, kibanaVersion, }: SyncDashboardUrlStateProps) => { + /** + * Loads any dashboard state from the URL, and removes the state from the URL. + */ + const loadAndRemoveDashboardState = (): Partial => { + const rawAppStateInUrl = kbnUrlStateStorage.get(DASHBOARD_STATE_STORAGE_KEY); + if (!rawAppStateInUrl) return {}; + + let panelsMap: DashboardPanelMap = {}; + if (rawAppStateInUrl.panels && rawAppStateInUrl.panels.length > 0) { + const rawState = migrateAppState(rawAppStateInUrl, kibanaVersion, usageCollection); + panelsMap = convertSavedPanelsToPanelMap(rawState.panels); + } + + const migratedQuery = rawAppStateInUrl.query + ? migrateLegacyQuery(rawAppStateInUrl.query) + : undefined; + + const nextUrl = replaceUrlHashQuery(window.location.href, (query) => { + delete query[DASHBOARD_STATE_STORAGE_KEY]; + return query; + }); + kbnUrlStateStorage.kbnUrlControls.update(nextUrl, true); + + return { + ..._.omit(rawAppStateInUrl, ['panels', 'query']), + ...(migratedQuery ? { query: migratedQuery } : {}), + ...(rawAppStateInUrl.panels ? { panels: panelsMap } : {}), + }; + }; + // load initial state before subscribing to avoid state removal triggering update. - const loadDashboardStateProps = { kbnUrlStateStorage, usageCollection, kibanaVersion }; - const initialDashboardStateFromUrl = loadDashboardUrlState(loadDashboardStateProps); + const initialDashboardStateFromUrl = loadAndRemoveDashboardState(); const appStateSubscription = kbnUrlStateStorage .change$(DASHBOARD_STATE_STORAGE_KEY) + .pipe(debounceTime(10)) // debounce URL updates so react has time to unsubscribe when changing URLs .subscribe(() => { - const stateFromUrl = loadDashboardUrlState(loadDashboardStateProps); + const stateFromUrl = loadAndRemoveDashboardState(); const updatedDashboardState = { ...getLatestDashboardState(), ...stateFromUrl }; applyDashboardFilterState({ @@ -57,57 +86,6 @@ export const syncDashboardUrlState = ({ dispatchDashboardStateChange(setDashboardState(updatedDashboardState)); }); - const stopWatchingAppStateInUrl = () => { - appStateSubscription.unsubscribe(); - }; + const stopWatchingAppStateInUrl = () => appStateSubscription.unsubscribe(); return { initialDashboardStateFromUrl, stopWatchingAppStateInUrl }; }; - -interface LoadDashboardUrlStateProps { - kibanaVersion: DashboardBuildContext['kibanaVersion']; - usageCollection: DashboardBuildContext['usageCollection']; - kbnUrlStateStorage: DashboardBuildContext['kbnUrlStateStorage']; -} - -/** - * Loads any dashboard state from the URL, and removes the state from the URL. - */ -const loadDashboardUrlState = ({ - kibanaVersion, - usageCollection, - kbnUrlStateStorage, -}: LoadDashboardUrlStateProps): Partial => { - const rawAppStateInUrl = kbnUrlStateStorage.get(DASHBOARD_STATE_STORAGE_KEY); - if (!rawAppStateInUrl) return {}; - - let panelsMap: DashboardPanelMap = {}; - if (rawAppStateInUrl.panels && rawAppStateInUrl.panels.length > 0) { - const rawState = migrateAppState(rawAppStateInUrl, kibanaVersion, usageCollection); - panelsMap = convertSavedPanelsToPanelMap(rawState.panels); - } - - const migratedQuery = rawAppStateInUrl.query - ? migrateLegacyQuery(rawAppStateInUrl.query) - : undefined; - - // remove state from URL - if (!awaitingRemoval) { - awaitingRemoval = true; - kbnUrlStateStorage.kbnUrlControls.updateAsync((nextUrl) => { - awaitingRemoval = false; - if (nextUrl.includes(DASHBOARD_STATE_STORAGE_KEY)) { - return replaceUrlHashQuery(nextUrl, (query) => { - delete query[DASHBOARD_STATE_STORAGE_KEY]; - return query; - }); - } - return nextUrl; - }, true); - } - - return { - ..._.omit(rawAppStateInUrl, ['panels', 'query']), - ...(migratedQuery ? { query: migratedQuery } : {}), - ...(rawAppStateInUrl.panels ? { panels: panelsMap } : {}), - }; -}; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx index 5b53fc47e06a..65374ad723f2 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx @@ -35,6 +35,7 @@ import { DashboardUnsavedListing } from './dashboard_unsaved_listing'; import { confirmCreateWithUnsaved, confirmDiscardUnsavedChanges } from './confirm_overlays'; import { getDashboardListItemLink } from './get_dashboard_list_item_link'; import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_session_storage'; +import { useExecutionContext } from '../../../../kibana_react/public'; export interface DashboardListingProps { kbnUrlStateStorage: IKbnUrlStateStorage; @@ -67,6 +68,11 @@ export const DashboardListing = ({ dashboardSessionStorage.getDashboardIdsWithUnsavedChanges() ); + useExecutionContext(core.executionContext, { + type: 'application', + page: 'list', + }); + // Set breadcrumbs useEffect useEffect(() => { setBreadcrumbs([ diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index eb251ad41f62..e66525398b86 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -598,17 +598,16 @@ export function DashboardTopNav({ /> ), quickButtonGroup: , - addFromLibraryButton: ( - - ), extraButtons: [ , + , + dashboardAppState.dashboardContainer.controlGroup?.getToolbarButtons(), ], }} diff --git a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx index 44b1aec226fd..5fece7ff959c 100644 --- a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx +++ b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx @@ -153,7 +153,8 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { }; const getEmbeddableFactoryMenuItem = ( - factory: EmbeddableFactoryDefinition + factory: EmbeddableFactoryDefinition, + closePopover: () => void ): EuiContextMenuPanelItemDescriptor => { const icon = factory?.getIconType ? factory.getIconType() : 'empty'; @@ -164,6 +165,7 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { icon, toolTipContent, onClick: async () => { + closePopover(); if (trackUiMetric) { trackUiMetric(METRIC_TYPE.CLICK, factory.type); } @@ -192,42 +194,47 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { defaultMessage: 'Aggregation based', }); - const editorMenuPanels = [ - { - id: 0, - items: [ - ...visTypeAliases.map(getVisTypeAliasMenuItem), - ...Object.values(factoryGroupMap).map(({ id, appName, icon, panelId }) => ({ - name: appName, - icon, - panel: panelId, - 'data-test-subj': `dashboardEditorMenu-${id}Group`, - })), - ...ungroupedFactories.map(getEmbeddableFactoryMenuItem), - ...promotedVisTypes.map(getVisTypeMenuItem), - { - name: aggsPanelTitle, - icon: 'visualizeApp', - panel: aggBasedPanelID, - 'data-test-subj': `dashboardEditorAggBasedMenuItem`, - }, - ...toolVisTypes.map(getVisTypeMenuItem), - ], - }, - { - id: aggBasedPanelID, - title: aggsPanelTitle, - items: aggsBasedVisTypes.map(getVisTypeMenuItem), - }, - ...Object.values(factoryGroupMap).map( - ({ appName, panelId, factories: groupFactories }: FactoryGroup) => ({ - id: panelId, - title: appName, - items: groupFactories.map(getEmbeddableFactoryMenuItem), - }) - ), - ]; - + const getEditorMenuPanels = (closePopover: () => void) => { + return [ + { + id: 0, + items: [ + ...visTypeAliases.map(getVisTypeAliasMenuItem), + ...Object.values(factoryGroupMap).map(({ id, appName, icon, panelId }) => ({ + name: appName, + icon, + panel: panelId, + 'data-test-subj': `dashboardEditorMenu-${id}Group`, + })), + ...ungroupedFactories.map((factory) => { + return getEmbeddableFactoryMenuItem(factory, closePopover); + }), + ...promotedVisTypes.map(getVisTypeMenuItem), + { + name: aggsPanelTitle, + icon: 'visualizeApp', + panel: aggBasedPanelID, + 'data-test-subj': `dashboardEditorAggBasedMenuItem`, + }, + ...toolVisTypes.map(getVisTypeMenuItem), + ], + }, + { + id: aggBasedPanelID, + title: aggsPanelTitle, + items: aggsBasedVisTypes.map(getVisTypeMenuItem), + }, + ...Object.values(factoryGroupMap).map( + ({ appName, panelId, factories: groupFactories }: FactoryGroup) => ({ + id: panelId, + title: appName, + items: groupFactories.map((factory) => { + return getEmbeddableFactoryMenuItem(factory, closePopover); + }), + }) + ), + ]; + }; return ( { panelPaddingSize="none" data-test-subj="dashboardEditorMenuButton" > - {() => ( + {({ closePopover }: { closePopover: () => void }) => ( ({ - controlStyle: 'oneLine' as ControlStyle, - panels: {}, -}); - export function createDashboardEditUrl(id?: string, editMode?: boolean) { if (!id) { return `${DashboardConstants.CREATE_NEW_DASHBOARD_URL}`; diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index 52ecb9549d54..661a4dc8144f 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -17,8 +17,7 @@ import { extractReferences, injectReferences } from '../../common/saved_dashboar import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/types'; import { DashboardOptions } from '../types'; - -import { ControlStyle } from '../../../controls/public'; +import { RawControlGroupAttributes } from '../application'; export interface DashboardSavedObject extends SavedObject { id?: string; @@ -39,7 +38,7 @@ export interface DashboardSavedObject extends SavedObject { outcome?: string; aliasId?: string; - controlGroupInput?: { controlStyle?: ControlStyle; panelsJSON?: string }; + controlGroupInput?: Omit; } const defaults = { diff --git a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts index ed8f87ad9b51..bd3051fc5a25 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts @@ -18,7 +18,12 @@ import { migrations730 } from './migrations_730'; import { SavedDashboardPanel } from '../../common/types'; import { EmbeddableSetup } from '../../../embeddable/server'; import { migrateMatchAllQuery } from './migrate_match_all_query'; -import { DashboardDoc700To720, DashboardDoc730ToLatest } from '../../common'; +import { + serializableToRawAttributes, + DashboardDoc700To720, + DashboardDoc730ToLatest, + rawAttributesToSerializable, +} from '../../common'; import { injectReferences, extractReferences } from '../../common/saved_dashboard_references'; import { convertPanelStateToSavedDashboardPanel, @@ -32,6 +37,7 @@ import { MigrateFunctionsObject, } from '../../../kibana_utils/common'; import { replaceIndexPatternReference } from './replace_index_pattern_reference'; +import { CONTROL_GROUP_TYPE } from '../../../controls/common'; function migrateIndexPattern(doc: DashboardDoc700To720) { const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON'); @@ -163,12 +169,23 @@ const migrateByValuePanels = (migrate: MigrateFunction, version: string): SavedObjectMigrationFn => (doc: any) => { const { attributes } = doc; + + if (attributes?.controlGroupInput) { + const controlGroupInput = rawAttributesToSerializable(attributes.controlGroupInput); + const migratedControlGroupInput = migrate({ + ...controlGroupInput, + type: CONTROL_GROUP_TYPE, + }); + attributes.controlGroupInput = serializableToRawAttributes(migratedControlGroupInput); + } + // Skip if panelsJSON is missing otherwise this will cause saved object import to fail when // importing objects without panelsJSON. At development time of this, there is no guarantee each saved // object has panelsJSON in all previous versions of kibana. if (typeof attributes?.panelsJSON !== 'string') { return doc; } + const panels = JSON.parse(attributes.panelsJSON) as SavedDashboardPanel[]; // Same here, prevent failing saved object import if ever panels aren't an array. if (!Array.isArray(panels)) { diff --git a/src/plugins/dashboard/tsconfig.json b/src/plugins/dashboard/tsconfig.json index 55049447aee5..862bed9d667a 100644 --- a/src/plugins/dashboard/tsconfig.json +++ b/src/plugins/dashboard/tsconfig.json @@ -24,6 +24,7 @@ { "path": "../navigation/tsconfig.json" }, { "path": "../saved_objects_tagging_oss/tsconfig.json" }, { "path": "../saved_objects/tsconfig.json" }, + { "path": "../screenshot_mode/tsconfig.json" }, { "path": "../ui_actions/tsconfig.json" }, { "path": "../charts/tsconfig.json" }, { "path": "../discover/tsconfig.json" }, diff --git a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts new file mode 100644 index 000000000000..d626bc222654 --- /dev/null +++ b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.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 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 { createStubDataView } from 'src/plugins/data_views/common/mocks'; +import type { DataViewsContract } from 'src/plugins/data_views/common'; +import type { DatatableColumn } from 'src/plugins/expressions/common'; +import { FieldFormat } from 'src/plugins/field_formats/common'; +import { fieldFormatsMock } from 'src/plugins/field_formats/common/mocks'; +import type { AggsCommonStart } from '../search'; +import { DatatableUtilitiesService } from './datatable_utilities_service'; + +describe('DatatableUtilitiesService', () => { + let aggs: jest.Mocked; + let dataViews: jest.Mocked; + let datatableUtilitiesService: DatatableUtilitiesService; + + beforeEach(() => { + aggs = { + createAggConfigs: jest.fn(), + types: { get: jest.fn() }, + } as unknown as typeof aggs; + dataViews = { + get: jest.fn(), + } as unknown as typeof dataViews; + + datatableUtilitiesService = new DatatableUtilitiesService(aggs, dataViews, fieldFormatsMock); + }); + + describe('clearField', () => { + it('should delete the field reference', () => { + const column = { meta: { field: 'foo' } } as DatatableColumn; + + datatableUtilitiesService.clearField(column); + + expect(column).not.toHaveProperty('meta.field'); + }); + }); + + describe('clearFieldFormat', () => { + it('should remove field format', () => { + const column = { meta: { params: { id: 'number' } } } as DatatableColumn; + datatableUtilitiesService.clearFieldFormat(column); + + expect(column).not.toHaveProperty('meta.params'); + }); + }); + + describe('getDataView', () => { + it('should return a data view instance', async () => { + const column = { meta: { index: 'index' } } as DatatableColumn; + const dataView = {} as ReturnType; + dataViews.get.mockReturnValue(dataView); + + await expect(datatableUtilitiesService.getDataView(column)).resolves.toBe(dataView); + expect(dataViews.get).toHaveBeenCalledWith('index'); + }); + + it('should return undefined when there is no index metadata', async () => { + const column = { meta: {} } as DatatableColumn; + + await expect(datatableUtilitiesService.getDataView(column)).resolves.toBeUndefined(); + expect(dataViews.get).not.toHaveBeenCalled(); + }); + }); + + describe('getField', () => { + it('should return a data view field instance', async () => { + const column = { meta: { field: 'field', index: 'index' } } as DatatableColumn; + const dataView = createStubDataView({ spec: {} }); + const field = {}; + spyOn(datatableUtilitiesService, 'getDataView').and.returnValue(dataView); + spyOn(dataView, 'getFieldByName').and.returnValue(field); + + await expect(datatableUtilitiesService.getField(column)).resolves.toBe(field); + expect(dataView.getFieldByName).toHaveBeenCalledWith('field'); + }); + + it('should return undefined when there is no field metadata', async () => { + const column = { meta: {} } as DatatableColumn; + + await expect(datatableUtilitiesService.getField(column)).resolves.toBeUndefined(); + }); + }); + + describe('getFieldFormat', () => { + it('should deserialize field format', () => { + const column = { meta: { params: { id: 'number' } } } as DatatableColumn; + const fieldFormat = datatableUtilitiesService.getFieldFormat(column); + + expect(fieldFormat).toBeInstanceOf(FieldFormat); + }); + }); + + describe('getInterval', () => { + it('should return a histogram interval', () => { + const column = { + meta: { sourceParams: { params: { interval: '1d' } } }, + } as unknown as DatatableColumn; + + expect(datatableUtilitiesService.getInterval(column)).toBe('1d'); + }); + }); + + describe('setFieldFormat', () => { + it('should set new field format', () => { + const column = { meta: {} } as DatatableColumn; + const fieldFormat = fieldFormatsMock.deserialize({ id: 'number' }); + datatableUtilitiesService.setFieldFormat(column, fieldFormat); + + expect(column.meta.params).toEqual( + expect.objectContaining({ + id: expect.anything(), + params: undefined, + }) + ); + }); + }); +}); diff --git a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts new file mode 100644 index 000000000000..cf4e65f31cce --- /dev/null +++ b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.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 type { DataView, DataViewsContract, DataViewField } from 'src/plugins/data_views/common'; +import type { DatatableColumn } from 'src/plugins/expressions/common'; +import type { FieldFormatsStartCommon, FieldFormat } from 'src/plugins/field_formats/common'; +import type { AggsCommonStart, AggConfig, CreateAggConfigParams, IAggType } from '../search'; + +export class DatatableUtilitiesService { + constructor( + private aggs: AggsCommonStart, + private dataViews: DataViewsContract, + private fieldFormats: FieldFormatsStartCommon + ) { + this.getAggConfig = this.getAggConfig.bind(this); + this.getDataView = this.getDataView.bind(this); + this.getField = this.getField.bind(this); + this.isFilterable = this.isFilterable.bind(this); + } + + clearField(column: DatatableColumn): void { + delete column.meta.field; + } + + clearFieldFormat(column: DatatableColumn): void { + delete column.meta.params; + } + + async getAggConfig(column: DatatableColumn): Promise { + const dataView = await this.getDataView(column); + + if (!dataView) { + return; + } + + const { aggs } = await this.aggs.createAggConfigs( + dataView, + column.meta.sourceParams && [column.meta.sourceParams as CreateAggConfigParams] + ); + + return aggs[0]; + } + + async getDataView(column: DatatableColumn): Promise { + if (!column.meta.index) { + return; + } + + return this.dataViews.get(column.meta.index); + } + + async getField(column: DatatableColumn): Promise { + if (!column.meta.field) { + return; + } + + const dataView = await this.getDataView(column); + if (!dataView) { + return; + } + + return dataView.getFieldByName(column.meta.field); + } + + getFieldFormat(column: DatatableColumn): FieldFormat | undefined { + return this.fieldFormats.deserialize(column.meta.params); + } + + getInterval(column: DatatableColumn): string | undefined { + const params = column.meta.sourceParams?.params as { interval: string } | undefined; + + return params?.interval; + } + + isFilterable(column: DatatableColumn): boolean { + if (column.meta.source !== 'esaggs') { + return false; + } + + const aggType = this.aggs.types.get(column.meta.sourceParams?.type as string) as IAggType; + + return Boolean(aggType.createFilter); + } + + setFieldFormat(column: DatatableColumn, fieldFormat: FieldFormat): void { + column.meta.params = fieldFormat.toJSON(); + } +} diff --git a/src/plugins/data/common/datatable_utilities/index.ts b/src/plugins/data/common/datatable_utilities/index.ts new file mode 100644 index 000000000000..34df78137510 --- /dev/null +++ b/src/plugins/data/common/datatable_utilities/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 * from './datatable_utilities_service'; diff --git a/src/plugins/data/common/datatable_utilities/mock.ts b/src/plugins/data/common/datatable_utilities/mock.ts new file mode 100644 index 000000000000..4266e501f2ca --- /dev/null +++ b/src/plugins/data/common/datatable_utilities/mock.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 type { DatatableUtilitiesService } from './datatable_utilities_service'; + +export function createDatatableUtilitiesMock(): jest.Mocked { + return { + clearField: jest.fn(), + clearFieldFormat: jest.fn(), + getAggConfig: jest.fn(), + getDataView: jest.fn(), + getField: jest.fn(), + getFieldFormat: jest.fn(), + isFilterable: jest.fn(), + setFieldFormat: jest.fn(), + } as unknown as jest.Mocked; +} diff --git a/src/plugins/data/common/es_query/index.ts b/src/plugins/data/common/es_query/index.ts index fa9b7ac86a7f..d717af0107e8 100644 --- a/src/plugins/data/common/es_query/index.ts +++ b/src/plugins/data/common/es_query/index.ts @@ -43,13 +43,10 @@ import { buildExistsFilter as oldBuildExistsFilter, toggleFilterNegated as oldtoggleFilterNegated, Filter as oldFilter, - RangeFilterMeta as oldRangeFilterMeta, RangeFilterParams as oldRangeFilterParams, ExistsFilter as oldExistsFilter, - PhrasesFilter as oldPhrasesFilter, PhraseFilter as oldPhraseFilter, MatchAllFilter as oldMatchAllFilter, - CustomFilter as oldCustomFilter, RangeFilter as oldRangeFilter, KueryNode as oldKueryNode, FilterMeta as oldFilterMeta, @@ -58,17 +55,11 @@ import { compareFilters as oldCompareFilters, COMPARE_ALL_OPTIONS as OLD_COMPARE_ALL_OPTIONS, dedupFilters as oldDedupFilters, - isFilter as oldIsFilter, onlyDisabledFiltersChanged as oldOnlyDisabledFiltersChanged, uniqFilters as oldUniqFilters, FilterStateStore, } from '@kbn/es-query'; -/** - * @deprecated Import from the "@kbn/es-query" package directly instead. - * @removeBy 8.1 - */ -const isFilter = oldIsFilter; /** * @deprecated Import from the "@kbn/es-query" package directly instead. * @removeBy 8.1 @@ -295,12 +286,6 @@ const FILTERS = oldFILTERS; */ type Filter = oldFilter; -/** - * @deprecated Import from the "@kbn/es-query" package directly instead. - * @removeBy 8.1 - */ -type RangeFilterMeta = oldRangeFilterMeta; - /** * @deprecated Import from the "@kbn/es-query" package directly instead. * @removeBy 8.1 @@ -313,12 +298,6 @@ type RangeFilterParams = oldRangeFilterParams; */ type ExistsFilter = oldExistsFilter; -/** - * @deprecated Import from the "@kbn/es-query" package directly instead. - * @removeBy 8.1 - */ -type PhrasesFilter = oldPhrasesFilter; - /** * @deprecated Import from the "@kbn/es-query" package directly instead. * @removeBy 8.1 @@ -331,12 +310,6 @@ type PhraseFilter = oldPhraseFilter; */ type MatchAllFilter = oldMatchAllFilter; -/** - * @deprecated Import from the "@kbn/es-query" package directly instead. - * @removeBy 8.1 - */ -type CustomFilter = oldCustomFilter; - /** * @deprecated Import from the "@kbn/es-query" package directly instead. * @removeBy 8.1 @@ -368,13 +341,10 @@ type EsQueryConfig = oldEsQueryConfig; export type { Filter, - RangeFilterMeta, RangeFilterParams, ExistsFilter, - PhrasesFilter, PhraseFilter, MatchAllFilter, - CustomFilter, RangeFilter, KueryNode, FilterMeta, @@ -415,7 +385,6 @@ export { buildExistsFilter, toggleFilterNegated, FILTERS, - isFilter, isFilterDisabled, dedupFilters, onlyDisabledFiltersChanged, diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index 7bb4b78850dc..a793050eb655 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -10,6 +10,7 @@ /* eslint-disable @kbn/eslint/no_export_all */ export * from './constants'; +export * from './datatable_utilities'; export * from './es_query'; export * from './kbn_field_types'; export * from './query'; diff --git a/src/plugins/data/common/mocks.ts b/src/plugins/data/common/mocks.ts index c656d9d21346..cf7d6bef6a4e 100644 --- a/src/plugins/data/common/mocks.ts +++ b/src/plugins/data/common/mocks.ts @@ -7,3 +7,4 @@ */ export * from '../../data_views/common/fields/fields.mocks'; +export * from './datatable_utilities/mock'; diff --git a/src/plugins/data/common/search/aggs/agg_types.ts b/src/plugins/data/common/search/aggs/agg_types.ts index 01ccd401c07a..d7750c48016c 100644 --- a/src/plugins/data/common/search/aggs/agg_types.ts +++ b/src/plugins/data/common/search/aggs/agg_types.ts @@ -36,6 +36,7 @@ export const getAggTypes = () => ({ { name: METRIC_TYPES.PERCENTILES, fn: metrics.getPercentilesMetricAgg }, { name: METRIC_TYPES.PERCENTILE_RANKS, fn: metrics.getPercentileRanksMetricAgg }, { name: METRIC_TYPES.TOP_HITS, fn: metrics.getTopHitMetricAgg }, + { name: METRIC_TYPES.TOP_METRICS, fn: metrics.getTopMetricsMetricAgg }, { name: METRIC_TYPES.DERIVATIVE, fn: metrics.getDerivativeMetricAgg }, { name: METRIC_TYPES.CUMULATIVE_SUM, fn: metrics.getCumulativeSumMetricAgg }, { name: METRIC_TYPES.MOVING_FN, fn: metrics.getMovingAvgMetricAgg }, @@ -109,4 +110,5 @@ export const getAggTypesFunctions = () => [ metrics.aggStdDeviation, metrics.aggSum, metrics.aggTopHit, + metrics.aggTopMetrics, ]; diff --git a/src/plugins/data/common/search/aggs/aggs_service.test.ts b/src/plugins/data/common/search/aggs/aggs_service.test.ts index 998b8bf286b5..6090e965489e 100644 --- a/src/plugins/data/common/search/aggs/aggs_service.test.ts +++ b/src/plugins/data/common/search/aggs/aggs_service.test.ts @@ -95,6 +95,7 @@ describe('Aggs service', () => { "percentiles", "percentile_ranks", "top_hits", + "top_metrics", "derivative", "cumulative_sum", "moving_avg", @@ -147,6 +148,7 @@ describe('Aggs service', () => { "percentiles", "percentile_ranks", "top_hits", + "top_metrics", "derivative", "cumulative_sum", "moving_avg", @@ -206,11 +208,10 @@ describe('Aggs service', () => { describe('start()', () => { test('exposes proper contract', () => { const start = service.start(startDeps); - expect(Object.keys(start).length).toBe(4); + expect(Object.keys(start).length).toBe(3); expect(start).toHaveProperty('calculateAutoTimeExpression'); expect(start).toHaveProperty('createAggConfigs'); expect(start).toHaveProperty('types'); - expect(start).toHaveProperty('datatableUtilities'); }); test('types registry returns uninitialized type providers', () => { diff --git a/src/plugins/data/common/search/aggs/aggs_service.ts b/src/plugins/data/common/search/aggs/aggs_service.ts index 58f65bb0cab4..6fe7eef5b87b 100644 --- a/src/plugins/data/common/search/aggs/aggs_service.ts +++ b/src/plugins/data/common/search/aggs/aggs_service.ts @@ -17,7 +17,6 @@ import { getCalculateAutoTimeExpression, } from './'; import { AggsCommonSetup, AggsCommonStart } from './types'; -import { getDatatableColumnUtilities } from './utils/datatable_column_meta'; /** @internal */ export const aggsRequiredUiSettings = [ @@ -67,11 +66,7 @@ export class AggsCommonService { }; } - public start({ - getConfig, - getIndexPattern, - isDefaultTimezone, - }: AggsCommonStartDependencies): AggsCommonStart { + public start({ getConfig }: AggsCommonStartDependencies): AggsCommonStart { const aggTypesStart = this.aggTypesRegistry.start(); const calculateAutoTimeExpression = getCalculateAutoTimeExpression(getConfig); @@ -86,11 +81,6 @@ export class AggsCommonService { return { calculateAutoTimeExpression, - datatableUtilities: getDatatableColumnUtilities({ - getIndexPattern, - createAggConfigs, - aggTypesStart, - }), createAggConfigs, types: aggTypesStart, }; diff --git a/src/plugins/data/common/search/aggs/buckets/filters.ts b/src/plugins/data/common/search/aggs/buckets/filters.ts index 4861c7248ebf..2ce6abbef543 100644 --- a/src/plugins/data/common/search/aggs/buckets/filters.ts +++ b/src/plugins/data/common/search/aggs/buckets/filters.ts @@ -12,7 +12,6 @@ import { buildEsQuery, Query } from '@kbn/es-query'; import { QueryFilter, queryFilterToAst } from '../../expressions'; import { createFilterFilters } from './create_filter/filters'; -import { toAngularJSON } from '../utils'; import { BucketAggType } from './bucket_agg_type'; import { BUCKET_TYPES } from './bucket_agg_types'; import { aggFiltersFnName } from './filters_fn'; @@ -83,7 +82,7 @@ export const getFiltersBucketAgg = ({ getConfig }: FiltersBucketAggDependencies) matchAllLabel || (typeof filter.input.query === 'string' ? filter.input.query - : toAngularJSON(filter.input.query)); + : JSON.stringify(filter.input.query)); filters[label] = query; }, {} diff --git a/src/plugins/data/common/search/aggs/metrics/index.ts b/src/plugins/data/common/search/aggs/metrics/index.ts index d37b74a1a28a..4d80e3632510 100644 --- a/src/plugins/data/common/search/aggs/metrics/index.ts +++ b/src/plugins/data/common/search/aggs/metrics/index.ts @@ -56,3 +56,5 @@ export * from './sum_fn'; export * from './sum'; export * from './top_hit_fn'; export * from './top_hit'; +export * from './top_metrics'; +export * from './top_metrics_fn'; diff --git a/src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts b/src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts index 478b8309272e..1fe703313218 100644 --- a/src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts +++ b/src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts @@ -15,6 +15,7 @@ import { parentPipelineAggWriter } from './parent_pipeline_agg_writer'; const metricAggFilter = [ '!top_hits', + '!top_metrics', '!percentiles', '!percentile_ranks', '!median', diff --git a/src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts b/src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts index f8c903b8cfe4..243a119847a2 100644 --- a/src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts +++ b/src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts @@ -13,6 +13,7 @@ import { IMetricAggConfig, MetricAggParam } from '../metric_agg_type'; const metricAggFilter: string[] = [ '!top_hits', + '!top_metrics', '!percentiles', '!percentile_ranks', '!median', diff --git a/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts b/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts index 6ddb0fdd9410..5237c1ecffe5 100644 --- a/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts +++ b/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts @@ -22,6 +22,7 @@ export interface MetricAggParam extends AggParamType { filterFieldTypes?: FieldTypes; onlyAggregatable?: boolean; + scriptable?: boolean; } const metricType = 'metrics'; diff --git a/src/plugins/data/common/search/aggs/metrics/metric_agg_types.ts b/src/plugins/data/common/search/aggs/metrics/metric_agg_types.ts index a308153b3816..eed6d0a378fc 100644 --- a/src/plugins/data/common/search/aggs/metrics/metric_agg_types.ts +++ b/src/plugins/data/common/search/aggs/metrics/metric_agg_types.ts @@ -27,6 +27,7 @@ export enum METRIC_TYPES { SERIAL_DIFF = 'serial_diff', SUM = 'sum', TOP_HITS = 'top_hits', + TOP_METRICS = 'top_metrics', PERCENTILES = 'percentiles', PERCENTILE_RANKS = 'percentile_ranks', STD_DEV = 'std_dev', diff --git a/src/plugins/data/common/search/aggs/metrics/top_metrics.test.ts b/src/plugins/data/common/search/aggs/metrics/top_metrics.test.ts new file mode 100644 index 000000000000..9bf5f581aa0a --- /dev/null +++ b/src/plugins/data/common/search/aggs/metrics/top_metrics.test.ts @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { getTopMetricsMetricAgg } from './top_metrics'; +import { AggConfigs } from '../agg_configs'; +import { mockAggTypesRegistry } from '../test_helpers'; +import { IMetricAggConfig } from './metric_agg_type'; +import { KBN_FIELD_TYPES } from '../../../../common'; + +describe('Top metrics metric', () => { + let aggConfig: IMetricAggConfig; + + const init = ({ + fieldName = 'field', + fieldType = KBN_FIELD_TYPES.NUMBER, + sortFieldName = 'sortField', + sortFieldType = KBN_FIELD_TYPES.NUMBER, + sortOrder = 'desc', + size = 1, + }: any) => { + const typesRegistry = mockAggTypesRegistry(); + const field = { + name: fieldName, + displayName: fieldName, + type: fieldType, + }; + + const sortField = { + name: sortFieldName, + displayName: sortFieldName, + type: sortFieldType, + }; + + const params = { + size, + field: field.name, + sortField: sortField.name, + sortOrder: { + value: sortOrder, + }, + }; + + const indexPattern = { + id: '1234', + title: 'logstash-*', + fields: { + getByName: (name: string) => { + if (name === sortFieldName) return sortField; + if (name === fieldName) return field; + return null; + }, + filter: () => [field, sortField], + }, + } as any; + + const aggConfigs = new AggConfigs( + indexPattern, + [ + { + id: '1', + type: 'top_metrics', + schema: 'metric', + params, + }, + ], + { typesRegistry } + ); + + // Grab the aggConfig off the vis (we don't actually use the vis for anything else) + aggConfig = aggConfigs.aggs[0] as IMetricAggConfig; + }; + + it('should return a label prefixed with Last if sorting in descending order', () => { + init({ fieldName: 'bytes', sortFieldName: '@timestamp' }); + expect(getTopMetricsMetricAgg().makeLabel(aggConfig)).toEqual( + 'Last "bytes" value by "@timestamp"' + ); + }); + + it('should return a label prefixed with First if sorting in ascending order', () => { + init({ + fieldName: 'bytes', + sortFieldName: '@timestamp', + sortOrder: 'asc', + }); + expect(getTopMetricsMetricAgg().makeLabel(aggConfig)).toEqual( + 'First "bytes" value by "@timestamp"' + ); + }); + + it('should return a label with size if larger then 1', () => { + init({ + fieldName: 'bytes', + sortFieldName: '@timestamp', + sortOrder: 'asc', + size: 3, + }); + expect(getTopMetricsMetricAgg().makeLabel(aggConfig)).toEqual( + 'First 3 "bytes" values by "@timestamp"' + ); + }); + + it('should return a fieldName in getValueBucketPath', () => { + init({ + fieldName: 'bytes', + sortFieldName: '@timestamp', + sortOrder: 'asc', + size: 3, + }); + expect(getTopMetricsMetricAgg().getValueBucketPath(aggConfig)).toEqual('1[bytes]'); + }); + + it('produces the expected expression ast', () => { + init({ fieldName: 'machine.os', sortFieldName: '@timestamp' }); + expect(aggConfig.toExpressionAst()).toMatchInlineSnapshot(` + Object { + "chain": Array [ + Object { + "arguments": Object { + "enabled": Array [ + true, + ], + "field": Array [ + "machine.os", + ], + "id": Array [ + "1", + ], + "schema": Array [ + "metric", + ], + "size": Array [ + 1, + ], + "sortField": Array [ + "@timestamp", + ], + "sortOrder": Array [ + "desc", + ], + }, + "function": "aggTopMetrics", + "type": "function", + }, + ], + "type": "expression", + } + `); + }); + + describe('gets value from top metrics bucket', () => { + it('should return null if there is no hits', () => { + const bucket = { + '1': { + top: [], + }, + }; + + init({ fieldName: 'bytes' }); + expect(getTopMetricsMetricAgg().getValue(aggConfig, bucket)).toBe(null); + }); + + it('should return a single value if there is a single hit', () => { + const bucket = { + '1': { + top: [{ sort: [3], metrics: { bytes: 1024 } }], + }, + }; + + init({ fieldName: 'bytes' }); + expect(getTopMetricsMetricAgg().getValue(aggConfig, bucket)).toBe(1024); + }); + + it('should return an array of values if there is a multiple results', () => { + const bucket = { + '1': { + top: [ + { sort: [3], metrics: { bytes: 1024 } }, + { sort: [2], metrics: { bytes: 512 } }, + { sort: [1], metrics: { bytes: 256 } }, + ], + }, + }; + + init({ fieldName: 'bytes' }); + expect(getTopMetricsMetricAgg().getValue(aggConfig, bucket)).toEqual([1024, 512, 256]); + }); + }); +}); diff --git a/src/plugins/data/common/search/aggs/metrics/top_metrics.ts b/src/plugins/data/common/search/aggs/metrics/top_metrics.ts new file mode 100644 index 000000000000..2079925e0435 --- /dev/null +++ b/src/plugins/data/common/search/aggs/metrics/top_metrics.ts @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import _ from 'lodash'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { i18n } from '@kbn/i18n'; +import { aggTopMetricsFnName } from './top_metrics_fn'; +import { IMetricAggConfig, MetricAggType } from './metric_agg_type'; +import { METRIC_TYPES } from './metric_agg_types'; +import { KBN_FIELD_TYPES } from '../../../../common'; +import { BaseAggParams } from '../types'; + +export interface AggParamsTopMetrics extends BaseAggParams { + field: string; + sortField?: string; + sortOrder?: 'desc' | 'asc'; + size?: number; +} + +export const getTopMetricsMetricAgg = () => { + return new MetricAggType({ + name: METRIC_TYPES.TOP_METRICS, + expressionName: aggTopMetricsFnName, + title: i18n.translate('data.search.aggs.metrics.topMetricsTitle', { + defaultMessage: 'Top metrics', + }), + makeLabel(aggConfig) { + const isDescOrder = aggConfig.getParam('sortOrder').value === 'desc'; + const size = aggConfig.getParam('size'); + const field = aggConfig.getParam('field'); + const sortField = aggConfig.getParam('sortField'); + + if (isDescOrder) { + if (size > 1) { + return i18n.translate('data.search.aggs.metrics.topMetrics.descWithSizeLabel', { + defaultMessage: `Last {size} "{fieldName}" values by "{sortField}"`, + values: { + size, + fieldName: field?.displayName, + sortField: sortField?.displayName ?? '_score', + }, + }); + } else { + return i18n.translate('data.search.aggs.metrics.topMetrics.descNoSizeLabel', { + defaultMessage: `Last "{fieldName}" value by "{sortField}"`, + values: { + fieldName: field?.displayName, + sortField: sortField?.displayName ?? '_score', + }, + }); + } + } else { + if (size > 1) { + return i18n.translate('data.search.aggs.metrics.topMetrics.ascWithSizeLabel', { + defaultMessage: `First {size} "{fieldName}" values by "{sortField}"`, + values: { + size, + fieldName: field?.displayName, + sortField: sortField?.displayName ?? '_score', + }, + }); + } else { + return i18n.translate('data.search.aggs.metrics.topMetrics.ascNoSizeLabel', { + defaultMessage: `First "{fieldName}" value by "{sortField}"`, + values: { + fieldName: field?.displayName, + sortField: sortField?.displayName ?? '_score', + }, + }); + } + } + }, + params: [ + { + name: 'field', + type: 'field', + scriptable: false, + filterFieldTypes: [ + KBN_FIELD_TYPES.STRING, + KBN_FIELD_TYPES.IP, + KBN_FIELD_TYPES.BOOLEAN, + KBN_FIELD_TYPES.NUMBER, + KBN_FIELD_TYPES.DATE, + ], + write(agg, output) { + const field = agg.getParam('field'); + output.params.metrics = { field: field.name }; + }, + }, + { + name: 'size', + default: 1, + }, + { + name: 'sortField', + type: 'field', + scriptable: false, + filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE], + default(agg: IMetricAggConfig) { + return agg.getIndexPattern().timeFieldName; + }, + write: _.noop, // prevent default write, it is handled below + }, + { + name: 'sortOrder', + type: 'optioned', + default: 'desc', + options: [ + { + text: i18n.translate('data.search.aggs.metrics.topMetrics.descendingLabel', { + defaultMessage: 'Descending', + }), + value: 'desc', + }, + { + text: i18n.translate('data.search.aggs.metrics.topMetrics.ascendingLabel', { + defaultMessage: 'Ascending', + }), + value: 'asc', + }, + ], + write(agg, output) { + const sortField = agg.params.sortField; + const sortOrder = agg.params.sortOrder; + + if (sortField && sortOrder) { + output.params.sort = { + [sortField.name]: sortOrder.value, + }; + } else { + output.params.sort = '_score'; + } + }, + }, + ], + // override is needed to support top_metrics as an orderAgg of terms agg + getValueBucketPath(agg) { + const field = agg.getParam('field').name; + return `${agg.id}[${field}]`; + }, + getValue(agg, aggregate: Record) { + const metricFieldName = agg.getParam('field').name; + const results = aggregate[agg.id]?.top.map((result) => result.metrics[metricFieldName]) ?? []; + + if (results.length === 0) return null; + if (results.length === 1) return results[0]; + return results; + }, + }); +}; diff --git a/src/plugins/data/common/search/aggs/metrics/top_metrics_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/top_metrics_fn.test.ts new file mode 100644 index 000000000000..848fccda283f --- /dev/null +++ b/src/plugins/data/common/search/aggs/metrics/top_metrics_fn.test.ts @@ -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 { functionWrapper } from '../test_helpers'; +import { aggTopMetrics } from './top_metrics_fn'; + +describe('agg_expression_functions', () => { + describe('aggTopMetrics', () => { + const fn = functionWrapper(aggTopMetrics()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + "size": undefined, + "sortField": undefined, + "sortOrder": undefined, + }, + "schema": undefined, + "type": "top_metrics", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + id: '1', + enabled: false, + schema: 'whatever', + field: 'machine.os.keyword', + sortOrder: 'asc', + size: 6, + sortField: 'bytes', + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": false, + "id": "1", + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + "size": 6, + "sortField": "bytes", + "sortOrder": "asc", + }, + "schema": "whatever", + "type": "top_metrics", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual('{ "foo": true }'); + }); + }); +}); diff --git a/src/plugins/data/common/search/aggs/metrics/top_metrics_fn.ts b/src/plugins/data/common/search/aggs/metrics/top_metrics_fn.ts new file mode 100644 index 000000000000..6fe9ba97fe44 --- /dev/null +++ b/src/plugins/data/common/search/aggs/metrics/top_metrics_fn.ts @@ -0,0 +1,106 @@ +/* + * Copyright 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 { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; + +export const aggTopMetricsFnName = 'aggTopMetrics'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition< + typeof aggTopMetricsFnName, + Input, + AggArgs, + Output +>; + +export const aggTopMetrics = (): FunctionDefinition => ({ + name: aggTopMetricsFnName, + help: i18n.translate('data.search.aggs.function.metrics.topMetrics.help', { + defaultMessage: 'Generates a serialized aggregation configuration for Top metrics.', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.topMetrics.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.topMetrics.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.topMetrics.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.topMetrics.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + size: { + types: ['number'], + help: i18n.translate('data.search.aggs.metrics.topMetrics.size.help', { + defaultMessage: 'Number of top values to retrieve', + }), + }, + sortOrder: { + types: ['string'], + options: ['desc', 'asc'], + help: i18n.translate('data.search.aggs.metrics.topMetrics.sortOrder.help', { + defaultMessage: 'Order in which to return the results: asc or desc', + }), + }, + sortField: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.topMetrics.sortField.help', { + defaultMessage: 'Field to order results by', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.topMetrics.json.help', { + defaultMessage: 'Advanced JSON to include when the aggregation is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.topMetrics.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.TOP_METRICS, + params: { + ...rest, + }, + }, + }; + }, +}); diff --git a/src/plugins/data/common/search/aggs/param_types/field.ts b/src/plugins/data/common/search/aggs/param_types/field.ts index 940fdafd5487..b56787121f72 100644 --- a/src/plugins/data/common/search/aggs/param_types/field.ts +++ b/src/plugins/data/common/search/aggs/param_types/field.ts @@ -43,6 +43,7 @@ export class FieldParamType extends BaseParamType { this.filterFieldTypes = config.filterFieldTypes || '*'; this.onlyAggregatable = config.onlyAggregatable !== false; + this.scriptable = config.scriptable !== false; this.filterField = config.filterField; if (!config.write) { diff --git a/src/plugins/data/common/search/aggs/types.ts b/src/plugins/data/common/search/aggs/types.ts index 34d773b0ba51..edc328bcb509 100644 --- a/src/plugins/data/common/search/aggs/types.ts +++ b/src/plugins/data/common/search/aggs/types.ts @@ -7,7 +7,6 @@ */ import { Assign } from '@kbn/utility-types'; -import { DatatableColumn } from 'src/plugins/expressions'; import { IndexPattern } from '../..'; import { aggAvg, @@ -88,13 +87,14 @@ import { CreateAggConfigParams, getCalculateAutoTimeExpression, METRIC_TYPES, - AggConfig, aggFilteredMetric, aggSinglePercentile, } from './'; import { AggParamsSampler } from './buckets/sampler'; import { AggParamsDiversifiedSampler } from './buckets/diversified_sampler'; import { AggParamsSignificantText } from './buckets/significant_text'; +import { AggParamsTopMetrics } from './metrics/top_metrics'; +import { aggTopMetrics } from './metrics/top_metrics_fn'; export type { IAggConfig, AggConfigSerialized } from './agg_config'; export type { CreateAggConfigParams, IAggConfigs } from './agg_configs'; @@ -111,11 +111,6 @@ export interface AggsCommonSetup { export interface AggsCommonStart { calculateAutoTimeExpression: ReturnType; - datatableUtilities: { - getIndexPattern: (column: DatatableColumn) => Promise; - getAggConfig: (column: DatatableColumn) => Promise; - isFilterable: (column: DatatableColumn) => boolean; - }; createAggConfigs: ( indexPattern: IndexPattern, configStates?: CreateAggConfigParams[] @@ -194,6 +189,7 @@ export interface AggParamsMapping { [METRIC_TYPES.PERCENTILES]: AggParamsPercentiles; [METRIC_TYPES.SERIAL_DIFF]: AggParamsSerialDiff; [METRIC_TYPES.TOP_HITS]: AggParamsTopHit; + [METRIC_TYPES.TOP_METRICS]: AggParamsTopMetrics; } /** @@ -236,4 +232,5 @@ export interface AggFunctionsMapping { aggStdDeviation: ReturnType; aggSum: ReturnType; aggTopHit: ReturnType; + aggTopMetrics: ReturnType; } diff --git a/src/plugins/data/common/search/aggs/utils/datatable_column_meta.ts b/src/plugins/data/common/search/aggs/utils/datatable_column_meta.ts deleted file mode 100644 index 0e3ff69fac1d..000000000000 --- a/src/plugins/data/common/search/aggs/utils/datatable_column_meta.ts +++ /dev/null @@ -1,57 +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 { DatatableColumn } from 'src/plugins/expressions/common'; -import { IndexPattern } from '../../..'; -import { AggConfigs, CreateAggConfigParams } from '../agg_configs'; -import { AggTypesRegistryStart } from '../agg_types_registry'; -import { IAggType } from '../agg_type'; - -export interface MetaByColumnDeps { - getIndexPattern: (id: string) => Promise; - createAggConfigs: ( - indexPattern: IndexPattern, - configStates?: CreateAggConfigParams[] - ) => InstanceType; - aggTypesStart: AggTypesRegistryStart; -} - -export const getDatatableColumnUtilities = (deps: MetaByColumnDeps) => { - const { getIndexPattern, createAggConfigs, aggTypesStart } = deps; - - const getIndexPatternFromDatatableColumn = async (column: DatatableColumn) => { - if (!column.meta.index) return; - - return await getIndexPattern(column.meta.index); - }; - - const getAggConfigFromDatatableColumn = async (column: DatatableColumn) => { - const indexPattern = await getIndexPatternFromDatatableColumn(column); - - if (!indexPattern) return; - - const aggConfigs = await createAggConfigs(indexPattern, [column.meta.sourceParams as any]); - return aggConfigs.aggs[0]; - }; - - const isFilterableAggDatatableColumn = (column: DatatableColumn) => { - if (column.meta.source !== 'esaggs') { - return false; - } - const aggType = (aggTypesStart.get(column.meta.sourceParams?.type as string) as any)( - {} - ) as IAggType; - return Boolean(aggType.createFilter); - }; - - return { - getIndexPattern: getIndexPatternFromDatatableColumn, - getAggConfig: getAggConfigFromDatatableColumn, - isFilterable: isFilterableAggDatatableColumn, - }; -}; diff --git a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.test.ts b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.test.ts index 8510acf1572c..963fe024c1f8 100644 --- a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.test.ts +++ b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.test.ts @@ -126,7 +126,7 @@ describe('getAggsFormats', () => { const mapping = { id: 'multi_terms', params: { - paramsPerField: Array(terms.length).fill({ id: 'terms' }), + paramsPerField: [{ id: 'terms' }, { id: 'terms' }, { id: 'terms' }], }, }; @@ -141,7 +141,7 @@ describe('getAggsFormats', () => { const mapping = { id: 'multi_terms', params: { - paramsPerField: Array(terms.length).fill({ id: 'terms' }), + paramsPerField: [{ id: 'terms' }, { id: 'terms' }, { id: 'terms' }], separator: ' - ', }, }; diff --git a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts index f14f981fdec6..e514cc24f93c 100644 --- a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts +++ b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts @@ -12,6 +12,7 @@ import { i18n } from '@kbn/i18n'; import { FieldFormat, FieldFormatInstanceType, + FieldFormatParams, FieldFormatsContentType, IFieldFormat, SerializedFieldFormat, @@ -133,11 +134,20 @@ export function getAggsFormats(getFieldFormat: GetFieldFormat): FieldFormatInsta static id = 'multi_terms'; static hidden = true; + private formatCache: Map, FieldFormat> = new Map(); + convert = (val: unknown, type: FieldFormatsContentType) => { const params = this._params; - const formats = (params.paramsPerField as SerializedFieldFormat[]).map((fieldParams) => - getFieldFormat({ id: fieldParams.id, params: fieldParams }) - ); + const formats = (params.paramsPerField as SerializedFieldFormat[]).map((fieldParams) => { + const isCached = this.formatCache.has(fieldParams); + const cachedFormat = + this.formatCache.get(fieldParams) || + getFieldFormat({ id: fieldParams.id, params: fieldParams }); + if (!isCached) { + this.formatCache.set(fieldParams, cachedFormat); + } + return cachedFormat; + }); if (String(val) === '__other__') { return params.otherBucketLabel; diff --git a/src/plugins/data/common/search/aggs/utils/index.ts b/src/plugins/data/common/search/aggs/utils/index.ts index 2edce79ca690..d12c0ebb1cbb 100644 --- a/src/plugins/data/common/search/aggs/utils/index.ts +++ b/src/plugins/data/common/search/aggs/utils/index.ts @@ -13,6 +13,5 @@ export * from './date_interval_utils'; export * from './get_aggs_formats'; export * from './ip_address'; export * from './prop_filter'; -export * from './to_angular_json'; export * from './infer_time_zone'; export * from './parse_time_shift'; diff --git a/src/plugins/data/common/search/aggs/utils/to_angular_json.ts b/src/plugins/data/common/search/aggs/utils/to_angular_json.ts deleted file mode 100644 index cf4050072b82..000000000000 --- a/src/plugins/data/common/search/aggs/utils/to_angular_json.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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. - */ - -/** - * An inlined version of angular.toJSON(). Source: - * https://github.com/angular/angular.js/blob/master/src/Angular.js#L1312 - * - * @internal - */ -export function toAngularJSON(obj: any, pretty?: any): string { - if (obj === undefined) return ''; - if (typeof pretty === 'number') { - pretty = pretty ? 2 : null; - } - return JSON.stringify(obj, toJsonReplacer, pretty); -} - -function isWindow(obj: any) { - return obj && obj.window === obj; -} - -function isScope(obj: any) { - return obj && obj.$evalAsync && obj.$watch; -} - -function toJsonReplacer(key: any, value: any) { - let val = value; - - if (typeof key === 'string' && key.charAt(0) === '$' && key.charAt(1) === '$') { - val = undefined; - } else if (isWindow(value)) { - val = '$WINDOW'; - } else if (value && window.document === value) { - val = '$DOCUMENT'; - } else if (isScope(value)) { - val = '$SCOPE'; - } - - return val; -} diff --git a/src/plugins/data/public/deprecated.ts b/src/plugins/data/public/deprecated.ts index 0458a940482d..6a6c7bbb2cd2 100644 --- a/src/plugins/data/public/deprecated.ts +++ b/src/plugins/data/public/deprecated.ts @@ -36,16 +36,12 @@ import { luceneStringToDsl, decorateQuery, FILTERS, - isFilter, isFilters, KueryNode, RangeFilter, - RangeFilterMeta, RangeFilterParams, ExistsFilter, - PhrasesFilter, PhraseFilter, - CustomFilter, MatchAllFilter, EsQueryConfig, FilterStateStore, @@ -139,16 +135,13 @@ export const esFilters = { export type { KueryNode, RangeFilter, - RangeFilterMeta, RangeFilterParams, ExistsFilter, - PhrasesFilter, PhraseFilter, - CustomFilter, MatchAllFilter, EsQueryConfig, }; -export { isFilter, isFilters }; +export { isFilters }; /** * @deprecated Import helpers from the "@kbn/es-query" package directly instead. diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index 53600a1f4446..630a29a8a785 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { createDatatableUtilitiesMock } from '../common/mocks'; import { DataPlugin, DataViewsContract } from '.'; import { fieldFormatsServiceMock } from '../../field_formats/public/mocks'; import { searchServiceMock } from './search/mocks'; @@ -58,6 +59,7 @@ const createStartContract = (): Start => { createFiltersFromRangeSelectAction: jest.fn(), }, autocomplete: autocompleteStartMock, + datatableUtilities: createDatatableUtilitiesMock(), search: searchServiceMock.createStartContract(), fieldFormats: fieldFormatsServiceMock.createStartContract(), query: queryStartMock, diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 7d19c1eb3ac1..50795b441624 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -42,7 +42,7 @@ import { APPLY_FILTER_TRIGGER, applyFilterTrigger } from './triggers'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { getTableViewDescription } from './utils/table_inspector_view'; import { NowProvider, NowProviderInternalContract } from './now_provider'; -import { getAggsFormats } from '../common'; +import { getAggsFormats, DatatableUtilitiesService } from '../common'; export class DataPublicPlugin implements @@ -108,7 +108,7 @@ export class DataPublicPlugin uiActions: startServices().plugins.uiActions, uiSettings: startServices().core.uiSettings, fieldFormats: startServices().self.fieldFormats, - isFilterable: startServices().self.search.aggs.datatableUtilities.isFilterable, + isFilterable: startServices().self.datatableUtilities.isFilterable, })) ); @@ -166,12 +166,14 @@ export class DataPublicPlugin uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER) ); + const datatableUtilities = new DatatableUtilitiesService(search.aggs, dataViews, fieldFormats); const dataServices = { actions: { createFiltersFromValueClickAction, createFiltersFromRangeSelectAction, }, autocomplete: this.autocomplete.start(), + datatableUtilities, fieldFormats, indexPatterns: dataViews, dataViews, diff --git a/src/plugins/data/public/search/aggs/aggs_service.test.ts b/src/plugins/data/public/search/aggs/aggs_service.test.ts index b0e6e0327e65..83328e196fa0 100644 --- a/src/plugins/data/public/search/aggs/aggs_service.test.ts +++ b/src/plugins/data/public/search/aggs/aggs_service.test.ts @@ -54,7 +54,7 @@ describe('AggsService - public', () => { service.setup(setupDeps); const start = service.start(startDeps); expect(start.types.getAll().buckets.length).toBe(16); - expect(start.types.getAll().metrics.length).toBe(23); + expect(start.types.getAll().metrics.length).toBe(24); }); test('registers custom agg types', () => { @@ -71,7 +71,7 @@ describe('AggsService - public', () => { const start = service.start(startDeps); expect(start.types.getAll().buckets.length).toBe(17); expect(start.types.getAll().buckets.some(({ name }) => name === 'foo')).toBe(true); - expect(start.types.getAll().metrics.length).toBe(24); + expect(start.types.getAll().metrics.length).toBe(25); expect(start.types.getAll().metrics.some(({ name }) => name === 'bar')).toBe(true); }); }); @@ -79,11 +79,10 @@ describe('AggsService - public', () => { describe('start()', () => { test('exposes proper contract', () => { const start = service.start(startDeps); - expect(Object.keys(start).length).toBe(4); + expect(Object.keys(start).length).toBe(3); expect(start).toHaveProperty('calculateAutoTimeExpression'); expect(start).toHaveProperty('createAggConfigs'); expect(start).toHaveProperty('types'); - expect(start).toHaveProperty('datatableUtilities'); }); test('types registry returns initialized agg types', () => { diff --git a/src/plugins/data/public/search/aggs/aggs_service.ts b/src/plugins/data/public/search/aggs/aggs_service.ts index 4907c3bcbad2..99930a95831e 100644 --- a/src/plugins/data/public/search/aggs/aggs_service.ts +++ b/src/plugins/data/public/search/aggs/aggs_service.ts @@ -91,13 +91,11 @@ export class AggsService { public start({ fieldFormats, uiSettings, indexPatterns }: AggsStartDependencies): AggsStart { const isDefaultTimezone = () => uiSettings.isDefault('dateFormat:tz'); - const { calculateAutoTimeExpression, datatableUtilities, types } = this.aggsCommonService.start( - { - getConfig: this.getConfig!, - getIndexPattern: indexPatterns.get, - isDefaultTimezone, - } - ); + const { calculateAutoTimeExpression, types } = this.aggsCommonService.start({ + getConfig: this.getConfig!, + getIndexPattern: indexPatterns.get, + isDefaultTimezone, + }); const aggTypesDependencies: AggTypesDependencies = { calculateBounds: this.calculateBounds, @@ -137,7 +135,6 @@ export class AggsService { return { calculateAutoTimeExpression, - datatableUtilities, createAggConfigs: (indexPattern, configStates = []) => { return new AggConfigs(indexPattern, configStates, { typesRegistry }); }, diff --git a/src/plugins/data/public/search/aggs/mocks.ts b/src/plugins/data/public/search/aggs/mocks.ts index fb50058f0834..c45d024384ba 100644 --- a/src/plugins/data/public/search/aggs/mocks.ts +++ b/src/plugins/data/public/search/aggs/mocks.ts @@ -56,11 +56,6 @@ export const searchAggsSetupMock = (): AggsSetup => ({ export const searchAggsStartMock = (): AggsStart => ({ calculateAutoTimeExpression: getCalculateAutoTimeExpression(getConfig), - datatableUtilities: { - isFilterable: jest.fn(), - getAggConfig: jest.fn(), - getIndexPattern: jest.fn(), - }, createAggConfigs: jest.fn().mockImplementation((indexPattern, configStates = [], schemas) => { return new AggConfigs(indexPattern, configStates, { typesRegistry: mockAggTypesRegistry(), diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts index 968dd870489f..f1e2e903cadd 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts @@ -119,6 +119,7 @@ describe('SearchInterceptor', () => { }), uiSettings: mockCoreSetup.uiSettings, http: mockCoreSetup.http, + executionContext: mockCoreSetup.executionContext, session: sessionService, theme: themeServiceMock.createSetupContract(), }); @@ -543,7 +544,12 @@ describe('SearchInterceptor', () => { .catch(() => {}); expect(fetchMock.mock.calls[0][0]).toEqual( expect.objectContaining({ - options: { sessionId, isStored: true, isRestore: true, strategy: 'ese' }, + options: { + sessionId, + isStored: true, + isRestore: true, + strategy: 'ese', + }, }) ); diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts index 7dc1ce6dee07..251e191d589e 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts @@ -61,6 +61,7 @@ import { SearchAbortController } from './search_abort_controller'; export interface SearchInterceptorDeps { bfetch: BfetchPublicSetup; http: CoreSetup['http']; + executionContext: CoreSetup['executionContext']; uiSettings: CoreSetup['uiSettings']; startServices: Promise<[CoreStart, any, unknown]>; toasts: ToastsSetup; @@ -297,10 +298,14 @@ export class SearchInterceptor { } }) as Promise; } else { + const { executionContext, ...rest } = options || {}; return this.batchedFetch( { request, - options: this.getSerializableOptions(options), + options: this.getSerializableOptions({ + ...rest, + executionContext: this.deps.executionContext.withGlobalContext(executionContext), + }), }, abortSignal ); diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index 961599de713d..b21ad44c7bd6 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -89,7 +89,7 @@ export class SearchService implements Plugin { constructor(private initializerContext: PluginInitializerContext) {} public setup( - { http, getStartServices, notifications, uiSettings, theme }: CoreSetup, + { http, getStartServices, notifications, uiSettings, executionContext, theme }: CoreSetup, { bfetch, expressions, usageCollection, nowProvider }: SearchServiceSetupDependencies ): ISearchSetup { this.usageCollector = createUsageCollector(getStartServices, usageCollection); @@ -108,6 +108,7 @@ export class SearchService implements Plugin { this.searchInterceptor = new SearchInterceptor({ bfetch, toasts: notifications.toasts, + executionContext, http, uiSettings, startServices: getStartServices(), diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index e2e7c6b222b9..bfc35b8f39c5 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -14,6 +14,7 @@ import { ExpressionsSetup } from 'src/plugins/expressions/public'; import { DataViewsPublicPluginStart } from 'src/plugins/data_views/public'; import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; import { FieldFormatsSetup, FieldFormatsStart } from 'src/plugins/field_formats/public'; +import { DatatableUtilitiesService } from '../common'; import { AutocompleteSetup, AutocompleteStart } from './autocomplete'; import { createFiltersFromRangeSelectAction, createFiltersFromValueClickAction } from './actions'; import type { ISearchSetup, ISearchStart } from './search'; @@ -83,6 +84,12 @@ export interface DataPublicPluginStart { * {@link DataViewsContract} */ dataViews: DataViewsContract; + + /** + * Datatable type utility functions. + */ + datatableUtilities: DatatableUtilitiesService; + /** * index patterns service * {@link DataViewsContract} diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx index e5da2bb9f089..75d22137937e 100644 --- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx @@ -284,7 +284,6 @@ export const QueryBarTopRow = React.memo( } const wrapperClasses = classNames('kbnQueryBar__datePickerWrapper', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'kbnQueryBar__datePickerWrapper-isHidden': isQueryInputFocused, }); diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index e33977f8d904..1ee3a97f45a3 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -399,7 +399,6 @@ class SearchBarUI extends Component { let filterBar; if (this.shouldRenderFilterBar()) { const filterGroupClasses = classNames('globalFilterGroup__wrapper', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'globalFilterGroup__wrapper-isVisible': this.state.isFiltersVisible, }); diff --git a/src/plugins/data/server/datatable_utilities/datatable_utilities_service.ts b/src/plugins/data/server/datatable_utilities/datatable_utilities_service.ts new file mode 100644 index 000000000000..3909003cd4d2 --- /dev/null +++ b/src/plugins/data/server/datatable_utilities/datatable_utilities_service.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { + ElasticsearchClient, + SavedObjectsClientContract, + UiSettingsServiceStart, +} from 'src/core/server'; +import type { FieldFormatsStart } from 'src/plugins/field_formats/server'; +import type { IndexPatternsServiceStart } from 'src/plugins/data_views/server'; +import { DatatableUtilitiesService as DatatableUtilitiesServiceCommon } from '../../common'; +import type { AggsStart } from '../search'; + +export class DatatableUtilitiesService { + constructor( + private aggs: AggsStart, + private dataViews: IndexPatternsServiceStart, + private fieldFormats: FieldFormatsStart, + private uiSettings: UiSettingsServiceStart + ) { + this.asScopedToClient = this.asScopedToClient.bind(this); + } + + async asScopedToClient( + savedObjectsClient: SavedObjectsClientContract, + elasticsearchClient: ElasticsearchClient + ): Promise { + const aggs = await this.aggs.asScopedToClient(savedObjectsClient, elasticsearchClient); + const dataViews = await this.dataViews.dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient + ); + const uiSettings = this.uiSettings.asScopedToClient(savedObjectsClient); + const fieldFormats = await this.fieldFormats.fieldFormatServiceFactory(uiSettings); + + return new DatatableUtilitiesServiceCommon(aggs, dataViews, fieldFormats); + } +} diff --git a/src/plugins/data/server/datatable_utilities/index.ts b/src/plugins/data/server/datatable_utilities/index.ts new file mode 100644 index 000000000000..34df78137510 --- /dev/null +++ b/src/plugins/data/server/datatable_utilities/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 * from './datatable_utilities_service'; diff --git a/src/plugins/data/server/datatable_utilities/mock.ts b/src/plugins/data/server/datatable_utilities/mock.ts new file mode 100644 index 000000000000..9ec069fda7ab --- /dev/null +++ b/src/plugins/data/server/datatable_utilities/mock.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createDatatableUtilitiesMock as createDatatableUtilitiesCommonMock } from '../../common/mocks'; +import type { DatatableUtilitiesService } from './datatable_utilities_service'; + +export function createDatatableUtilitiesMock(): jest.Mocked { + return { + asScopedToClient: jest.fn(createDatatableUtilitiesCommonMock), + } as unknown as jest.Mocked; +} diff --git a/src/plugins/data/server/mocks.ts b/src/plugins/data/server/mocks.ts index 6fd670d869c2..355e809888bd 100644 --- a/src/plugins/data/server/mocks.ts +++ b/src/plugins/data/server/mocks.ts @@ -16,6 +16,7 @@ import { createFieldFormatsStartMock, } from '../../field_formats/server/mocks'; import { createIndexPatternsStartMock } from './data_views/mocks'; +import { createDatatableUtilitiesMock } from './datatable_utilities/mock'; import { DataRequestHandlerContext } from './search'; import { AutocompleteSetup } from './autocomplete'; @@ -42,6 +43,7 @@ function createStartContract() { */ fieldFormats: createFieldFormatsStartMock(), indexPatterns: createIndexPatternsStartMock(), + datatableUtilities: createDatatableUtilitiesMock(), }; } diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index ab8e28755cd7..9d5b3792da56 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -11,6 +11,7 @@ import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { PluginStart as DataViewsServerPluginStart } from 'src/plugins/data_views/server'; import { ConfigSchema } from '../config'; +import { DatatableUtilitiesService } from './datatable_utilities'; import type { ISearchSetup, ISearchStart, SearchEnhancements } from './search'; import { SearchService } from './search/search_service'; import { QueryService } from './query/query_service'; @@ -48,6 +49,11 @@ export interface DataPluginStart { */ fieldFormats: FieldFormatsStart; indexPatterns: DataViewsServerPluginStart; + + /** + * Datatable type utility functions. + */ + datatableUtilities: DatatableUtilitiesService; } export interface DataPluginSetupDependencies { @@ -115,10 +121,19 @@ export class DataServerPlugin } public start(core: CoreStart, { fieldFormats, dataViews }: DataPluginStartDependencies) { + const search = this.searchService.start(core, { fieldFormats, indexPatterns: dataViews }); + const datatableUtilities = new DatatableUtilitiesService( + search.aggs, + dataViews, + fieldFormats, + core.uiSettings + ); + return { + datatableUtilities, + search, fieldFormats, indexPatterns: dataViews, - search: this.searchService.start(core, { fieldFormats, indexPatterns: dataViews }), }; } diff --git a/src/plugins/data/server/search/aggs/aggs_service.ts b/src/plugins/data/server/search/aggs/aggs_service.ts index e65c6d413497..808c0e9cc849 100644 --- a/src/plugins/data/server/search/aggs/aggs_service.ts +++ b/src/plugins/data/server/search/aggs/aggs_service.ts @@ -72,17 +72,13 @@ export class AggsService { }; const isDefaultTimezone = () => getConfig('dateFormat:tz') === 'Browser'; - const { calculateAutoTimeExpression, datatableUtilities, types } = - this.aggsCommonService.start({ - getConfig, - getIndexPattern: ( - await indexPatterns.indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient - ) - ).get, - isDefaultTimezone, - }); + const { calculateAutoTimeExpression, types } = this.aggsCommonService.start({ + getConfig, + getIndexPattern: ( + await indexPatterns.indexPatternsServiceFactory(savedObjectsClient, elasticsearchClient) + ).get, + isDefaultTimezone, + }); const aggTypesDependencies: AggTypesDependencies = { calculateBounds: this.calculateBounds, @@ -118,7 +114,6 @@ export class AggsService { return { calculateAutoTimeExpression, - datatableUtilities, createAggConfigs: (indexPattern, configStates = []) => { return new AggConfigs(indexPattern, configStates, { typesRegistry }); }, diff --git a/src/plugins/data/server/search/aggs/mocks.ts b/src/plugins/data/server/search/aggs/mocks.ts index 3644a3c13c48..301bc3e5e124 100644 --- a/src/plugins/data/server/search/aggs/mocks.ts +++ b/src/plugins/data/server/search/aggs/mocks.ts @@ -58,11 +58,6 @@ export const searchAggsSetupMock = (): AggsSetup => ({ const commonStartMock = (): AggsCommonStart => ({ calculateAutoTimeExpression: getCalculateAutoTimeExpression(getConfig), - datatableUtilities: { - getIndexPattern: jest.fn(), - getAggConfig: jest.fn(), - isFilterable: jest.fn(), - }, createAggConfigs: jest.fn().mockImplementation((indexPattern, configStates = [], schemas) => { return new AggConfigs(indexPattern, configStates, { typesRegistry: mockAggTypesRegistry(), diff --git a/src/plugins/data/server/search/routes/bsearch.ts b/src/plugins/data/server/search/routes/bsearch.ts index 314de4254851..25b1bd0d7009 100644 --- a/src/plugins/data/server/search/routes/bsearch.ts +++ b/src/plugins/data/server/search/routes/bsearch.ts @@ -9,6 +9,7 @@ import { catchError, first } from 'rxjs/operators'; import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import type { ExecutionContextSetup } from 'src/core/server'; +import apm from 'elastic-apm-node'; import { IKibanaSearchRequest, IKibanaSearchResponse, @@ -33,9 +34,10 @@ export function registerBsearchRoute( */ onBatchItem: async ({ request: requestData, options }) => { const { executionContext, ...restOptions } = options || {}; + return executionContextService.withContext(executionContext, () => { + apm.addLabels(executionContextService.getAsLabels()); - return executionContextService.withContext(executionContext, () => - search + return search .search(requestData, restOptions) .pipe( first(), @@ -49,8 +51,8 @@ export function registerBsearchRoute( }; }) ) - .toPromise() - ); + .toPromise(); + }); }, }; }); diff --git a/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx b/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx index 0c5381e99b8f..deab9b8cf824 100644 --- a/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx +++ b/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx @@ -143,14 +143,13 @@ const IndexPatternEditorFlyoutContentComponent = ({ isRollupIndex: () => false, pattern: '*', showAllIndices: allowHidden, - searchClient, }).then((dataSources) => { setAllSources(dataSources); const matchedSet = getMatchedIndices(dataSources, [], [], allowHidden); setMatchedIndices(matchedSet); setIsLoadingSources(false); }); - }, [http, allowHidden, searchClient]); + }, [http, allowHidden]); // loading list of index patterns useEffect(() => { @@ -407,7 +406,6 @@ const loadMatchedIndices = memoizeOne( isRollupIndex, pattern: query, showAllIndices: allowHidden, - searchClient, }); indexRequests.push(exactMatchedQuery); // provide default value when not making a request for the partialMatchQuery @@ -418,14 +416,12 @@ const loadMatchedIndices = memoizeOne( isRollupIndex, pattern: query, showAllIndices: allowHidden, - searchClient, }); const partialMatchQuery = getIndices({ http, isRollupIndex, pattern: `${query}*`, showAllIndices: allowHidden, - searchClient, }); indexRequests.push(exactMatchQuery); diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx index e5f4e6cec057..f34ec90e414b 100644 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx +++ b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx @@ -66,7 +66,6 @@ export const EmptyPrompts: FC = ({ allSources, onCancel, children, loadSo isRollupIndex: () => false, pattern: '*:*', showAllIndices: false, - searchClient, }).then((dataSources) => { setRemoteClustersExist(!!dataSources.filter(removeAliases).length); }); diff --git a/src/plugins/data_view_editor/public/components/flyout_panels/flyout_panel.tsx b/src/plugins/data_view_editor/public/components/flyout_panels/flyout_panel.tsx index 8f9193a47327..19e5b8211672 100644 --- a/src/plugins/data_view_editor/public/components/flyout_panels/flyout_panel.tsx +++ b/src/plugins/data_view_editor/public/components/flyout_panels/flyout_panel.tsx @@ -51,7 +51,6 @@ export const Panel: React.FC> = ({ hasFooter: false, }); - /* eslint-disable @typescript-eslint/naming-convention */ const classes = classnames('fieldEditor__flyoutPanel', className, { 'fieldEditor__flyoutPanel--pageBackground': backgroundColor === 'euiPageBackground', 'fieldEditor__flyoutPanel--emptyShade': backgroundColor === 'euiEmptyShade', @@ -59,7 +58,6 @@ export const Panel: React.FC> = ({ 'fieldEditor__flyoutPanel--rightBorder': border === 'right', 'fieldEditor__flyoutPanel--withContent': config.hasContent, }); - /* eslint-enable @typescript-eslint/naming-convention */ const { addPanel } = useFlyoutPanelsContext(); diff --git a/src/plugins/data_view_editor/public/lib/get_indices.test.ts b/src/plugins/data_view_editor/public/lib/get_indices.test.ts index d65cd27e090b..bc4e6b089606 100644 --- a/src/plugins/data_view_editor/public/lib/get_indices.test.ts +++ b/src/plugins/data_view_editor/public/lib/get_indices.test.ts @@ -6,15 +6,9 @@ * Side Public License, v 1. */ -import { - getIndices, - getIndicesViaSearch, - responseToItemArray, - dedupeMatchedItems, -} from './get_indices'; +import { getIndices, responseToItemArray } from './get_indices'; import { httpServiceMock } from '../../../../core/public/mocks'; -import { ResolveIndexResponseItemIndexAttrs, MatchedItem } from '../types'; -import { Observable } from 'rxjs'; +import { ResolveIndexResponseItemIndexAttrs } from '../types'; export const successfulResolveResponse = { indices: [ @@ -38,41 +32,8 @@ export const successfulResolveResponse = { ], }; -const successfulSearchResponse = { - isPartial: false, - isRunning: false, - rawResponse: { - aggregations: { - indices: { - buckets: [{ key: 'kibana_sample_data_ecommerce' }, { key: '.kibana_1' }], - }, - }, - }, -}; - -const partialSearchResponse = { - isPartial: true, - isRunning: true, - rawResponse: { - hits: { - total: 2, - hits: [], - }, - }, -}; - -const errorSearchResponse = { - isPartial: true, - isRunning: false, -}; - const isRollupIndex = () => false; const getTags = () => []; -const searchClient = () => - new Observable((observer) => { - observer.next(successfulSearchResponse); - observer.complete(); - }) as any; const http = httpServiceMock.createStartContract(); http.get.mockResolvedValue(successfulResolveResponse); @@ -83,7 +44,6 @@ describe('getIndices', () => { const result = await getIndices({ http, pattern: 'kibana', - searchClient: uncalledSearchClient, isRollupIndex, }); expect(http.get).toHaveBeenCalled(); @@ -95,42 +55,23 @@ describe('getIndices', () => { it('should make two calls in cross cluser case', async () => { http.get.mockResolvedValue(successfulResolveResponse); - const result = await getIndices({ http, pattern: '*:kibana', searchClient, isRollupIndex }); + const result = await getIndices({ http, pattern: '*:kibana', isRollupIndex }); expect(http.get).toHaveBeenCalled(); - expect(result.length).toBe(4); + expect(result.length).toBe(3); expect(result[0].name).toBe('f-alias'); expect(result[1].name).toBe('foo'); - expect(result[2].name).toBe('kibana_sample_data_ecommerce'); - expect(result[3].name).toBe('remoteCluster1:bar-01'); + expect(result[2].name).toBe('remoteCluster1:bar-01'); }); it('should ignore ccs query-all', async () => { - expect((await getIndices({ http, pattern: '*:', searchClient, isRollupIndex })).length).toBe(0); + expect((await getIndices({ http, pattern: '*:', isRollupIndex })).length).toBe(0); }); it('should ignore a single comma', async () => { - expect((await getIndices({ http, pattern: ',', searchClient, isRollupIndex })).length).toBe(0); - expect((await getIndices({ http, pattern: ',*', searchClient, isRollupIndex })).length).toBe(0); - expect( - (await getIndices({ http, pattern: ',foobar', searchClient, isRollupIndex })).length - ).toBe(0); - }); - - it('should work with partial responses', async () => { - const searchClientPartialResponse = () => - new Observable((observer) => { - observer.next(partialSearchResponse); - observer.next(successfulSearchResponse); - observer.complete(); - }) as any; - const result = await getIndices({ - http, - pattern: '*:kibana', - searchClient: searchClientPartialResponse, - isRollupIndex, - }); - expect(result.length).toBe(4); + expect((await getIndices({ http, pattern: ',', isRollupIndex })).length).toBe(0); + expect((await getIndices({ http, pattern: ',*', isRollupIndex })).length).toBe(0); + expect((await getIndices({ http, pattern: ',foobar', isRollupIndex })).length).toBe(0); }); it('response object to item array', () => { @@ -162,33 +103,12 @@ describe('getIndices', () => { expect(responseToItemArray({}, getTags)).toEqual([]); }); - it('matched items are deduped', () => { - const setA = [{ name: 'a' }, { name: 'b' }] as MatchedItem[]; - const setB = [{ name: 'b' }, { name: 'c' }] as MatchedItem[]; - expect(dedupeMatchedItems(setA, setB)).toHaveLength(3); - }); - describe('errors', () => { it('should handle thrown errors gracefully', async () => { http.get.mockImplementationOnce(() => { throw new Error('Test error'); }); - const result = await getIndices({ http, pattern: 'kibana', searchClient, isRollupIndex }); - expect(result.length).toBe(0); - }); - - it('getIndicesViaSearch should handle error responses gracefully', async () => { - const searchClientErrorResponse = () => - new Observable((observer) => { - observer.next(errorSearchResponse); - observer.complete(); - }) as any; - const result = await getIndicesViaSearch({ - pattern: '*:kibana', - searchClient: searchClientErrorResponse, - showAllIndices: false, - isRollupIndex, - }); + const result = await getIndices({ http, pattern: 'kibana', isRollupIndex }); expect(result.length).toBe(0); }); }); diff --git a/src/plugins/data_view_editor/public/lib/get_indices.ts b/src/plugins/data_view_editor/public/lib/get_indices.ts index de93e2c17793..2bf97dd31e45 100644 --- a/src/plugins/data_view_editor/public/lib/get_indices.ts +++ b/src/plugins/data_view_editor/public/lib/get_indices.ts @@ -8,18 +8,11 @@ import { sortBy } from 'lodash'; import { HttpStart } from 'kibana/public'; -import { map, filter } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { Tag, INDEX_PATTERN_TYPE } from '../types'; import { MatchedItem, ResolveIndexResponse, ResolveIndexResponseItemIndexAttrs } from '../types'; -import { MAX_SEARCH_SIZE } from '../constants'; -import { - DataPublicPluginStart, - IEsSearchResponse, - isErrorResponse, - isCompleteResponse, -} from '../../../data/public'; +import { IEsSearchResponse } from '../../../data/public'; const aliasLabel = i18n.translate('indexPatternEditor.aliasLabel', { defaultMessage: 'Alias' }); const dataStreamLabel = i18n.translate('indexPatternEditor.dataStreamLabel', { @@ -78,42 +71,6 @@ export const searchResponseToArray = } }; -export const getIndicesViaSearch = async ({ - pattern, - searchClient, - showAllIndices, - isRollupIndex, -}: { - pattern: string; - searchClient: DataPublicPluginStart['search']['search']; - showAllIndices: boolean; - isRollupIndex: (indexName: string) => boolean; -}): Promise => - searchClient({ - params: { - ignoreUnavailable: true, - expand_wildcards: showAllIndices ? 'all' : 'open', - index: pattern, - body: { - size: 0, // no hits - aggs: { - indices: { - terms: { - field: '_index', - size: MAX_SEARCH_SIZE, - }, - }, - }, - }, - }, - }) - .pipe( - filter((resp) => isCompleteResponse(resp) || isErrorResponse(resp)), - map(searchResponseToArray(getIndexTags(isRollupIndex), showAllIndices)) - ) - .toPromise() - .catch(() => []); - export const getIndicesViaResolve = async ({ http, pattern, @@ -137,48 +94,18 @@ export const getIndicesViaResolve = async ({ } }); -/** - * Takes two MatchedItem[]s and returns a merged set, with the second set prrioritized over the first based on name - * - * @param matchedA - * @param matchedB - */ - -export const dedupeMatchedItems = (matchedA: MatchedItem[], matchedB: MatchedItem[]) => { - const mergedMatchedItems = matchedA.reduce((col, item) => { - col[item.name] = item; - return col; - }, {} as Record); - - matchedB.reduce((col, item) => { - col[item.name] = item; - return col; - }, mergedMatchedItems); - - return Object.values(mergedMatchedItems).sort((a, b) => { - if (a.name > b.name) return 1; - if (b.name > a.name) return -1; - - return 0; - }); -}; - export async function getIndices({ http, pattern: rawPattern = '', showAllIndices = false, - searchClient, isRollupIndex, }: { http: HttpStart; pattern: string; showAllIndices?: boolean; - searchClient: DataPublicPluginStart['search']['search']; isRollupIndex: (indexName: string) => boolean; }): Promise { const pattern = rawPattern.trim(); - const isCCS = pattern.indexOf(':') !== -1; - const requests: Array> = []; // Searching for `*:` fails for CCS environments. The search request // is worthless anyways as the we should only send a request @@ -198,33 +125,12 @@ export async function getIndices({ return []; } - const promiseResolve = getIndicesViaResolve({ + return getIndicesViaResolve({ http, pattern, showAllIndices, isRollupIndex, }).catch(() => []); - requests.push(promiseResolve); - - if (isCCS) { - // CCS supports ±1 major version. We won't be able to expect resolve endpoint to exist until v9 - const promiseSearch = getIndicesViaSearch({ - pattern, - searchClient, - showAllIndices, - isRollupIndex, - }).catch(() => []); - requests.push(promiseSearch); - } - - const responses = await Promise.all(requests); - - if (responses.length === 2) { - const [resolveResponse, searchResponse] = responses; - return dedupeMatchedItems(searchResponse, resolveResponse); - } else { - return responses[0]; - } } export const responseToItemArray = ( diff --git a/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts b/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts index 2403ae8c12e5..c3175d7097a3 100644 --- a/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts +++ b/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts @@ -302,7 +302,7 @@ describe('Field editor Preview panel', () => { title: 'First doc - title', }, documentId: '001', - index: 'testIndex', + index: 'testIndexPattern', script: { source: 'echo("hello")', }, diff --git a/src/plugins/data_view_field_editor/public/components/flyout_panels/flyout_panel.tsx b/src/plugins/data_view_field_editor/public/components/flyout_panels/flyout_panel.tsx index fcd09ce6a38d..599505ac4ddb 100644 --- a/src/plugins/data_view_field_editor/public/components/flyout_panels/flyout_panel.tsx +++ b/src/plugins/data_view_field_editor/public/components/flyout_panels/flyout_panel.tsx @@ -56,7 +56,6 @@ export const Panel: React.FC> = ({ const [styles, setStyles] = useState({}); - /* eslint-disable @typescript-eslint/naming-convention */ const classes = classnames('fieldEditor__flyoutPanel', className, { 'fieldEditor__flyoutPanel--pageBackground': backgroundColor === 'euiPageBackground', 'fieldEditor__flyoutPanel--emptyShade': backgroundColor === 'euiEmptyShade', @@ -64,7 +63,6 @@ export const Panel: React.FC> = ({ 'fieldEditor__flyoutPanel--rightBorder': border === 'right', 'fieldEditor__flyoutPanel--withContent': config.hasContent, }); - /* eslint-enable @typescript-eslint/naming-convention */ const { addPanel } = useFlyoutPanelsContext(); diff --git a/src/plugins/data_view_field_editor/public/components/preview/field_list/field_list_item.tsx b/src/plugins/data_view_field_editor/public/components/preview/field_list/field_list_item.tsx index eb886da62123..7631e251e203 100644 --- a/src/plugins/data_view_field_editor/public/components/preview/field_list/field_list_item.tsx +++ b/src/plugins/data_view_field_editor/public/components/preview/field_list/field_list_item.tsx @@ -42,12 +42,10 @@ export const PreviewListItem: React.FC = ({ const [isPreviewImageModalVisible, setIsPreviewImageModalVisible] = useState(false); - /* eslint-disable @typescript-eslint/naming-convention */ const classes = classnames('indexPatternFieldEditor__previewFieldList__item', { 'indexPatternFieldEditor__previewFieldList__item--highlighted': isFromScript, 'indexPatternFieldEditor__previewFieldList__item--pinned': isPinned, }); - /* eslint-enable @typescript-eslint/naming-convention */ const doesContainImage = formattedValue?.includes(' { const currentApiCall = ++previewCount.current; const response = await getFieldPreview({ - index: currentDocIndex!, + index: dataView.title, document: document!, context: `${type!}_field` as PainlessExecuteContext, script: script!, @@ -384,7 +384,6 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { type, script, document, - currentDocIndex, currentDocId, getFieldPreview, notifications.toasts, @@ -392,6 +391,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { allParamsDefined, scriptEditorValidation, hasSomeParamsChanged, + dataView.title, ]); const goToNextDoc = useCallback(() => { diff --git a/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx b/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx index 583a8255c02a..ed050261f127 100644 --- a/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx +++ b/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx @@ -5,16 +5,14 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { css } from '@emotion/react'; + import { EuiBadge, EuiButton, - EuiButtonEmpty, + EuiLink, EuiInMemoryTable, EuiPageHeader, EuiSpacer, - EuiFlexItem, - EuiFlexGroup, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { RouteComponentProps, withRouter, useLocation } from 'react-router-dom'; @@ -53,10 +51,6 @@ const securityDataView = i18n.translate( const securitySolution = 'security-solution'; -const flexItemStyles = css` - justify-content: center; -`; - interface Props extends RouteComponentProps { canSave: boolean; showCreateDialog?: boolean; @@ -140,25 +134,19 @@ export const IndexPatternTable = ({ defaultMessage: 'Name', }), render: (name: string, dataView: IndexPatternTableItem) => ( - <> - - - - {name} - - - {dataView?.id?.indexOf(securitySolution) === 0 && ( - - {securityDataView} - - )} - {dataView?.tags?.map(({ key: tagKey, name: tagName }) => ( - - {tagName} - - ))} - - +
+ {name} + {dataView?.id?.indexOf(securitySolution) === 0 && ( + <> +  {securityDataView} + + )} + {dataView?.tags?.map(({ key: tagKey, name: tagName }) => ( + <> +  {tagName} + + ))} +
), dataType: 'string' as const, sortable: ({ sort }: { sort: string }) => sort, diff --git a/src/plugins/data_view_management/public/components/index_pattern_table/spaces_list.tsx b/src/plugins/data_view_management/public/components/index_pattern_table/spaces_list.tsx index c17e174ef1dd..be7bdb1b31fd 100644 --- a/src/plugins/data_view_management/public/components/index_pattern_table/spaces_list.tsx +++ b/src/plugins/data_view_management/public/components/index_pattern_table/spaces_list.tsx @@ -8,7 +8,6 @@ import React, { FC, useState } from 'react'; -import { EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { SpacesPluginStart, @@ -33,7 +32,6 @@ export const SpacesList: FC = ({ spacesApi, spaceIds, id, title, refresh function onClose() { setShowFlyout(false); - refresh(); } const LazySpaceList = spacesApi.ui.components.getSpaceList; @@ -47,18 +45,18 @@ export const SpacesList: FC = ({ spacesApi, spaceIds, id, title, refresh title, noun, }, + onUpdate: refresh, onClose, }; return ( <> - setShowFlyout(true)} - style={{ height: 'auto' }} - data-test-subj="manageSpacesButton" - > - - + setShowFlyout(true)} + /> {showFlyout && } ); diff --git a/src/plugins/data_views/common/data_views/data_views.ts b/src/plugins/data_views/common/data_views/data_views.ts index 2e31ed793c3d..04c1fd98a0f6 100644 --- a/src/plugins/data_views/common/data_views/data_views.ts +++ b/src/plugins/data_views/common/data_views/data_views.ts @@ -424,7 +424,7 @@ export class DataViewsService { ); if (!savedObject.version) { - throw new SavedObjectNotFound(DATA_VIEW_SAVED_OBJECT_TYPE, id, 'management/kibana/dataViews'); + throw new SavedObjectNotFound('data view', id, 'management/kibana/dataViews'); } return this.initFromSavedObject(savedObject); diff --git a/src/plugins/dev_tools/public/application.tsx b/src/plugins/dev_tools/public/application.tsx index a3ec8fc0a9af..bcfde68abd99 100644 --- a/src/plugins/dev_tools/public/application.tsx +++ b/src/plugins/dev_tools/public/application.tsx @@ -15,8 +15,14 @@ import { I18nProvider } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { euiThemeVars } from '@kbn/ui-theme'; -import { ApplicationStart, ChromeStart, ScopedHistory, CoreTheme } from 'src/core/public'; -import { KibanaThemeProvider } from '../../kibana_react/public'; +import type { + ApplicationStart, + ChromeStart, + ScopedHistory, + CoreTheme, + ExecutionContextStart, +} from 'src/core/public'; +import { KibanaThemeProvider, useExecutionContext } from '../../kibana_react/public'; import type { DocTitleService, BreadcrumbService } from './services'; import { DevToolApp } from './dev_tool'; @@ -24,6 +30,7 @@ import { DevToolApp } from './dev_tool'; export interface AppServices { docTitleService: DocTitleService; breadcrumbService: BreadcrumbService; + executionContext: ExecutionContextStart; } interface DevToolsWrapperProps { @@ -64,6 +71,11 @@ function DevToolsWrapper({ breadcrumbService.setBreadcrumbs(activeDevTool.title); }, [activeDevTool, docTitleService, breadcrumbService]); + useExecutionContext(appServices.executionContext, { + type: 'application', + page: activeDevTool.id, + }); + return (
diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index 1876bf278513..ee729c8f4400 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -61,7 +61,7 @@ export class DevToolsPlugin implements Plugin { element.classList.add('devAppWrapper'); const [core] = await getStartServices(); - const { application, chrome } = core; + const { application, chrome, executionContext } = core; this.docTitleService.setup(chrome.docTitle.change); this.breadcrumbService.setup(chrome.setBreadcrumbs); @@ -69,6 +69,7 @@ export class DevToolsPlugin implements Plugin { const appServices = { breadcrumbService: this.breadcrumbService, docTitleService: this.docTitleService, + executionContext, }; const { renderApp } = await import('./application'); 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 c9089a6c1111..cf1e7a98e01a 100644 --- a/src/plugins/discover/public/application/context/context_app.test.tsx +++ b/src/plugins/discover/public/application/context/context_app.test.tsx @@ -46,6 +46,9 @@ describe('ContextApp test', () => { toastNotifications: { addDanger: () => {} }, navigation: mockNavigationPlugin, core: { + executionContext: { + set: jest.fn(), + }, notifications: { toasts: [] }, theme: { theme$: themeServiceMock.createStartContract().theme$ }, }, diff --git a/src/plugins/discover/public/application/context/context_app.tsx b/src/plugins/discover/public/application/context/context_app.tsx index 8d2a6b2c0481..dcf1c6b11e68 100644 --- a/src/plugins/discover/public/application/context/context_app.tsx +++ b/src/plugins/discover/public/application/context/context_app.tsx @@ -25,6 +25,7 @@ import { ContextAppContent } from './context_app_content'; import { SurrDocType } from './services/context'; import { DocViewFilterFn } from '../../services/doc_views/doc_views_types'; import { useDiscoverServices } from '../../utils/use_discover_services'; +import { useExecutionContext } from '../../../../kibana_react/public'; import { generateFilters } from '../../../../data/public'; const ContextAppContentMemoized = memo(ContextAppContent); @@ -36,11 +37,17 @@ export interface ContextAppProps { export const ContextApp = ({ indexPattern, anchorId }: ContextAppProps) => { const services = useDiscoverServices(); - const { uiSettings, capabilities, indexPatterns, navigation, filterManager } = services; + const { uiSettings, capabilities, indexPatterns, navigation, filterManager, core } = services; const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]); const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); + useExecutionContext(core.executionContext, { + type: 'application', + page: 'context', + id: indexPattern.id || '', + }); + /** * Context app state */ diff --git a/src/plugins/discover/public/application/doc/single_doc_route.tsx b/src/plugins/discover/public/application/doc/single_doc_route.tsx index d11c6bdca76a..e2bdc6dc799e 100644 --- a/src/plugins/discover/public/application/doc/single_doc_route.tsx +++ b/src/plugins/discover/public/application/doc/single_doc_route.tsx @@ -16,6 +16,7 @@ import { withQueryParams } from '../../utils/with_query_params'; import { useMainRouteBreadcrumb } from '../../utils/use_navigation_props'; import { Doc } from './components/doc'; import { useDiscoverServices } from '../../utils/use_discover_services'; +import { useExecutionContext } from '../../../../kibana_react/public'; export interface SingleDocRouteProps { /** @@ -31,11 +32,17 @@ export interface DocUrlParams { const SingleDoc = ({ id }: SingleDocRouteProps) => { const services = useDiscoverServices(); - const { chrome, timefilter } = services; + const { chrome, timefilter, core } = services; const { indexPatternId, index } = useParams(); const breadcrumb = useMainRouteBreadcrumb(); + useExecutionContext(core.executionContext, { + type: 'application', + page: 'single-doc', + id: indexPatternId, + }); + useEffect(() => { chrome.setBreadcrumbs([ ...getRootBreadcrumbs(breadcrumb), diff --git a/src/plugins/discover/public/application/main/discover_main_route.tsx b/src/plugins/discover/public/application/main/discover_main_route.tsx index d5950085b94c..dcf229d36b1e 100644 --- a/src/plugins/discover/public/application/main/discover_main_route.tsx +++ b/src/plugins/discover/public/application/main/discover_main_route.tsx @@ -24,6 +24,7 @@ import { LoadingIndicator } from '../../components/common/loading_indicator'; import { DiscoverError } from '../../components/common/error_alert'; import { useDiscoverServices } from '../../utils/use_discover_services'; import { getUrlTracker } from '../../kibana_services'; +import { useExecutionContext } from '../../../../kibana_react/public'; const DiscoverMainAppMemoized = memo(DiscoverMainApp); @@ -50,6 +51,12 @@ export function DiscoverMainRoute() { >([]); const { id } = useParams(); + useExecutionContext(core.executionContext, { + type: 'application', + page: 'app', + id: id || 'new', + }); + const navigateToOverview = useCallback(() => { core.application.navigateToApp('kibanaOverview', { path: '#' }); }, [core.application]); diff --git a/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts b/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts index 4c68eff54f57..b1f736fa4b22 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts @@ -117,7 +117,7 @@ describe('test fetchCharts', () => { }); }); - test('fetch$ is called with execution context containing savedSearch id', async () => { + test('fetch$ is called with request specific execution context', async () => { const fetch$Mock = jest.fn().mockReturnValue(of(requestResult)); savedSearchMockWithTimeField.searchSource.fetch$ = fetch$Mock; @@ -126,10 +126,6 @@ describe('test fetchCharts', () => { expect(fetch$Mock.mock.calls[0][0].executionContext).toMatchInlineSnapshot(` Object { "description": "fetch chart data and total hits", - "id": "the-saved-search-id-with-timefield", - "name": "discover", - "type": "application", - "url": "/", } `); }); diff --git a/src/plugins/discover/public/application/main/utils/fetch_chart.ts b/src/plugins/discover/public/application/main/utils/fetch_chart.ts index 00cb9c43cacc..1ea2594a89d9 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_chart.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_chart.ts @@ -40,11 +40,7 @@ export function fetchChart( const chartAggConfigs = updateSearchSource(searchSource, interval, data); const executionContext = { - type: 'application', - name: 'discover', description: 'fetch chart data and total hits', - url: window.location.pathname, - id: savedSearch.id ?? '', }; const fetch$ = searchSource diff --git a/src/plugins/discover/public/application/main/utils/fetch_documents.test.ts b/src/plugins/discover/public/application/main/utils/fetch_documents.test.ts index 000d3282c38b..1e73f5de3a3f 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_documents.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_documents.test.ts @@ -57,10 +57,6 @@ describe('test fetchDocuments', () => { expect(fetch$Mock.mock.calls[0][0].executionContext).toMatchInlineSnapshot(` Object { "description": "fetch total hits", - "id": "the-saved-search-id", - "name": "discover", - "type": "application", - "url": "/", } `); }); diff --git a/src/plugins/discover/public/application/main/utils/fetch_documents.ts b/src/plugins/discover/public/application/main/utils/fetch_documents.ts index dbf972265547..8338839e8b0a 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_documents.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_documents.ts @@ -32,11 +32,7 @@ export const fetchDocuments = ( } const executionContext = { - type: 'application', - name: 'discover', description: 'fetch documents', - url: window.location.pathname, - id: savedSearch.id ?? '', }; const fetch$ = searchSource diff --git a/src/plugins/discover/public/application/main/utils/fetch_total_hits.test.ts b/src/plugins/discover/public/application/main/utils/fetch_total_hits.test.ts index ba7b6a765aa2..a5485c1a2e2e 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_total_hits.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_total_hits.test.ts @@ -51,10 +51,6 @@ describe('test fetchTotalHits', () => { expect(fetch$Mock.mock.calls[0][0].executionContext).toMatchInlineSnapshot(` Object { "description": "fetch total hits", - "id": "the-saved-search-id", - "name": "discover", - "type": "application", - "url": "/", } `); }); diff --git a/src/plugins/discover/public/application/main/utils/fetch_total_hits.ts b/src/plugins/discover/public/application/main/utils/fetch_total_hits.ts index af2d55e23cf3..e696570f05cf 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_total_hits.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_total_hits.ts @@ -30,11 +30,7 @@ export function fetchTotalHits( } const executionContext = { - type: 'application', - name: 'discover', description: 'fetch total hits', - url: window.location.pathname, - id: savedSearch.id ?? '', }; const fetch$ = searchSource diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.tsx index a8881b37301e..de8abf8c0113 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.tsx @@ -166,7 +166,6 @@ export function DiscoverGridDocumentToolbarBtn({ className={classNames({ // eslint-disable-next-line @typescript-eslint/naming-convention euiDataGrid__controlBtn: true, - // eslint-disable-next-line @typescript-eslint/naming-convention 'euiDataGrid__controlBtn--active': isFilterActive, })} > diff --git a/src/plugins/discover/public/components/doc_table/components/table_row.tsx b/src/plugins/discover/public/components/doc_table/components/table_row.tsx index fde6edfb69cd..31b1c8f4913d 100644 --- a/src/plugins/discover/public/components/doc_table/components/table_row.tsx +++ b/src/plugins/discover/public/components/doc_table/components/table_row.tsx @@ -57,7 +57,6 @@ export const TableRow = ({ ); const [open, setOpen] = useState(false); const docTableRowClassName = classNames('kbnDocTable__row', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'kbnDocTable__row--highlight': row.isAnchor, }); const anchorDocTableRowSubj = row.isAnchor ? ' docTableAnchorRow' : ''; diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index a032126396d4..39549cb4623c 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -9,7 +9,8 @@ import uuid from 'uuid'; import { isEqual, xor } from 'lodash'; import { merge, Subscription } from 'rxjs'; -import { startWith, pairwise } from 'rxjs/operators'; +import { pairwise, take, delay } from 'rxjs/operators'; + import { Embeddable, EmbeddableInput, @@ -19,7 +20,13 @@ import { IEmbeddable, isErrorEmbeddable, } from '../embeddables'; -import { IContainer, ContainerInput, ContainerOutput, PanelState } from './i_container'; +import { + IContainer, + ContainerInput, + ContainerOutput, + PanelState, + EmbeddableContainerSettings, +} from './i_container'; import { PanelNotFoundError, EmbeddableFactoryNotFoundError } from '../errors'; import { EmbeddableStart } from '../../plugin'; import { isSavedObjectEmbeddableInput } from '../../../common/lib/saved_object_embeddable'; @@ -39,19 +46,29 @@ export abstract class Container< [key: string]: IEmbeddable | ErrorEmbeddable; } = {}; - private subscription: Subscription; + private subscription: Subscription | undefined; constructor( input: TContainerInput, output: TContainerOutput, protected readonly getFactory: EmbeddableStart['getEmbeddableFactory'], - parent?: Container + parent?: IContainer, + settings?: EmbeddableContainerSettings ) { super(input, output, parent); this.getFactory = getFactory; // Currently required for using in storybook due to https://github.com/storybookjs/storybook/issues/13834 + + // initialize all children on the first input change. Delayed so it is run after the constructor is finished. + this.getInput$() + .pipe(delay(0), take(1)) + .subscribe(() => { + this.initializeChildEmbeddables(input, settings); + }); + + // on all subsequent input changes, diff and update children on changes. this.subscription = this.getInput$() - // At each update event, get both the previous and current state - .pipe(startWith(input), pairwise()) + // At each update event, get both the previous and current state. + .pipe(pairwise()) .subscribe(([{ panels: prevPanels }, { panels: currentPanels }]) => { this.maybeUpdateChildren(currentPanels, prevPanels); }); @@ -166,7 +183,7 @@ export abstract class Container< public destroy() { super.destroy(); Object.values(this.children).forEach((child) => child.destroy()); - this.subscription.unsubscribe(); + this.subscription?.unsubscribe(); } public async untilEmbeddableLoaded( @@ -264,6 +281,33 @@ export abstract class Container< */ protected abstract getInheritedInput(id: string): TChildInput; + private async initializeChildEmbeddables( + initialInput: TContainerInput, + initializeSettings?: EmbeddableContainerSettings + ) { + let initializeOrder = Object.keys(initialInput.panels); + if (initializeSettings?.childIdInitializeOrder) { + const initializeOrderSet = new Set(); + for (const id of [...initializeSettings.childIdInitializeOrder, ...initializeOrder]) { + if (!initializeOrderSet.has(id) && Boolean(this.getInput().panels[id])) { + initializeOrderSet.add(id); + } + } + initializeOrder = Array.from(initializeOrderSet); + } + + for (const id of initializeOrder) { + if (initializeSettings?.initializeSequentially) { + const embeddable = await this.onPanelAdded(initialInput.panels[id]); + if (embeddable && !isErrorEmbeddable(embeddable)) { + await this.untilEmbeddableLoaded(id); + } + } else { + this.onPanelAdded(initialInput.panels[id]); + } + } + } + private async createAndSaveEmbeddable< TEmbeddableInput extends EmbeddableInput = EmbeddableInput, TEmbeddable extends IEmbeddable = IEmbeddable diff --git a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx index 07867476508a..e4dfc8ab58d8 100644 --- a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx @@ -7,7 +7,6 @@ */ import React from 'react'; -import { nextTick } from '@kbn/test-jest-helpers'; import { EmbeddableChildPanel } from './embeddable_child_panel'; import { CONTACT_CARD_EMBEDDABLE } from '../test_samples/embeddables/contact_card/contact_card_embeddable_factory'; import { SlowContactCardEmbeddableFactory } from '../test_samples/embeddables/contact_card/slow_contact_card_embeddable_factory'; @@ -60,7 +59,7 @@ test('EmbeddableChildPanel renders an embeddable when it is done loading', async /> ); - await nextTick(); + await new Promise((r) => setTimeout(r, 1)); component.update(); // Due to the way embeddables mount themselves on the dom node, they are not forced to be @@ -89,7 +88,7 @@ test(`EmbeddableChildPanel renders an error message if the factory doesn't exist ); - await nextTick(); + await new Promise((r) => setTimeout(r, 1)); component.update(); expect( diff --git a/src/plugins/embeddable/public/lib/containers/i_container.ts b/src/plugins/embeddable/public/lib/containers/i_container.ts index c4593cac4969..f082000b38d4 100644 --- a/src/plugins/embeddable/public/lib/containers/i_container.ts +++ b/src/plugins/embeddable/public/lib/containers/i_container.ts @@ -28,6 +28,17 @@ export interface ContainerInput extends EmbeddableInput }; } +export interface EmbeddableContainerSettings { + /** + * If true, the container will wait for each embeddable to load after creation before loading the next embeddable. + */ + initializeSequentially?: boolean; + /** + * Initialise children in the order specified. If an ID does not match it will be skipped and if a child is not included it will be initialized in the default order after the list of provided IDs. + */ + childIdInitializeOrder?: string[]; +} + export interface IContainer< Inherited extends {} = {}, I extends ContainerInput = ContainerInput, diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index c8c0aea80e1e..59f02107b0f2 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -89,6 +89,15 @@ export abstract class Embeddable< ); } + public refreshInputFromParent() { + if (!this.parent) return; + // Make sure this panel hasn't been removed immediately after it was added, but before it finished loading. + if (!this.parent.getInput().panels[this.id]) return; + + const newInput = this.parent.getInputForChild(this.id); + this.onResetInput(newInput); + } + public getIsContainer(): this is IContainer { return this.isContainer === true; } diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 0ee288cb4b8c..a7a7372a3b55 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -189,4 +189,6 @@ export interface IEmbeddable< * Used to diff explicit embeddable input */ getExplicitInputIsEqual(lastInput: Partial): Promise; + + refreshInputFromParent(): void; } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index e288b0ee3274..eb4b6988b0b7 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -131,7 +131,6 @@ export function PanelHeader({ const showPanelBar = !isViewMode || badges.length > 0 || notifications.length > 0 || showTitle || description; const classes = classNames('embPanel__header', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'embPanel__header--floater': !showPanelBar, }); const placeholderTitle = i18n.translate('embeddableApi.panel.placeholderTitle', { diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx index 136ee5f4996b..18dc9778bc3e 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx @@ -12,6 +12,7 @@ import { I18nProvider } from '@kbn/i18n-react'; import { Container, ViewMode, ContainerInput } from '../..'; import { HelloWorldContainerComponent } from './hello_world_container_component'; import { EmbeddableStart } from '../../../plugin'; +import { EmbeddableContainerSettings } from '../../containers/i_container'; export const HELLO_WORLD_CONTAINER = 'HELLO_WORLD_CONTAINER'; @@ -40,9 +41,16 @@ export class HelloWorldContainer extends Container, - private readonly options: HelloWorldContainerOptions + private readonly options: HelloWorldContainerOptions, + initializeSettings?: EmbeddableContainerSettings ) { - super(input, { embeddableLoaded: {} }, options.getEmbeddableFactory || (() => undefined)); + super( + input, + { embeddableLoaded: {} }, + options.getEmbeddableFactory || (() => undefined), + undefined, + initializeSettings + ); } public getInheritedInput(id: string) { diff --git a/src/plugins/embeddable/public/tests/container.test.ts b/src/plugins/embeddable/public/tests/container.test.ts index f83316b11eb1..3e071594eea3 100644 --- a/src/plugins/embeddable/public/tests/container.test.ts +++ b/src/plugins/embeddable/public/tests/container.test.ts @@ -40,10 +40,12 @@ import { coreMock } from '../../../../core/public/mocks'; import { testPlugin } from './test_plugin'; import { of } from './helpers'; import { createEmbeddablePanelMock } from '../mocks'; +import { EmbeddableContainerSettings } from '../lib/containers/i_container'; async function creatHelloWorldContainerAndEmbeddable( containerInput: ContainerInput = { id: 'hello', panels: {} }, - embeddableInput = {} + embeddableInput = {}, + settings?: EmbeddableContainerSettings ) { const coreSetup = coreMock.createSetup(); const coreStart = coreMock.createStart(); @@ -69,10 +71,14 @@ async function creatHelloWorldContainerAndEmbeddable( application: coreStart.application, }); - const container = new HelloWorldContainer(containerInput, { - getEmbeddableFactory: start.getEmbeddableFactory, - panelComponent: testPanel, - }); + const container = new HelloWorldContainer( + containerInput, + { + getEmbeddableFactory: start.getEmbeddableFactory, + panelComponent: testPanel, + }, + settings + ); const embeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, @@ -87,23 +93,123 @@ async function creatHelloWorldContainerAndEmbeddable( return { container, embeddable, coreSetup, coreStart, setup, start, uiActions, testPanel }; } -test('Container initializes embeddables', async (done) => { - const { container } = await creatHelloWorldContainerAndEmbeddable({ - id: 'hello', - panels: { - '123': { - explicitInput: { id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }, +describe('container initialization', () => { + const panels = { + '123': { + explicitInput: { id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, }, - }); + '456': { + explicitInput: { id: '456' }, + type: CONTACT_CARD_EMBEDDABLE, + }, + '789': { + explicitInput: { id: '789' }, + type: CONTACT_CARD_EMBEDDABLE, + }, + }; - if (container.getOutput().embeddableLoaded['123']) { + const expectEmbeddableLoaded = (container: HelloWorldContainer, id: string) => { + expect(container.getOutput().embeddableLoaded['123']).toBe(true); const embeddable = container.getChild('123'); expect(embeddable).toBeDefined(); expect(embeddable.id).toBe('123'); + }; + + it('initializes embeddables', async (done) => { + const { container } = await creatHelloWorldContainerAndEmbeddable({ + id: 'hello', + panels, + }); + + expectEmbeddableLoaded(container, '123'); + expectEmbeddableLoaded(container, '456'); + expectEmbeddableLoaded(container, '789'); done(); - } + }); + + it('initializes embeddables in order', async (done) => { + const childIdInitializeOrder = ['456', '123', '789']; + const { container } = await creatHelloWorldContainerAndEmbeddable( + { + id: 'hello', + panels, + }, + {}, + { childIdInitializeOrder } + ); + + const onPanelAddedMock = jest.spyOn( + container as unknown as { onPanelAdded: () => {} }, + 'onPanelAdded' + ); + + await new Promise((r) => setTimeout(r, 1)); + for (const [index, orderedId] of childIdInitializeOrder.entries()) { + expect(onPanelAddedMock).toHaveBeenNthCalledWith(index + 1, { + explicitInput: { id: orderedId }, + type: 'CONTACT_CARD_EMBEDDABLE', + }); + } + done(); + }); + + it('initializes embeddables in order with partial order arg', async (done) => { + const childIdInitializeOrder = ['789', 'idontexist']; + const { container } = await creatHelloWorldContainerAndEmbeddable( + { + id: 'hello', + panels, + }, + {}, + { childIdInitializeOrder } + ); + const expectedInitializeOrder = ['789', '123', '456']; + + const onPanelAddedMock = jest.spyOn( + container as unknown as { onPanelAdded: () => {} }, + 'onPanelAdded' + ); + + await new Promise((r) => setTimeout(r, 1)); + for (const [index, orderedId] of expectedInitializeOrder.entries()) { + expect(onPanelAddedMock).toHaveBeenNthCalledWith(index + 1, { + explicitInput: { id: orderedId }, + type: 'CONTACT_CARD_EMBEDDABLE', + }); + } + done(); + }); + + it('initializes embeddables in order, awaiting each', async (done) => { + const childIdInitializeOrder = ['456', '123', '789']; + const { container } = await creatHelloWorldContainerAndEmbeddable( + { + id: 'hello', + panels, + }, + {}, + { childIdInitializeOrder, initializeSequentially: true } + ); + const onPanelAddedMock = jest.spyOn( + container as unknown as { onPanelAdded: () => {} }, + 'onPanelAdded' + ); + + const untilEmbeddableLoadedMock = jest.spyOn(container, 'untilEmbeddableLoaded'); + + await new Promise((r) => setTimeout(r, 10)); + + for (const [index, orderedId] of childIdInitializeOrder.entries()) { + await container.untilEmbeddableLoaded(orderedId); + expect(onPanelAddedMock).toHaveBeenNthCalledWith(index + 1, { + explicitInput: { id: orderedId }, + type: 'CONTACT_CARD_EMBEDDABLE', + }); + expect(untilEmbeddableLoadedMock).toHaveBeenCalledWith(orderedId); + } + done(); + }); }); test('Container.addNewEmbeddable', async () => { diff --git a/src/plugins/expressions/common/execution/execution.test.ts b/src/plugins/expressions/common/execution/execution.test.ts index 6dab9f7c683e..90b2d590bcf6 100644 --- a/src/plugins/expressions/common/execution/execution.test.ts +++ b/src/plugins/expressions/common/execution/execution.test.ts @@ -731,6 +731,64 @@ describe('Execution', () => { }); }); + describe('when arguments are not valid', () => { + let executor: ReturnType; + + beforeEach(() => { + const validateArg: ExpressionFunctionDefinition< + 'validateArg', + unknown, + { arg: unknown }, + unknown + > = { + name: 'validateArg', + args: { + arg: { + help: '', + multi: true, + options: ['valid'], + }, + }, + help: '', + fn: () => 'something', + }; + executor = createUnitTestExecutor(); + executor.registerFunction(validateArg); + }); + + it('errors when argument is invalid', async () => { + const { result } = await executor.run('validateArg arg="invalid"', null).toPromise(); + + expect(result).toMatchObject({ + type: 'error', + error: { + message: + "[validateArg] > Value 'invalid' is not among the allowed options for argument 'arg': 'valid'", + }, + }); + }); + + it('errors when at least one value is invalid', async () => { + const { result } = await executor + .run('validateArg arg="valid" arg="invalid"', null) + .toPromise(); + + expect(result).toMatchObject({ + type: 'error', + error: { + message: + "[validateArg] > Value 'invalid' is not among the allowed options for argument 'arg': 'valid'", + }, + }); + }); + + it('does not error when argument is valid', async () => { + const { result } = await executor.run('validateArg arg="valid"', null).toPromise(); + + expect(result).toBe('something'); + }); + }); + describe('debug mode', () => { test('can execute expression in debug mode', async () => { const execution = createExecution('add val=1 | add val=2 | add val=3', {}, true); diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts index 6bd3d5584ff7..7cc7fa771d8c 100644 --- a/src/plugins/expressions/common/execution/execution.ts +++ b/src/plugins/expressions/common/execution/execution.ts @@ -38,7 +38,7 @@ import { } from '../ast'; import { ExecutionContext, DefaultInspectorAdapters } from './types'; import { getType, Datatable } from '../expression_types'; -import { ExpressionFunction } from '../expression_functions'; +import type { ExpressionFunction, ExpressionFunctionParameter } from '../expression_functions'; import { getByAlias } from '../util/get_by_alias'; import { ExecutionContract } from './execution_contract'; import { ExpressionExecutionParams } from '../service'; @@ -442,6 +442,16 @@ export class Execution< throw new Error(`Can not cast '${fromTypeName}' to any of '${toTypeNames.join(', ')}'`); } + validate(value: Type, argDef: ExpressionFunctionParameter): void { + if (argDef.options?.length && !argDef.options.includes(value)) { + throw new Error( + `Value '${value}' is not among the allowed options for argument '${ + argDef.name + }': '${argDef.options.join("', '")}'` + ); + } + } + // Processes the multi-valued AST argument values into arguments that can be passed to the function resolveArgs( fnDef: Fn, @@ -498,7 +508,8 @@ export class Execution< } return this.cast(output, argDefs[argName].types); - }) + }), + tap((value) => this.validate(value, argDefs[argName])) ) ) ); diff --git a/src/plugins/field_formats/common/converters/color.test.ts b/src/plugins/field_formats/common/converters/color.test.ts index 994c6d802ae3..617945b3d1cd 100644 --- a/src/plugins/field_formats/common/converters/color.test.ts +++ b/src/plugins/field_formats/common/converters/color.test.ts @@ -112,5 +112,24 @@ describe('Color Format', () => { expect(converter('<', HTML_CONTEXT_TYPE)).toBe('<'); }); + + test('returns original value (escaped) on regex with syntax error', () => { + const colorer = new ColorFormat( + { + fieldType: 'string', + colors: [ + { + regex: 'nogroup(', + text: 'blue', + background: 'yellow', + }, + ], + }, + jest.fn() + ); + const converter = colorer.getConverterFor(HTML_CONTEXT_TYPE) as Function; + + expect(converter('<', HTML_CONTEXT_TYPE)).toBe('<'); + }); }); }); diff --git a/src/plugins/field_formats/common/converters/color.tsx b/src/plugins/field_formats/common/converters/color.tsx index 3e5ff9783047..197468fc1592 100644 --- a/src/plugins/field_formats/common/converters/color.tsx +++ b/src/plugins/field_formats/common/converters/color.tsx @@ -35,7 +35,11 @@ export class ColorFormat extends FieldFormat { switch (this.param('fieldType')) { case 'string': return findLast(this.param('colors'), (colorParam: typeof DEFAULT_CONVERTER_COLOR) => { - return new RegExp(colorParam.regex).test(val as string); + try { + return new RegExp(colorParam.regex).test(val as string); + } catch (e) { + return false; + } }); case 'number': diff --git a/src/plugins/home/kibana.json b/src/plugins/home/kibana.json index d8c09ab5e80c..02b33e814e2a 100644 --- a/src/plugins/home/kibana.json +++ b/src/plugins/home/kibana.json @@ -8,6 +8,6 @@ "server": true, "ui": true, "requiredPlugins": ["dataViews", "share", "urlForwarding"], - "optionalPlugins": ["usageCollection", "telemetry", "customIntegrations"], + "optionalPlugins": ["usageCollection", "customIntegrations"], "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap index ab6ad1b6cc0c..43d8f935221b 100644 --- a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap +++ b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap @@ -374,202 +374,6 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to false when t exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when there are no index patterns 1`] = ` `; diff --git a/src/plugins/home/public/application/components/__snapshots__/sample_data_view_data_button.test.js.snap b/src/plugins/home/public/application/components/__snapshots__/sample_data_view_data_button.test.js.snap index 6855e3a327c7..d9e341394ee0 100644 --- a/src/plugins/home/public/application/components/__snapshots__/sample_data_view_data_button.test.js.snap +++ b/src/plugins/home/public/application/components/__snapshots__/sample_data_view_data_button.test.js.snap @@ -30,6 +30,7 @@ exports[`should render popover when appLinks is not empty 1`] = ` "id": 0, "items": Array [ Object { + "data-test-subj": "viewSampleDataSetecommerce-dashboard", "href": "root/app/dashboards#/view/722b74f0-b882-11e8-a6d9-e546fe2bba5f", "icon":
`; - -exports[`should render a Welcome screen with the telemetry disclaimer 1`] = ` - -
-
-
- - - - - -

- -

-
- -
-
-
- - - - - - - - - - - - - - - - - -
-
-
-`; - -exports[`should render a Welcome screen with the telemetry disclaimer when optIn is false 1`] = ` - -
-
-
- - - - - -

- -

-
- -
-
-
- - - - - - - - - - - - - - - - - -
-
-
-`; - -exports[`should render a Welcome screen with the telemetry disclaimer when optIn is true 1`] = ` - -
-
-
- - - - - -

- -

-
- -
-
-
- - - - - - - - - - - - - - - - - -
-
-
-`; - -exports[`should render a Welcome screen without the opt in/out link when user cannot change optIn status 1`] = ` - -
-
-
- - - - - -

- -

-
- -
-
-
- - - - - - - - - - - - - -
-
-
-`; diff --git a/src/plugins/home/public/application/components/home.test.tsx b/src/plugins/home/public/application/components/home.test.tsx index 9983afa3d4d6..f27a286488c2 100644 --- a/src/plugins/home/public/application/components/home.test.tsx +++ b/src/plugins/home/public/application/components/home.test.tsx @@ -12,7 +12,6 @@ import type { HomeProps } from './home'; import { Home } from './home'; import { FeatureCatalogueCategory } from '../../services'; -import { telemetryPluginMock } from '../../../../telemetry/public/mocks'; import { Welcome } from './welcome'; let mockHasIntegrationsPermission = true; @@ -57,7 +56,6 @@ describe('home', () => { setItem: jest.fn(), }, urlBasePath: 'goober', - telemetry: telemetryPluginMock.createStartContract(), addBasePath(url) { return `base_path/${url}`; }, diff --git a/src/plugins/home/public/application/components/home.tsx b/src/plugins/home/public/application/components/home.tsx index fdf04ea58065..1fb0b3c790ab 100644 --- a/src/plugins/home/public/application/components/home.tsx +++ b/src/plugins/home/public/application/components/home.tsx @@ -10,7 +10,6 @@ import React, { Component } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { METRIC_TYPE } from '@kbn/analytics'; import { i18n } from '@kbn/i18n'; -import type { TelemetryPluginStart } from 'src/plugins/telemetry/public'; import { KibanaPageTemplate, OverviewPageFooter } from '../../../../kibana_react/public'; import { HOME_APP_BASE_PATH } from '../../../common/constants'; import type { FeatureCatalogueEntry, FeatureCatalogueSolution } from '../../services'; @@ -29,7 +28,6 @@ export interface HomeProps { solutions: FeatureCatalogueSolution[]; localStorage: Storage; urlBasePath: string; - telemetry: TelemetryPluginStart; hasUserDataView: () => Promise; } @@ -175,13 +173,7 @@ export class Home extends Component { } private renderWelcome() { - return ( - this.skipWelcome()} - urlBasePath={this.props.urlBasePath} - telemetry={this.props.telemetry} - /> - ); + return this.skipWelcome()} urlBasePath={this.props.urlBasePath} />; } public render() { diff --git a/src/plugins/home/public/application/components/home_app.js b/src/plugins/home/public/application/components/home_app.js index 62df479ecbfd..a634573aaf21 100644 --- a/src/plugins/home/public/application/components/home_app.js +++ b/src/plugins/home/public/application/components/home_app.js @@ -26,7 +26,6 @@ export function HomeApp({ directories, solutions }) { getBasePath, addBasePath, environmentService, - telemetry, dataViewsService, } = getServices(); const environment = environmentService.getEnvironment(); @@ -75,7 +74,6 @@ export function HomeApp({ directories, solutions }) { solutions={solutions} localStorage={localStorage} urlBasePath={getBasePath()} - telemetry={telemetry} hasUserDataView={() => dataViewsService.hasUserDataView()} /> diff --git a/src/plugins/home/public/application/components/sample_data_view_data_button.js b/src/plugins/home/public/application/components/sample_data_view_data_button.js index 701b84611d16..dbf7548ca71d 100644 --- a/src/plugins/home/public/application/components/sample_data_view_data_button.js +++ b/src/plugins/home/public/application/components/sample_data_view_data_button.js @@ -69,6 +69,9 @@ export class SampleDataViewDataButton extends React.Component { onClick: createAppNavigationHandler(path), }; }); + + /** @typedef {import('@elastic/eui').EuiContextMenuProps['panels']} EuiContextMenuPanels */ + /** @type {EuiContextMenuPanels} */ const panels = [ { id: 0, @@ -80,6 +83,7 @@ export class SampleDataViewDataButton extends React.Component { icon: , href: prefixedDashboardPath, onClick: createAppNavigationHandler(dashboardPath), + 'data-test-subj': `viewSampleDataSet${this.props.id}-dashboard`, }, ...additionalItems, ], diff --git a/src/plugins/home/public/application/components/welcome.test.mocks.ts b/src/plugins/home/public/application/components/welcome.test.mocks.ts new file mode 100644 index 000000000000..fc9854bae319 --- /dev/null +++ b/src/plugins/home/public/application/components/welcome.test.mocks.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { welcomeServiceMock } from '../../services/welcome/welcome_service.mocks'; + +jest.doMock('../kibana_services', () => ({ + getServices: () => ({ + addBasePath: (path: string) => `root${path}`, + trackUiMetric: () => {}, + welcomeService: welcomeServiceMock.create(), + }), +})); diff --git a/src/plugins/home/public/application/components/welcome.test.tsx b/src/plugins/home/public/application/components/welcome.test.tsx index b042a91e58c9..3400b4bfcdb7 100644 --- a/src/plugins/home/public/application/components/welcome.test.tsx +++ b/src/plugins/home/public/application/components/welcome.test.tsx @@ -8,58 +8,11 @@ import React from 'react'; import { shallow } from 'enzyme'; +import './welcome.test.mocks'; import { Welcome } from './welcome'; -import { telemetryPluginMock } from '../../../../telemetry/public/mocks'; -jest.mock('../kibana_services', () => ({ - getServices: () => ({ - addBasePath: (path: string) => `root${path}`, - trackUiMetric: () => {}, - }), -})); - -test('should render a Welcome screen with the telemetry disclaimer', () => { - const telemetry = telemetryPluginMock.createStartContract(); - const component = shallow( {}} telemetry={telemetry} />); - - expect(component).toMatchSnapshot(); -}); - -test('should render a Welcome screen with the telemetry disclaimer when optIn is true', () => { - const telemetry = telemetryPluginMock.createStartContract(); - telemetry.telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); - const component = shallow( {}} telemetry={telemetry} />); - - expect(component).toMatchSnapshot(); -}); - -test('should render a Welcome screen with the telemetry disclaimer when optIn is false', () => { - const telemetry = telemetryPluginMock.createStartContract(); - telemetry.telemetryService.getIsOptedIn = jest.fn().mockReturnValue(false); - const component = shallow( {}} telemetry={telemetry} />); - - expect(component).toMatchSnapshot(); -}); - -test('should render a Welcome screen with no telemetry disclaimer', () => { +test('should render a Welcome screen', () => { const component = shallow( {}} />); expect(component).toMatchSnapshot(); }); - -test('should render a Welcome screen without the opt in/out link when user cannot change optIn status', () => { - const telemetry = telemetryPluginMock.createStartContract(); - telemetry.telemetryService.getCanChangeOptInStatus = jest.fn().mockReturnValue(false); - const component = shallow( {}} telemetry={telemetry} />); - - expect(component).toMatchSnapshot(); -}); - -test('fires opt-in seen when mounted', () => { - const telemetry = telemetryPluginMock.createStartContract(); - const mockSetOptedInNoticeSeen = jest.fn(); - telemetry.telemetryNotifications.setOptedInNoticeSeen = mockSetOptedInNoticeSeen; - shallow( {}} telemetry={telemetry} />); - - expect(mockSetOptedInNoticeSeen).toHaveBeenCalled(); -}); diff --git a/src/plugins/home/public/application/components/welcome.tsx b/src/plugins/home/public/application/components/welcome.tsx index 1a6251ebdca1..9efa6d356d97 100644 --- a/src/plugins/home/public/application/components/welcome.tsx +++ b/src/plugins/home/public/application/components/welcome.tsx @@ -12,27 +12,17 @@ * in Elasticsearch. */ -import React, { Fragment } from 'react'; -import { - EuiLink, - EuiTextColor, - EuiTitle, - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiPortal, -} from '@elastic/eui'; +import React from 'react'; +import { EuiTitle, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPortal } from '@elastic/eui'; import { METRIC_TYPE } from '@kbn/analytics'; import { FormattedMessage } from '@kbn/i18n-react'; import { getServices } from '../kibana_services'; -import { TelemetryPluginStart } from '../../../../telemetry/public'; import { SampleDataCard } from './sample_data'; + interface Props { urlBasePath: string; onSkip: () => void; - telemetry?: TelemetryPluginStart; } /** @@ -47,7 +37,7 @@ export class Welcome extends React.Component { } }; - private redirecToAddData() { + private redirectToAddData() { this.services.application.navigateToApp('integrations', { path: '/browse' }); } @@ -58,68 +48,23 @@ export class Welcome extends React.Component { private onSampleDataConfirm = () => { this.services.trackUiMetric(METRIC_TYPE.CLICK, 'sampleDataConfirm'); - this.redirecToAddData(); + this.redirectToAddData(); }; componentDidMount() { - const { telemetry } = this.props; + const { welcomeService } = this.services; this.services.trackUiMetric(METRIC_TYPE.LOADED, 'welcomeScreenMount'); - if (telemetry?.telemetryService.userCanChangeSettings) { - telemetry.telemetryNotifications.setOptedInNoticeSeen(); - } document.addEventListener('keydown', this.hideOnEsc); + welcomeService.onRendered(); } componentWillUnmount() { document.removeEventListener('keydown', this.hideOnEsc); } - private renderTelemetryEnabledOrDisabledText = () => { - const { telemetry } = this.props; - if ( - !telemetry || - !telemetry.telemetryService.userCanChangeSettings || - !telemetry.telemetryService.getCanChangeOptInStatus() - ) { - return null; - } - - const isOptedIn = telemetry.telemetryService.getIsOptedIn(); - if (isOptedIn) { - return ( - - - - - - - ); - } else { - return ( - - - - - - - ); - } - }; - render() { - const { urlBasePath, telemetry } = this.props; + const { urlBasePath } = this.props; + const { welcomeService } = this.services; return (
@@ -146,28 +91,7 @@ export class Welcome extends React.Component { onDecline={this.onSampleDataDecline} /> - {!!telemetry && ( - - - - - - - {this.renderTelemetryEnabledOrDisabledText()} - - - - )} + {welcomeService.renderTelemetryNotice()}
diff --git a/src/plugins/home/public/application/kibana_services.ts b/src/plugins/home/public/application/kibana_services.ts index fdd325df96ac..3ccfd9413a88 100644 --- a/src/plugins/home/public/application/kibana_services.ts +++ b/src/plugins/home/public/application/kibana_services.ts @@ -17,7 +17,6 @@ import { ApplicationStart, } from 'kibana/public'; import { UiCounterMetricType } from '@kbn/analytics'; -import { TelemetryPluginStart } from '../../../telemetry/public'; import { UrlForwardingStart } from '../../../url_forwarding/public'; import { DataViewsContract } from '../../../data_views/public'; import { TutorialService } from '../services/tutorials'; @@ -26,6 +25,7 @@ import { FeatureCatalogueRegistry } from '../services/feature_catalogue'; import { EnvironmentService } from '../services/environment'; import { ConfigSchema } from '../../config'; import { SharePluginSetup } from '../../../share/public'; +import type { WelcomeService } from '../services/welcome'; export interface HomeKibanaServices { dataViewsService: DataViewsContract; @@ -46,9 +46,9 @@ export interface HomeKibanaServices { docLinks: DocLinksStart; addBasePath: (url: string) => string; environmentService: EnvironmentService; - telemetry?: TelemetryPluginStart; tutorialService: TutorialService; addDataService: AddDataService; + welcomeService: WelcomeService; } let services: HomeKibanaServices | null = null; diff --git a/src/plugins/home/public/index.ts b/src/plugins/home/public/index.ts index 009382eee000..3450f4f9d2ca 100644 --- a/src/plugins/home/public/index.ts +++ b/src/plugins/home/public/index.ts @@ -27,6 +27,8 @@ export type { TutorialVariables, TutorialDirectoryHeaderLinkComponent, TutorialModuleNoticeComponent, + WelcomeRenderTelemetryNotice, + WelcomeServiceSetup, } from './services'; export { INSTRUCTION_VARIANT, getDisplayText } from '../common/instruction_variant'; diff --git a/src/plugins/home/public/mocks.ts b/src/plugins/home/public/mocks.ts index 10c186ee3f4e..42e489dea9d2 100644 --- a/src/plugins/home/public/mocks.ts +++ b/src/plugins/home/public/mocks.ts @@ -8,16 +8,17 @@ import { featureCatalogueRegistryMock } from './services/feature_catalogue/feature_catalogue_registry.mock'; import { environmentServiceMock } from './services/environment/environment.mock'; -import { configSchema } from '../config'; import { tutorialServiceMock } from './services/tutorials/tutorial_service.mock'; import { addDataServiceMock } from './services/add_data/add_data_service.mock'; +import { HomePublicPluginSetup } from './plugin'; +import { welcomeServiceMock } from './services/welcome/welcome_service.mocks'; -const createSetupContract = () => ({ +const createSetupContract = (): jest.Mocked => ({ featureCatalogue: featureCatalogueRegistryMock.createSetup(), environment: environmentServiceMock.createSetup(), tutorials: tutorialServiceMock.createSetup(), addData: addDataServiceMock.createSetup(), - config: configSchema.validate({}), + welcomeScreen: welcomeServiceMock.createSetup(), }); export const homePluginMock = { diff --git a/src/plugins/home/public/plugin.test.mocks.ts b/src/plugins/home/public/plugin.test.mocks.ts index c3e3c50a2fe0..22d314cbd6d0 100644 --- a/src/plugins/home/public/plugin.test.mocks.ts +++ b/src/plugins/home/public/plugin.test.mocks.ts @@ -10,14 +10,17 @@ import { featureCatalogueRegistryMock } from './services/feature_catalogue/featu import { environmentServiceMock } from './services/environment/environment.mock'; import { tutorialServiceMock } from './services/tutorials/tutorial_service.mock'; import { addDataServiceMock } from './services/add_data/add_data_service.mock'; +import { welcomeServiceMock } from './services/welcome/welcome_service.mocks'; export const registryMock = featureCatalogueRegistryMock.create(); export const environmentMock = environmentServiceMock.create(); export const tutorialMock = tutorialServiceMock.create(); export const addDataMock = addDataServiceMock.create(); +export const welcomeMock = welcomeServiceMock.create(); jest.doMock('./services', () => ({ FeatureCatalogueRegistry: jest.fn(() => registryMock), EnvironmentService: jest.fn(() => environmentMock), TutorialService: jest.fn(() => tutorialMock), AddDataService: jest.fn(() => addDataMock), + WelcomeService: jest.fn(() => welcomeMock), })); diff --git a/src/plugins/home/public/plugin.test.ts b/src/plugins/home/public/plugin.test.ts index 990f0dce54a0..57a1f5ec112a 100644 --- a/src/plugins/home/public/plugin.test.ts +++ b/src/plugins/home/public/plugin.test.ts @@ -79,5 +79,18 @@ describe('HomePublicPlugin', () => { expect(setup).toHaveProperty('tutorials'); expect(setup.tutorials).toHaveProperty('setVariable'); }); + + test('wires up and returns welcome service', async () => { + const setup = await new HomePublicPlugin(mockInitializerContext).setup( + coreMock.createSetup() as any, + { + share: mockShare, + urlForwarding: urlForwardingPluginMock.createSetupContract(), + } + ); + expect(setup).toHaveProperty('welcomeScreen'); + expect(setup.welcomeScreen).toHaveProperty('registerOnRendered'); + expect(setup.welcomeScreen).toHaveProperty('registerTelemetryNoticeRenderer'); + }); }); }); diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index 1ece73e71f39..af43e56a1d75 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -25,11 +25,12 @@ import { TutorialServiceSetup, AddDataService, AddDataServiceSetup, + WelcomeService, + WelcomeServiceSetup, } from './services'; import { ConfigSchema } from '../config'; import { setServices } from './application/kibana_services'; import { DataViewsPublicPluginStart } from '../../data_views/public'; -import { TelemetryPluginStart } from '../../telemetry/public'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { UrlForwardingSetup, UrlForwardingStart } from '../../url_forwarding/public'; import { AppNavLinkStatus } from '../../../core/public'; @@ -38,7 +39,6 @@ import { SharePluginSetup } from '../../share/public'; export interface HomePluginStartDependencies { dataViews: DataViewsPublicPluginStart; - telemetry?: TelemetryPluginStart; urlForwarding: UrlForwardingStart; } @@ -61,6 +61,7 @@ export class HomePublicPlugin private readonly environmentService = new EnvironmentService(); private readonly tutorialService = new TutorialService(); private readonly addDataService = new AddDataService(); + private readonly welcomeService = new WelcomeService(); constructor(private readonly initializerContext: PluginInitializerContext) {} @@ -76,7 +77,7 @@ export class HomePublicPlugin const trackUiMetric = usageCollection ? usageCollection.reportUiCounter.bind(usageCollection, 'Kibana_home') : () => {}; - const [coreStart, { telemetry, dataViews, urlForwarding: urlForwardingStart }] = + const [coreStart, { dataViews, urlForwarding: urlForwardingStart }] = await core.getStartServices(); setServices({ share, @@ -89,7 +90,6 @@ export class HomePublicPlugin savedObjectsClient: coreStart.savedObjects.client, chrome: coreStart.chrome, application: coreStart.application, - telemetry, uiSettings: core.uiSettings, addBasePath: core.http.basePath.prepend, getBasePath: core.http.basePath.get, @@ -100,6 +100,7 @@ export class HomePublicPlugin tutorialService: this.tutorialService, addDataService: this.addDataService, featureCatalogue: this.featuresCatalogueRegistry, + welcomeService: this.welcomeService, }); coreStart.chrome.docTitle.change( i18n.translate('home.pageTitle', { defaultMessage: 'Home' }) @@ -132,6 +133,7 @@ export class HomePublicPlugin environment: { ...this.environmentService.setup() }, tutorials: { ...this.tutorialService.setup() }, addData: { ...this.addDataService.setup() }, + welcomeScreen: { ...this.welcomeService.setup() }, }; } @@ -159,12 +161,12 @@ export interface HomePublicPluginSetup { tutorials: TutorialServiceSetup; addData: AddDataServiceSetup; featureCatalogue: FeatureCatalogueSetup; + welcomeScreen: WelcomeServiceSetup; /** * The environment service is only available for a transition period and will * be replaced by display specific extension points. * @deprecated */ - environment: EnvironmentSetup; } diff --git a/src/plugins/home/public/services/index.ts b/src/plugins/home/public/services/index.ts index 2ee68a9eef0c..41bc9ee258ce 100644 --- a/src/plugins/home/public/services/index.ts +++ b/src/plugins/home/public/services/index.ts @@ -28,3 +28,6 @@ export type { export { AddDataService } from './add_data'; export type { AddDataServiceSetup, AddDataTab } from './add_data'; + +export { WelcomeService } from './welcome'; +export type { WelcomeServiceSetup, WelcomeRenderTelemetryNotice } from './welcome'; diff --git a/src/plugins/home/public/services/welcome/index.ts b/src/plugins/home/public/services/welcome/index.ts new file mode 100644 index 000000000000..371c6044c5dc --- /dev/null +++ b/src/plugins/home/public/services/welcome/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { WelcomeServiceSetup, WelcomeRenderTelemetryNotice } from './welcome_service'; +export { WelcomeService } from './welcome_service'; diff --git a/src/plugins/home/public/services/welcome/welcome_service.mocks.ts b/src/plugins/home/public/services/welcome/welcome_service.mocks.ts new file mode 100644 index 000000000000..921cb9906632 --- /dev/null +++ b/src/plugins/home/public/services/welcome/welcome_service.mocks.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 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 { PublicMethodsOf } from '@kbn/utility-types'; +import { WelcomeService, WelcomeServiceSetup } from './welcome_service'; + +const createSetupMock = (): jest.Mocked => { + const welcomeService = new WelcomeService(); + const welcomeServiceSetup = welcomeService.setup(); + return { + registerTelemetryNoticeRenderer: jest + .fn() + .mockImplementation(welcomeServiceSetup.registerTelemetryNoticeRenderer), + registerOnRendered: jest.fn().mockImplementation(welcomeServiceSetup.registerOnRendered), + }; +}; + +const createMock = (): jest.Mocked> => { + const welcomeService = new WelcomeService(); + + return { + setup: jest.fn().mockImplementation(welcomeService.setup), + onRendered: jest.fn().mockImplementation(welcomeService.onRendered), + renderTelemetryNotice: jest.fn().mockImplementation(welcomeService.renderTelemetryNotice), + }; +}; + +export const welcomeServiceMock = { + createSetup: createSetupMock, + create: createMock, +}; diff --git a/src/plugins/home/public/services/welcome/welcome_service.test.ts b/src/plugins/home/public/services/welcome/welcome_service.test.ts new file mode 100644 index 000000000000..df2f95718c78 --- /dev/null +++ b/src/plugins/home/public/services/welcome/welcome_service.test.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 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 { WelcomeService, WelcomeServiceSetup } from './welcome_service'; + +describe('WelcomeService', () => { + let welcomeService: WelcomeService; + let welcomeServiceSetup: WelcomeServiceSetup; + + beforeEach(() => { + welcomeService = new WelcomeService(); + welcomeServiceSetup = welcomeService.setup(); + }); + describe('onRendered', () => { + test('it should register an onRendered listener', () => { + const onRendered = jest.fn(); + welcomeServiceSetup.registerOnRendered(onRendered); + + welcomeService.onRendered(); + expect(onRendered).toHaveBeenCalledTimes(1); + }); + + test('it should handle onRendered errors', () => { + const onRendered = jest.fn().mockImplementation(() => { + throw new Error('Something went terribly wrong'); + }); + welcomeServiceSetup.registerOnRendered(onRendered); + + expect(() => welcomeService.onRendered()).not.toThrow(); + expect(onRendered).toHaveBeenCalledTimes(1); + }); + + test('it should allow registering multiple onRendered listeners', () => { + const onRendered = jest.fn(); + const onRendered2 = jest.fn(); + welcomeServiceSetup.registerOnRendered(onRendered); + welcomeServiceSetup.registerOnRendered(onRendered2); + + welcomeService.onRendered(); + expect(onRendered).toHaveBeenCalledTimes(1); + expect(onRendered2).toHaveBeenCalledTimes(1); + }); + + test('if the same handler is registered twice, it is called twice', () => { + const onRendered = jest.fn(); + welcomeServiceSetup.registerOnRendered(onRendered); + welcomeServiceSetup.registerOnRendered(onRendered); + + welcomeService.onRendered(); + expect(onRendered).toHaveBeenCalledTimes(2); + }); + }); + describe('renderTelemetryNotice', () => { + test('it should register a renderer', () => { + const renderer = jest.fn().mockReturnValue('rendered text'); + welcomeServiceSetup.registerTelemetryNoticeRenderer(renderer); + + expect(welcomeService.renderTelemetryNotice()).toEqual('rendered text'); + }); + + test('it should fail to register a 2nd renderer and still use the first registered renderer', () => { + const renderer = jest.fn().mockReturnValue('rendered text'); + const renderer2 = jest.fn().mockReturnValue('other text'); + welcomeServiceSetup.registerTelemetryNoticeRenderer(renderer); + expect(() => welcomeServiceSetup.registerTelemetryNoticeRenderer(renderer2)).toThrowError( + 'Only one renderTelemetryNotice handler can be registered' + ); + + expect(welcomeService.renderTelemetryNotice()).toEqual('rendered text'); + }); + + test('it should handle errors in the renderer', () => { + const renderer = jest.fn().mockImplementation(() => { + throw new Error('Something went terribly wrong'); + }); + welcomeServiceSetup.registerTelemetryNoticeRenderer(renderer); + + expect(welcomeService.renderTelemetryNotice()).toEqual(null); + }); + }); +}); diff --git a/src/plugins/home/public/services/welcome/welcome_service.ts b/src/plugins/home/public/services/welcome/welcome_service.ts new file mode 100644 index 000000000000..46cf139adb36 --- /dev/null +++ b/src/plugins/home/public/services/welcome/welcome_service.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 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 WelcomeRenderTelemetryNotice = () => null | JSX.Element; + +export interface WelcomeServiceSetup { + /** + * Register listeners to be called when the Welcome component is mounted. + * It can be called multiple times to register multiple listeners. + */ + registerOnRendered: (onRendered: () => void) => void; + /** + * Register a renderer of the telemetry notice to be shown below the Welcome page. + */ + registerTelemetryNoticeRenderer: (renderTelemetryNotice: WelcomeRenderTelemetryNotice) => void; +} + +export class WelcomeService { + private readonly onRenderedHandlers: Array<() => void> = []; + private renderTelemetryNoticeHandler?: WelcomeRenderTelemetryNotice; + + public setup = (): WelcomeServiceSetup => { + return { + registerOnRendered: (onRendered) => { + this.onRenderedHandlers.push(onRendered); + }, + registerTelemetryNoticeRenderer: (renderTelemetryNotice) => { + if (this.renderTelemetryNoticeHandler) { + throw new Error('Only one renderTelemetryNotice handler can be registered'); + } + this.renderTelemetryNoticeHandler = renderTelemetryNotice; + }, + }; + }; + + public onRendered = () => { + this.onRenderedHandlers.forEach((onRendered) => { + try { + onRendered(); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + } + }); + }; + + public renderTelemetryNotice = () => { + if (this.renderTelemetryNoticeHandler) { + try { + return this.renderTelemetryNoticeHandler(); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + } + } + return null; + }; +} diff --git a/src/plugins/home/tsconfig.json b/src/plugins/home/tsconfig.json index fa98b98ff8e1..17d0fc7bd91a 100644 --- a/src/plugins/home/tsconfig.json +++ b/src/plugins/home/tsconfig.json @@ -15,7 +15,6 @@ { "path": "../kibana_react/tsconfig.json" }, { "path": "../share/tsconfig.json" }, { "path": "../url_forwarding/tsconfig.json" }, - { "path": "../usage_collection/tsconfig.json" }, - { "path": "../telemetry/tsconfig.json" } + { "path": "../usage_collection/tsconfig.json" } ] } diff --git a/src/plugins/input_control_vis/public/components/editor/control_editor.tsx b/src/plugins/input_control_vis/public/components/editor/control_editor.tsx index 76727fcaa645..cb0ea78d613b 100644 --- a/src/plugins/input_control_vis/public/components/editor/control_editor.tsx +++ b/src/plugins/input_control_vis/public/components/editor/control_editor.tsx @@ -22,7 +22,7 @@ import { import { RangeControlEditor } from './range_control_editor'; import { ListControlEditor } from './list_control_editor'; import { getTitle, ControlParams, CONTROL_TYPES, ControlParamsOptions } from '../../editor_utils'; -import { IndexPattern } from '../../../../data/public'; +import { DataView } from '../../../../data_views/public'; import { InputControlVisDependencies } from '../../plugin'; import './control_editor.scss'; @@ -35,7 +35,7 @@ interface ControlEditorUiProps { handleRemoveControl: (controlIndex: number) => void; handleIndexPatternChange: (controlIndex: number, indexPatternId: string) => void; handleFieldNameChange: (controlIndex: number, fieldName: string) => void; - getIndexPattern: (indexPatternId: string) => Promise; + getIndexPattern: (indexPatternId: string) => Promise; handleOptionsChange: ( controlIndex: number, optionName: T, diff --git a/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx b/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx index 41a6b34259a7..0b000aa61f34 100644 --- a/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx +++ b/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx @@ -20,7 +20,7 @@ import { } from '@elastic/eui'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { IndexPattern } from 'src/plugins/data/public'; +import { DataView } from '../../../../data_views/public'; import { ControlEditor } from './control_editor'; import { addControl, @@ -49,7 +49,7 @@ class ControlsTab extends PureComponent { type: CONTROL_TYPES.LIST, }; - getIndexPattern = async (indexPatternId: string): Promise => { + getIndexPattern = async (indexPatternId: string): Promise => { const [, startDeps] = await this.props.deps.core.getStartServices(); return await startDeps.data.indexPatterns.get(indexPatternId); }; diff --git a/src/plugins/input_control_vis/public/components/editor/field_select.tsx b/src/plugins/input_control_vis/public/components/editor/field_select.tsx index 1ecbf2772ebf..7cc818b71d79 100644 --- a/src/plugins/input_control_vis/public/components/editor/field_select.tsx +++ b/src/plugins/input_control_vis/public/components/editor/field_select.tsx @@ -12,7 +12,7 @@ import React, { Component } from 'react'; import { injectI18n, FormattedMessage, InjectedIntlProps } from '@kbn/i18n-react'; import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { IndexPattern, IndexPatternField } from '../../../../data/public'; +import { DataView, DataViewField } from '../../../../data_views/public'; interface FieldSelectUiState { isLoading: boolean; @@ -21,11 +21,11 @@ interface FieldSelectUiState { } export type FieldSelectUiProps = InjectedIntlProps & { - getIndexPattern: (indexPatternId: string) => Promise; + getIndexPattern: (indexPatternId: string) => Promise; indexPatternId: string; onChange: (value: any) => void; fieldName?: string; - filterField?: (field: IndexPatternField) => boolean; + filterField?: (field: DataViewField) => boolean; controlIndex: number; }; @@ -74,7 +74,7 @@ class FieldSelectUi extends Component { return; } - let indexPattern: IndexPattern; + let indexPattern: DataView; try { indexPattern = await this.props.getIndexPattern(indexPatternId); } catch (err) { @@ -96,7 +96,7 @@ class FieldSelectUi extends Component { const fields: Array> = []; indexPattern.fields .filter(this.props.filterField ?? (() => true)) - .forEach((field: IndexPatternField) => { + .forEach((field: DataViewField) => { const fieldsList = fieldsByTypeMap.get(field.type) ?? []; fieldsList.push(field.name); fieldsByTypeMap.set(field.type, fieldsList); diff --git a/src/plugins/input_control_vis/public/components/editor/list_control_editor.tsx b/src/plugins/input_control_vis/public/components/editor/list_control_editor.tsx index 2bf1bacbbcd5..720b1325142e 100644 --- a/src/plugins/input_control_vis/public/components/editor/list_control_editor.tsx +++ b/src/plugins/input_control_vis/public/components/editor/list_control_editor.tsx @@ -14,7 +14,8 @@ import { EuiFormRow, EuiFieldNumber, EuiSwitch, EuiSelect } from '@elastic/eui'; import { IndexPatternSelectFormRow } from './index_pattern_select_form_row'; import { FieldSelect } from './field_select'; import { ControlParams, ControlParamsOptions } from '../../editor_utils'; -import { IndexPattern, IndexPatternField, IndexPatternSelectProps } from '../../../../data/public'; +import { IndexPatternSelectProps } from '../../../../data/public'; +import { DataView, DataViewField } from '../../../../data_views/public'; import { InputControlVisDependencies } from '../../plugin'; interface ListControlEditorState { @@ -25,7 +26,7 @@ interface ListControlEditorState { } interface ListControlEditorProps { - getIndexPattern: (indexPatternId: string) => Promise; + getIndexPattern: (indexPatternId: string) => Promise; controlIndex: number; controlParams: ControlParams; handleFieldNameChange: (fieldName: string) => void; @@ -40,7 +41,7 @@ interface ListControlEditorProps { deps: InputControlVisDependencies; } -function filterField(field: IndexPatternField) { +function filterField(field: DataViewField) { return ( Boolean(field.aggregatable) && ['number', 'boolean', 'date', 'ip', 'string'].includes(field.type) @@ -104,7 +105,7 @@ export class ListControlEditor extends PureComponent< return; } - let indexPattern: IndexPattern; + let indexPattern: DataView; try { indexPattern = await this.props.getIndexPattern(this.props.controlParams.indexPattern); } catch (err) { @@ -116,7 +117,7 @@ export class ListControlEditor extends PureComponent< return; } - const field = (indexPattern.fields as IndexPatternField[]).find( + const field = (indexPattern.fields as DataViewField[]).find( ({ name }) => name === this.props.controlParams.fieldName ); if (!field) { diff --git a/src/plugins/input_control_vis/public/components/editor/range_control_editor.tsx b/src/plugins/input_control_vis/public/components/editor/range_control_editor.tsx index cdf8663caea5..913eb49c96cf 100644 --- a/src/plugins/input_control_vis/public/components/editor/range_control_editor.tsx +++ b/src/plugins/input_control_vis/public/components/editor/range_control_editor.tsx @@ -14,13 +14,14 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { IndexPatternSelectFormRow } from './index_pattern_select_form_row'; import { FieldSelect } from './field_select'; import { ControlParams, ControlParamsOptions } from '../../editor_utils'; -import { IndexPattern, IndexPatternField, IndexPatternSelectProps } from '../../../../data/public'; +import { IndexPatternSelectProps } from '../../../../data/public'; +import { DataView, DataViewField } from '../../../../data_views/public'; import { InputControlVisDependencies } from '../../plugin'; interface RangeControlEditorProps { controlIndex: number; controlParams: ControlParams; - getIndexPattern: (indexPatternId: string) => Promise; + getIndexPattern: (indexPatternId: string) => Promise; handleFieldNameChange: (fieldName: string) => void; handleIndexPatternChange: (indexPatternId: string) => void; handleOptionsChange: ( @@ -35,7 +36,7 @@ interface RangeControlEditorState { IndexPatternSelect: ComponentType | null; } -function filterField(field: IndexPatternField) { +function filterField(field: DataViewField) { return field.type === 'number'; } diff --git a/src/plugins/input_control_vis/public/control/control.ts b/src/plugins/input_control_vis/public/control/control.ts index 2df4a417da43..26a88be6cd90 100644 --- a/src/plugins/input_control_vis/public/control/control.ts +++ b/src/plugins/input_control_vis/public/control/control.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { Filter } from 'src/plugins/data/public'; +import { Filter } from '@kbn/es-query'; import { ControlParams, ControlParamsOptions, CONTROL_TYPES } from '../editor_utils'; import { RangeFilterManager } from './filter_manager/range_filter_manager'; import { PhraseFilterManager } from './filter_manager/phrase_filter_manager'; diff --git a/src/plugins/input_control_vis/public/control/create_search_source.ts b/src/plugins/input_control_vis/public/control/create_search_source.ts index 87dec8b1d9a2..c9db1de9f7f2 100644 --- a/src/plugins/input_control_vis/public/control/create_search_source.ts +++ b/src/plugins/input_control_vis/public/control/create_search_source.ts @@ -9,15 +9,16 @@ import { Filter } from '@kbn/es-query'; import { SerializedSearchSourceFields, - IndexPattern, TimefilterContract, DataPublicPluginStart, } from 'src/plugins/data/public'; +import { DataView } from '../../../data_views/public'; + export async function createSearchSource( { create }: DataPublicPluginStart['search']['searchSource'], initialState: SerializedSearchSourceFields | null, - indexPattern: IndexPattern, + indexPattern: DataView, aggs: any, useTimeFilter: boolean, filters: Filter[] = [], diff --git a/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.test.ts b/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.test.ts index a96326a626a2..7759ba3b3460 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.test.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.test.ts @@ -10,11 +10,8 @@ import expect from '@kbn/expect'; import { FilterManager } from './filter_manager'; import { coreMock } from '../../../../../core/public/mocks'; -import { - Filter, - FilterManager as QueryFilterManager, - IndexPatternsContract, -} from '../../../../data/public'; +import { FilterManager as QueryFilterManager, DataViewsContract } from '../../../../data/public'; +import { Filter } from '@kbn/es-query'; const setupMock = coreMock.createSetup(); @@ -44,7 +41,7 @@ describe('FilterManager', function () { controlId, 'field1', '1', - {} as IndexPatternsContract, + {} as DataViewsContract, queryFilterMock ); }); diff --git a/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.ts b/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.ts index f35eb364ecaf..420cb8fe844d 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.ts @@ -6,23 +6,20 @@ * Side Public License, v 1. */ +import { Filter } from '@kbn/es-query'; import _ from 'lodash'; -import { - FilterManager as QueryFilterManager, - IndexPattern, - Filter, - IndexPatternsContract, -} from '../../../../data/public'; +import { FilterManager as QueryFilterManager, DataViewsContract } from '../../../../data/public'; +import { DataView } from '../../../../data_views/public'; export abstract class FilterManager { - protected indexPattern: IndexPattern | undefined; + protected indexPattern: DataView | undefined; constructor( public controlId: string, public fieldName: string, private indexPatternId: string, - private indexPatternsService: IndexPatternsContract, + private indexPatternsService: DataViewsContract, protected queryFilter: QueryFilterManager ) {} @@ -48,7 +45,7 @@ export abstract class FilterManager { } } - getIndexPattern(): IndexPattern | undefined { + getIndexPattern(): DataView | undefined { return this.indexPattern; } diff --git a/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.ts b/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.ts index 14a616e8a0db..45e67ad742a6 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.ts @@ -9,11 +9,8 @@ import { Filter } from '@kbn/es-query'; import expect from '@kbn/expect'; -import { - IndexPattern, - FilterManager as QueryFilterManager, - IndexPatternsContract, -} from '../../../../data/public'; +import { FilterManager as QueryFilterManager, DataViewsContract } from '../../../../data/public'; +import { DataView } from '../../../../data_views/public'; import { PhraseFilterManager } from './phrase_filter_manager'; describe('PhraseFilterManager', function () { @@ -27,7 +24,7 @@ describe('PhraseFilterManager', function () { convert: (value: any) => value, }, }; - const indexPatternMock: IndexPattern = { + const indexPatternMock: DataView = { id: indexPatternId, fields: { getByName: (name: string) => { @@ -35,10 +32,10 @@ describe('PhraseFilterManager', function () { return fields[name]; }, }, - } as IndexPattern; + } as DataView; const indexPatternsServiceMock = { get: jest.fn().mockReturnValue(Promise.resolve(indexPatternMock)), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked; const queryFilterMock: QueryFilterManager = {} as QueryFilterManager; let filterManager: PhraseFilterManager; beforeEach(async () => { @@ -89,7 +86,7 @@ describe('PhraseFilterManager', function () { id: string, fieldName: string, indexPatternId: string, - indexPatternsService: IndexPatternsContract, + indexPatternsService: DataViewsContract, queryFilter: QueryFilterManager ) { super(id, fieldName, indexPatternId, indexPatternsService, queryFilter); @@ -105,7 +102,7 @@ describe('PhraseFilterManager', function () { } } - const indexPatternsServiceMock = {} as IndexPatternsContract; + const indexPatternsServiceMock = {} as DataViewsContract; const queryFilterMock: QueryFilterManager = {} as QueryFilterManager; let filterManager: MockFindFiltersPhraseFilterManager; beforeEach(() => { diff --git a/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.ts b/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.ts index 98ba8b4fbcda..0653d25f16d4 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.ts @@ -18,17 +18,14 @@ import { PhraseFilter, } from '@kbn/es-query'; import { FilterManager } from './filter_manager'; -import { - IndexPatternsContract, - FilterManager as QueryFilterManager, -} from '../../../../data/public'; +import { DataViewsContract, FilterManager as QueryFilterManager } from '../../../../data/public'; export class PhraseFilterManager extends FilterManager { constructor( controlId: string, fieldName: string, indexPatternId: string, - indexPatternsService: IndexPatternsContract, + indexPatternsService: DataViewsContract, queryFilter: QueryFilterManager ) { super(controlId, fieldName, indexPatternId, indexPatternsService, queryFilter); diff --git a/src/plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.ts b/src/plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.ts index bdcd1a34573d..a329773720bc 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.ts @@ -9,11 +9,8 @@ import expect from '@kbn/expect'; import { RangeFilterManager } from './range_filter_manager'; -import { - IndexPattern, - FilterManager as QueryFilterManager, - IndexPatternsContract, -} from '../../../../data/public'; +import { FilterManager as QueryFilterManager, DataViewsContract } from '../../../../data/public'; +import { DataView } from '../../../../data_views/public'; import { RangeFilter, RangeFilterMeta } from '@kbn/es-query'; describe('RangeFilterManager', function () { @@ -24,7 +21,7 @@ describe('RangeFilterManager', function () { const fieldMock = { name: 'field1', }; - const indexPatternMock: IndexPattern = { + const indexPatternMock: DataView = { id: indexPatternId, fields: { getByName: (name: any) => { @@ -34,10 +31,10 @@ describe('RangeFilterManager', function () { return fields[name]; }, }, - } as IndexPattern; + } as DataView; const indexPatternsServiceMock = { get: jest.fn().mockReturnValue(Promise.resolve(indexPatternMock)), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked; const queryFilterMock: QueryFilterManager = {} as QueryFilterManager; let filterManager: RangeFilterManager; beforeEach(async () => { @@ -70,7 +67,7 @@ describe('RangeFilterManager', function () { id: string, fieldName: string, indexPatternId: string, - indexPatternsService: IndexPatternsContract, + indexPatternsService: DataViewsContract, queryFilter: QueryFilterManager ) { super(id, fieldName, indexPatternId, indexPatternsService, queryFilter); @@ -86,7 +83,7 @@ describe('RangeFilterManager', function () { } } - const indexPatternsServiceMock = {} as IndexPatternsContract; + const indexPatternsServiceMock = {} as DataViewsContract; const queryFilterMock: QueryFilterManager = {} as QueryFilterManager; let filterManager: MockFindFiltersRangeFilterManager; beforeEach(() => { diff --git a/src/plugins/input_control_vis/public/control/list_control_factory.ts b/src/plugins/input_control_vis/public/control/list_control_factory.ts index 39c5f259c273..f6bd0bc0cd28 100644 --- a/src/plugins/input_control_vis/public/control/list_control_factory.ts +++ b/src/plugins/input_control_vis/public/control/list_control_factory.ts @@ -9,11 +9,11 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { - IndexPatternField, TimefilterContract, SerializedSearchSourceFields, DataPublicPluginStart, } from 'src/plugins/data/public'; +import { DataViewField } from '../../../data_views/public'; import { Control, noValuesDisableMsg, noIndexPatternMsg } from './control'; import { PhraseFilterManager } from './filter_manager/phrase_filter_manager'; import { createSearchSource } from './create_search_source'; @@ -26,7 +26,7 @@ function getEscapedQuery(query = '') { } interface TermsAggArgs { - field?: IndexPatternField; + field?: DataViewField; size: number | null; direction: string; query?: string; diff --git a/src/plugins/input_control_vis/public/control/range_control_factory.ts b/src/plugins/input_control_vis/public/control/range_control_factory.ts index 906762266a7b..6cd477d28b4f 100644 --- a/src/plugins/input_control_vis/public/control/range_control_factory.ts +++ b/src/plugins/input_control_vis/public/control/range_control_factory.ts @@ -9,18 +9,15 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { - IndexPatternField, - TimefilterContract, - DataPublicPluginStart, -} from 'src/plugins/data/public'; +import { TimefilterContract, DataPublicPluginStart } from 'src/plugins/data/public'; +import { DataViewField } from '../../../data_views/public'; import { Control, noValuesDisableMsg, noIndexPatternMsg } from './control'; import { RangeFilterManager } from './filter_manager/range_filter_manager'; import { createSearchSource } from './create_search_source'; import { ControlParams } from '../editor_utils'; import { InputControlVisDependencies } from '../plugin'; -const minMaxAgg = (field?: IndexPatternField) => { +const minMaxAgg = (field?: DataViewField) => { const aggBody: any = {}; if (field) { if (field.scripted) { diff --git a/src/plugins/input_control_vis/public/test_utils/get_index_pattern_mock.ts b/src/plugins/input_control_vis/public/test_utils/get_index_pattern_mock.ts index 122800198f09..40f01b05d18b 100644 --- a/src/plugins/input_control_vis/public/test_utils/get_index_pattern_mock.ts +++ b/src/plugins/input_control_vis/public/test_utils/get_index_pattern_mock.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { IndexPattern } from 'src/plugins/data/public'; +import { DataView } from 'src/plugins/data_views/public'; /** * Returns forced **Partial** IndexPattern for use in tests */ -export const getIndexPatternMock = (): Promise => { +export const getIndexPatternMock = (): Promise => { return Promise.resolve({ id: 'mockIndexPattern', title: 'mockIndexPattern', @@ -20,5 +20,5 @@ export const getIndexPatternMock = (): Promise => { { name: 'textField', type: 'string', aggregatable: false }, { name: 'numberField', type: 'number', aggregatable: true }, ], - } as IndexPattern); + } as DataView); }; diff --git a/src/plugins/input_control_vis/public/vis_controller.tsx b/src/plugins/input_control_vis/public/vis_controller.tsx index bb09a90bb9dd..51c88962f3cb 100644 --- a/src/plugins/input_control_vis/public/vis_controller.tsx +++ b/src/plugins/input_control_vis/public/vis_controller.tsx @@ -13,8 +13,9 @@ import { Subscription } from 'rxjs'; import { I18nStart } from 'kibana/public'; import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { Filter } from '@kbn/es-query'; import { VisualizationContainer } from '../../visualizations/public'; -import { FilterManager, Filter } from '../../data/public'; +import { FilterManager } from '../../data/public'; import { InputControlVis } from './components/vis/input_control_vis'; import { getControlFactory } from './control/control_factory'; diff --git a/src/plugins/input_control_vis/tsconfig.json b/src/plugins/input_control_vis/tsconfig.json index 5e53199bb1e6..43b1539e87da 100644 --- a/src/plugins/input_control_vis/tsconfig.json +++ b/src/plugins/input_control_vis/tsconfig.json @@ -13,6 +13,7 @@ "references": [ { "path": "../kibana_react/tsconfig.json" }, { "path": "../data/tsconfig.json"}, + { "path": "../data_views/tsconfig.json"}, { "path": "../expressions/tsconfig.json" }, { "path": "../visualizations/tsconfig.json" }, { "path": "../vis_default_editor/tsconfig.json" }, diff --git a/src/plugins/kibana_react/public/code_editor/code_editor.tsx b/src/plugins/kibana_react/public/code_editor/code_editor.tsx index 500aefc82d60..239208e6752f 100644 --- a/src/plugins/kibana_react/public/code_editor/code_editor.tsx +++ b/src/plugins/kibana_react/public/code_editor/code_editor.tsx @@ -178,11 +178,9 @@ export const CodeEditor: React.FC = ({ const [isHintActive, setIsHintActive] = useState(true); - /* eslint-disable @typescript-eslint/naming-convention */ const promptClasses = classNames('kibanaCodeEditor__keyboardHint', { 'kibanaCodeEditor__keyboardHint--isInactive': !isHintActive, }); - /* eslint-enable @typescript-eslint/naming-convention */ const _updateDimensions = useCallback(() => { _editor.current?.layout(); diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index baed839d4112..94e8b505f1dd 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -39,6 +39,8 @@ export { createReactOverlays } from './overlays'; export { useUiSetting, useUiSetting$ } from './ui_settings'; +export { useExecutionContext } from './use_execution_context'; + export type { TableListViewProps, TableListViewState } from './table_list_view'; export { TableListView } from './table_list_view'; diff --git a/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap b/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap index d90daa33d168..5889338c37d8 100644 --- a/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap @@ -1,39 +1,65 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`KibanaPageTemplate render basic template 1`] = ` - +
+
+
+
+
+
+
+

+ test +

+
+
+
+
+
+ test +
+
+
+
+
+
+
+
+
+
+
`; exports[`KibanaPageTemplate render custom empty prompt only 1`] = ` - } /> - + `; exports[`KibanaPageTemplate render custom empty prompt with page header 1`] = ` - } /> - + `; exports[`KibanaPageTemplate render default empty prompt 1`] = ` - - - test -

+ ], + "title": "test", } - iconColor="" - iconType="test" - /> -
+ } +/> `; exports[`KibanaPageTemplate render noDataContent 1`] = ` - + { + const { + className, + noDataConfig, + ...rest + } = props; + + if (!noDataConfig) { + return null; } - pageSideBarProps={ + + const template = _util.NO_DATA_PAGE_TEMPLATE_PROPS.template; + const classes = (0, _util.getClasses)(template, className); + return /*#__PURE__*/_react.default.createElement(_eui.EuiPageTemplate, (0, _extends2.default)({ + "data-test-subj": props['data-test-subj'], + template: template, + className: classes + }, rest, _util.NO_DATA_PAGE_TEMPLATE_PROPS), /*#__PURE__*/_react.default.createElement(_no_data_page.NoDataPage, noDataConfig)); +} + noDataConfig={ Object { - "className": "kbnPageTemplate__pageSideBar", - "paddingSize": "none", - } - } - restrictWidth={950} - template="centeredBody" -> - - -`; - -exports[`KibanaPageTemplate render solutionNav 1`] = ` - - } - pageSideBarProps={ + solutionNav={ Object { - "className": "kbnPageTemplate__pageSideBar", - "paddingSize": "none", + "icon": "solution", + "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, + }, + ], + "name": "Solution", } } - restrictWidth={true} /> `; + +exports[`KibanaPageTemplate render solutionNav 1`] = ` +
+
+
+
+
+
+
+
+
+

+ test +

+
+
+
+
+
+ test +
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/plugins/kibana_react/public/page_template/index.ts b/src/plugins/kibana_react/public/page_template/index.ts index 41eeaab01ef3..fda644a28479 100644 --- a/src/plugins/kibana_react/public/page_template/index.ts +++ b/src/plugins/kibana_react/public/page_template/index.ts @@ -8,5 +8,7 @@ export type { KibanaPageTemplateProps } from './page_template'; export { KibanaPageTemplate } from './page_template'; -export { KibanaPageTemplateSolutionNavAvatar } from './solution_nav'; +export { KibanaPageTemplateSolutionNavAvatar, KibanaPageTemplateSolutionNav } from './solution_nav'; export * from './no_data_page'; +export { withSolutionNav } from './with_solution_nav'; +export { NO_DATA_PAGE_MAX_WIDTH, NO_DATA_PAGE_TEMPLATE_PROPS } from './util'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap b/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap index 0554e64c5ecb..18df4fa24449 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap @@ -106,88 +106,22 @@ exports[`NoDataPage render 1`] = ` } } > - - - -

- -

- -

- - - , - "solution": "Elastic", - } - } - /> -

-
-
- - - , + , + , + ] } - > - - - - - - - - - - + docsLink="test" + pageTitle="Welcome to Elastic Elastic!" + solution="Elastic" + /> diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.scss b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.scss similarity index 100% rename from src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.scss rename to src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.scss diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.test.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.test.tsx new file mode 100644 index 000000000000..6223613815f5 --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.test.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 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 { shallowWithIntl } from '@kbn/test-jest-helpers'; +import { NoDataCard } from '../no_data_card'; +import { ActionCards } from './action_cards'; + +describe('ActionCards', () => { + const onClick = jest.fn(); + const action = { + recommended: false, + button: 'Button text', + onClick, + }; + const card = ; + const actionCard1 =
{card}
; + const actionCard2 =
{card}
; + + test('renders correctly', () => { + const component = shallowWithIntl(); + const actionCards = component.find('div'); + expect(actionCards.length).toBe(2); + expect(actionCards.at(0).key()).toBe('first'); + expect(actionCards.at(1).key()).toBe('second'); + }); +}); diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.tsx new file mode 100644 index 000000000000..3af0a6187672 --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.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 './action_cards.scss'; + +import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; +import React, { ReactElement } from 'react'; +import { ElasticAgentCard, NoDataCard } from '../no_data_card'; + +interface ActionCardsProps { + actionCards: Array | ReactElement>; +} +export const ActionCards = ({ actionCards }: ActionCardsProps) => { + const cards = actionCards.map((card) => ( + + {card} + + )); + return ( + + {cards} + + ); +}; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/index.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/index.tsx new file mode 100644 index 000000000000..0ba8ef86ba5c --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/index.tsx @@ -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 { ActionCards } from './action_cards'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/index.ts b/src/plugins/kibana_react/public/page_template/no_data_page/index.ts index 55661ad6f14f..b5a11722dd39 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/index.ts +++ b/src/plugins/kibana_react/public/page_template/no_data_page/index.ts @@ -8,3 +8,4 @@ export * from './no_data_page'; export * from './no_data_card'; +export * from './no_data_config_page'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/index.ts b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/index.ts index e05d4d9675ca..7f88c1a76e79 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/index.ts +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/index.ts @@ -7,4 +7,5 @@ */ export * from './elastic_agent_card'; +/** @deprecated Use `NoDataCard` from `src/plugins/shared_ux/page_template`. */ export * from './no_data_card'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/index.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/index.tsx new file mode 100644 index 000000000000..0bdde4002139 --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/index.tsx @@ -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 { NoDataConfigPage, NoDataConfigPageWithSolutionNavBar } from './no_data_config_page'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/no_data_config_page.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/no_data_config_page.tsx new file mode 100644 index 000000000000..07ffc9618147 --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/no_data_config_page.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EuiPageTemplate } from '@elastic/eui'; +import React from 'react'; +import { NoDataPage } from '../no_data_page'; +import { withSolutionNav } from '../../with_solution_nav'; +import { KibanaPageTemplateProps } from '../../page_template'; +import { getClasses, NO_DATA_PAGE_TEMPLATE_PROPS } from '../../util'; + +export const NoDataConfigPage = (props: KibanaPageTemplateProps) => { + const { className, noDataConfig, ...rest } = props; + + if (!noDataConfig) { + return null; + } + + const template = NO_DATA_PAGE_TEMPLATE_PROPS.template; + const classes = getClasses(template, className); + + return ( + + + + ); +}; + +export const NoDataConfigPageWithSolutionNavBar = withSolutionNav(NoDataConfigPage); diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx index 0c8754f852b0..077f991477e8 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx @@ -6,36 +6,14 @@ * Side Public License, v 1. */ -import './no_data_page.scss'; - import React, { ReactNode, useMemo, FunctionComponent, MouseEventHandler } from 'react'; -import { - EuiFlexItem, - EuiCardProps, - EuiFlexGrid, - EuiSpacer, - EuiText, - EuiTextColor, - EuiLink, - CommonProps, -} from '@elastic/eui'; +import { EuiCardProps, EuiSpacer, EuiText, EuiLink, CommonProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import classNames from 'classnames'; -import { KibanaPageTemplateProps } from '../page_template'; import { ElasticAgentCard, NoDataCard } from './no_data_card'; -import { KibanaPageTemplateSolutionNavAvatar } from '../solution_nav'; - -export const NO_DATA_PAGE_MAX_WIDTH = 950; -export const NO_DATA_PAGE_TEMPLATE_PROPS: KibanaPageTemplateProps = { - restrictWidth: NO_DATA_PAGE_MAX_WIDTH, - template: 'centeredBody', - pageContentProps: { - hasShadow: false, - color: 'transparent', - }, -}; +import { NoDataPageBody } from './no_data_page_body/no_data_page_body'; export const NO_DATA_RECOMMENDED = i18n.translate( 'kibana-react.noDataPage.noDataPage.recommended', @@ -112,70 +90,35 @@ export const NoDataPage: FunctionComponent = ({ // Convert the iterated [[key, value]] array format back into an object const sortedData = Object.fromEntries(sortedEntries); const actionsKeys = Object.keys(sortedData); - const renderActions = useMemo(() => { + + const actionCards = useMemo(() => { return Object.values(sortedData).map((action, i) => { - if (actionsKeys[i] === 'elasticAgent' || actionsKeys[i] === 'beats') { - return ( - - - - ); - } else { - return ( - - - - ); - } + const isAgent = actionsKeys[i] === 'elasticAgent' || actionsKeys[i] === 'beats'; + const key = isAgent ? 'empty-page-agent-action' : `empty-page-${actionsKeys[i]}-action`; + return isAgent ? ( + + ) : ( + + ); }); }, [actions, sortedData, actionsKeys]); + const title = + pageTitle || + i18n.translate('kibana-react.noDataPage.welcomeTitle', { + defaultMessage: 'Welcome to Elastic {solution}!', + values: { solution }, + }); + return (
- - - -

- {pageTitle || ( - - )} -

- -

- - - - ), - }} - /> -

-
-
- - - - {renderActions} - + {actionsKeys.length > 1 ? ( <> diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/__snapshots__/no_data_page_body.test.tsx.snap b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/__snapshots__/no_data_page_body.test.tsx.snap new file mode 100644 index 000000000000..034e716cb6dc --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/__snapshots__/no_data_page_body.test.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NoDataPageBody render 1`] = ` + + + + +

+ +

+ + + , + "solution": "Elastic", + } + } + /> +

+
+ + + + +

, + ] + } + /> + +`; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/index.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/index.tsx new file mode 100644 index 000000000000..a5312d696139 --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/index.tsx @@ -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 { NoDataPageBody } from './no_data_page_body'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.test.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.test.tsx new file mode 100644 index 000000000000..f3419a47f63b --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.test.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 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 { NoDataPageBody } from './no_data_page_body'; +import React, { ReactElement } from 'react'; +import { shallowWithIntl } from '@kbn/test-jest-helpers'; +import { NoDataCard } from '../no_data_card'; + +describe('NoDataPageBody', () => { + const action = { + recommended: false, + button: 'Button text', + onClick: jest.fn(), + }; + const el = ; + const actionCards: ReactElement[] = []; + actionCards.push(
{el}
); + test('render', () => { + const component = shallowWithIntl( + + ); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.tsx new file mode 100644 index 000000000000..67e123de6888 --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.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 { EuiLink, EuiSpacer, EuiText, EuiTextColor } from '@elastic/eui'; +import React, { ReactElement } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { NoDataPageProps } from '../no_data_page'; +import { KibanaPageTemplateSolutionNavAvatar } from '../../solution_nav'; +import { ActionCards } from '../action_cards'; +import { ElasticAgentCard, NoDataCard } from '../no_data_card'; + +type NoDataPageBodyProps = { + actionCards: Array | ReactElement>; +} & Omit; + +export const NoDataPageBody = (props: NoDataPageBodyProps) => { + const { pageTitle, docsLink, solution, actionCards, logo } = props; + + return ( + <> + + + +

{pageTitle}

+ +

+ + + + ), + }} + /> +

+
+
+ + + + ); +}; diff --git a/src/plugins/kibana_react/public/page_template/page_template.test.tsx b/src/plugins/kibana_react/public/page_template/page_template.test.tsx index 6c6c4bb33e6b..aff6082902a3 100644 --- a/src/plugins/kibana_react/public/page_template/page_template.test.tsx +++ b/src/plugins/kibana_react/public/page_template/page_template.test.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, render } from 'enzyme'; import { KibanaPageTemplate, KibanaPageTemplateProps } from './page_template'; import { EuiEmptyPrompt } from '@elastic/eui'; import { KibanaPageTemplateSolutionNavProps } from './solution_nav'; @@ -104,7 +104,7 @@ describe('KibanaPageTemplate', () => { }); test('render basic template', () => { - const component = shallow( + const component = render( { }); test('render solutionNav', () => { - const component = shallow( + const component = render( { /> ); expect(component).toMatchSnapshot(); + expect(component.find('div.kbnPageTemplate__pageSideBar').length).toBe(1); }); test('render noDataContent', () => { @@ -167,8 +168,6 @@ describe('KibanaPageTemplate', () => { pageSideBarProps={{ className: 'customClass' }} /> ); - expect(component.prop('pageSideBarProps').className).toEqual( - 'kbnPageTemplate__pageSideBar customClass' - ); + expect(component.html().includes('kbnPageTemplate__pageSideBar customClass')).toBe(true); }); }); diff --git a/src/plugins/kibana_react/public/page_template/page_template.tsx b/src/plugins/kibana_react/public/page_template/page_template.tsx index cf2b27c3b00d..77469b240a19 100644 --- a/src/plugins/kibana_react/public/page_template/page_template.tsx +++ b/src/plugins/kibana_react/public/page_template/page_template.tsx @@ -6,25 +6,18 @@ * Side Public License, v 1. */ -/* eslint-disable @typescript-eslint/naming-convention */ import './page_template.scss'; -import React, { FunctionComponent, useState } from 'react'; -import classNames from 'classnames'; +import React, { FunctionComponent } from 'react'; +import { EuiPageTemplateProps } from '@elastic/eui'; +import { KibanaPageTemplateSolutionNavProps } from './solution_nav'; import { - EuiEmptyPrompt, - EuiPageTemplate, - EuiPageTemplateProps, - useIsWithinBreakpoints, -} from '@elastic/eui'; - -import { - KibanaPageTemplateSolutionNav, - KibanaPageTemplateSolutionNavProps, -} from './solution_nav/solution_nav'; - -import { NoDataPage, NoDataPageProps, NO_DATA_PAGE_TEMPLATE_PROPS } from './no_data_page'; + NoDataPageProps, + NoDataConfigPage, + NoDataConfigPageWithSolutionNavBar, +} from './no_data_page'; +import { KibanaPageTemplateInner, KibanaPageTemplateWithSolutionNav } from './page_template_inner'; /** * A thin wrapper around EuiPageTemplate with a few Kibana specific additions @@ -51,119 +44,53 @@ export type KibanaPageTemplateProps = EuiPageTemplateProps & { export const KibanaPageTemplate: FunctionComponent = ({ template, className, - pageHeader, children, - isEmptyState, - restrictWidth = true, - pageSideBar, - pageSideBarProps, solutionNav, noDataConfig, ...rest }) => { /** - * Only default to open in large+ breakpoints - */ - const isMediumBreakpoint = useIsWithinBreakpoints(['m']); - const isLargerBreakpoint = useIsWithinBreakpoints(['l', 'xl']); - - /** - * Create the solution nav component + * If passing the custom template of `noDataConfig` */ - const [isSideNavOpenOnDesktop, setisSideNavOpenOnDesktop] = useState( - JSON.parse(String(localStorage.getItem('solutionNavIsCollapsed'))) ? false : true - ); - const toggleOpenOnDesktop = () => { - setisSideNavOpenOnDesktop(!isSideNavOpenOnDesktop); - // Have to store it as the opposite of the default we want - localStorage.setItem('solutionNavIsCollapsed', JSON.stringify(isSideNavOpenOnDesktop)); - }; - let sideBarClasses = 'kbnPageTemplate__pageSideBar'; - if (solutionNav) { - // Only apply shrinking classes if collapsibility is available through `solutionNav` - sideBarClasses = classNames(sideBarClasses, { - 'kbnPageTemplate__pageSideBar--shrink': - isMediumBreakpoint || (isLargerBreakpoint && !isSideNavOpenOnDesktop), - }); - - pageSideBar = ( - ); } - /** - * An easy way to create the right content for empty pages - */ - const emptyStateDefaultTemplate = pageSideBar ? 'centeredContent' : 'centeredBody'; - if (isEmptyState && pageHeader && !children) { - template = template ?? emptyStateDefaultTemplate; - const { iconType, pageTitle, description, rightSideItems } = pageHeader; - pageHeader = undefined; - children = ( - {pageTitle} : undefined} - body={description ?

{description}

: undefined} - actions={rightSideItems} + if (noDataConfig) { + return ( + ); - } else if (isEmptyState && pageHeader && children) { - template = template ?? 'centeredContent'; - } else if (isEmptyState && !pageHeader) { - template = template ?? emptyStateDefaultTemplate; } - // Set the template before the classes - template = noDataConfig ? NO_DATA_PAGE_TEMPLATE_PROPS.template : template; - - const classes = classNames( - 'kbnPageTemplate', - { [`kbnPageTemplate--${template}`]: template }, - className - ); - - /** - * If passing the custom template of `noDataConfig` - */ - if (noDataConfig) { + if (solutionNav) { return ( - - - + className={className} + solutionNav={solutionNav} + children={children} + {...rest} + /> ); } return ( - - {children} - + /> ); }; diff --git a/src/plugins/kibana_react/public/page_template/page_template_inner.tsx b/src/plugins/kibana_react/public/page_template/page_template_inner.tsx new file mode 100644 index 000000000000..3060a77c781c --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/page_template_inner.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, { FunctionComponent } from 'react'; + +import { EuiEmptyPrompt, EuiPageTemplate } from '@elastic/eui'; +import { withSolutionNav } from './with_solution_nav'; +import { KibanaPageTemplateProps } from './page_template'; +import { getClasses } from './util'; + +type Props = KibanaPageTemplateProps; + +/** + * A thin wrapper around EuiPageTemplate with a few Kibana specific additions + */ +export const KibanaPageTemplateInner: FunctionComponent = ({ + template, + className, + pageHeader, + children, + isEmptyState, + ...rest +}) => { + /** + * An easy way to create the right content for empty pages + */ + const emptyStateDefaultTemplate = 'centeredBody'; + if (isEmptyState && pageHeader && !children) { + template = template ?? emptyStateDefaultTemplate; + const { iconType, pageTitle, description, rightSideItems } = pageHeader; + pageHeader = undefined; + children = ( + {pageTitle} : undefined} + body={description ?

{description}

: undefined} + actions={rightSideItems} + /> + ); + } else if (isEmptyState && pageHeader && children) { + template = template ?? 'centeredContent'; + } else if (isEmptyState && !pageHeader) { + template = template ?? emptyStateDefaultTemplate; + } + + const classes = getClasses(template, className); + + return ( + + {children} + + ); +}; + +export const KibanaPageTemplateWithSolutionNav = withSolutionNav(KibanaPageTemplateInner); diff --git a/src/plugins/kibana_react/public/page_template/util/constants.ts b/src/plugins/kibana_react/public/page_template/util/constants.ts new file mode 100644 index 000000000000..159a6d0d8d4c --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/util/constants.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 { KibanaPageTemplateProps } from '../page_template'; + +export const NO_DATA_PAGE_MAX_WIDTH = 950; + +export const NO_DATA_PAGE_TEMPLATE_PROPS: KibanaPageTemplateProps = { + restrictWidth: NO_DATA_PAGE_MAX_WIDTH, + template: 'centeredBody', + pageContentProps: { + hasShadow: false, + color: 'transparent', + }, +}; diff --git a/src/plugins/kibana_react/public/page_template/util/index.ts b/src/plugins/kibana_react/public/page_template/util/index.ts new file mode 100644 index 000000000000..adfefdf83456 --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/util/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { getClasses } from './presentation'; +export * from './constants'; diff --git a/src/plugins/kibana_react/public/page_template/util/presentation.ts b/src/plugins/kibana_react/public/page_template/util/presentation.ts new file mode 100644 index 000000000000..ab7144ee37b5 --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/util/presentation.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 classNames from 'classnames'; + +export const getClasses = (template: string | undefined, className: string | undefined) => { + return classNames('kbnPageTemplate', { [`kbnPageTemplate--${template}`]: template }, className); +}; diff --git a/src/plugins/kibana_react/public/page_template/with_solution_nav.tsx b/src/plugins/kibana_react/public/page_template/with_solution_nav.tsx new file mode 100644 index 000000000000..9ea1aa1bcd5b --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/with_solution_nav.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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, { ComponentType, useState } from 'react'; +import classNames from 'classnames'; +import { useIsWithinBreakpoints } from '@elastic/eui'; +import { EuiPageSideBarProps } from '@elastic/eui/src/components/page/page_side_bar'; +import { + KibanaPageTemplateSolutionNav, + KibanaPageTemplateSolutionNavProps, +} from '../page_template/solution_nav'; +import { KibanaPageTemplateProps } from '../page_template'; + +type SolutionNavProps = KibanaPageTemplateProps & { + solutionNav: KibanaPageTemplateSolutionNavProps; +}; + +const SOLUTION_NAV_COLLAPSED_KEY = 'solutionNavIsCollapsed'; + +export const withSolutionNav = (WrappedComponent: ComponentType) => { + const WithSolutionNav = (props: SolutionNavProps) => { + const isMediumBreakpoint = useIsWithinBreakpoints(['m']); + const isLargerBreakpoint = useIsWithinBreakpoints(['l', 'xl']); + const [isSideNavOpenOnDesktop, setisSideNavOpenOnDesktop] = useState( + !JSON.parse(String(localStorage.getItem(SOLUTION_NAV_COLLAPSED_KEY))) + ); + const { solutionNav, ...propagatedProps } = props; + const { children, isEmptyState, template } = propagatedProps; + const toggleOpenOnDesktop = () => { + setisSideNavOpenOnDesktop(!isSideNavOpenOnDesktop); + // Have to store it as the opposite of the default we want + localStorage.setItem(SOLUTION_NAV_COLLAPSED_KEY, JSON.stringify(isSideNavOpenOnDesktop)); + }; + const sideBarClasses = classNames( + 'kbnPageTemplate__pageSideBar', + { + 'kbnPageTemplate__pageSideBar--shrink': + isMediumBreakpoint || (isLargerBreakpoint && !isSideNavOpenOnDesktop), + }, + props.pageSideBarProps?.className + ); + + const templateToUse = isEmptyState && !template ? 'centeredContent' : template; + + const pageSideBar = ( + + ); + const pageSideBarProps = { + paddingSize: 'none', + ...props.pageSideBarProps, + className: sideBarClasses, + } as EuiPageSideBarProps; // needed because for some reason 'none' is not recognized as a valid value for paddingSize + return ( + + {children} + + ); + }; + WithSolutionNav.displayName = `WithSolutionNavBar${WrappedComponent}`; + return WithSolutionNav; +}; diff --git a/src/plugins/kibana_react/public/use_execution_context/index.ts b/src/plugins/kibana_react/public/use_execution_context/index.ts new file mode 100644 index 000000000000..f36d094eb86d --- /dev/null +++ b/src/plugins/kibana_react/public/use_execution_context/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 { useExecutionContext } from './use_execution_context'; diff --git a/src/plugins/kibana_react/public/use_execution_context/use_execution_context.ts b/src/plugins/kibana_react/public/use_execution_context/use_execution_context.ts new file mode 100644 index 000000000000..e2c538056153 --- /dev/null +++ b/src/plugins/kibana_react/public/use_execution_context/use_execution_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 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 { KibanaExecutionContext, CoreStart } from 'kibana/public'; +import useDeepCompareEffect from 'react-use/lib/useDeepCompareEffect'; + +/** + * Set and clean up application level execution context + * @param executionContext + * @param context + */ +export function useExecutionContext( + executionContext: CoreStart['executionContext'], + context: KibanaExecutionContext +) { + useDeepCompareEffect(() => { + executionContext.set(context); + + return () => { + executionContext.clear(); + }; + }, [context]); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts index adfe8da335a1..30b3bd4b4e40 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -158,6 +158,9 @@ export const applicationUsageSchema = { security_logout: commonSchema, security_overwritten_session: commonSchema, securitySolutionUI: commonSchema, + /** + * @deprecated legacy key for users that still have bookmarks to the old siem name. "securitySolutionUI" key is the replacement + */ siem: commonSchema, space_selector: commonSchema, uptime: commonSchema, 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 bde8a10e4dd2..7ad6cf697d10 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -124,6 +124,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'securitySolution:enableCcsWarning': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'search:includeFrozen': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, @@ -172,7 +176,6 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, - // eslint-disable-next-line @typescript-eslint/naming-convention 'doc_table:hideTimeColumn': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, @@ -436,6 +439,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'observability:enableServiceGroups': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'banners:placement': { type: 'keyword', _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 47656c568bf4..86ca596925f7 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -41,6 +41,7 @@ export interface UsageStats { 'observability:maxSuggestions': number; 'observability:enableComparisonByDefault': boolean; 'observability:enableInfrastructureView': boolean; + 'observability:enableServiceGroups': boolean; 'visualize:enableLabs': boolean; 'visualization:heatmap:maxBuckets': number; 'visualization:colorMapping': string; @@ -59,6 +60,7 @@ export interface UsageStats { 'securitySolution:defaultAnomalyScore': number; 'securitySolution:refreshIntervalDefaults': string; 'securitySolution:enableNewsFeed': boolean; + 'securitySolution:enableCcsWarning': boolean; 'search:includeFrozen': boolean; 'courier:maxConcurrentShardRequests': number; 'courier:setRequestPreference': string; @@ -71,7 +73,6 @@ export interface UsageStats { 'notifications:lifetime:error': number; 'doc_table:highlight': boolean; 'discover:searchOnPageLoad': boolean; - // eslint-disable-next-line @typescript-eslint/naming-convention 'doc_table:hideTimeColumn': boolean; 'discover:sampleSize': number; defaultColumns: string[]; diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/schema.ts index 5252ab24395a..b0db5e6534c6 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/schema.ts @@ -54,14 +54,17 @@ const uiMetricFromDataPluginSchema: MakeSchemaFrom = { security_login: commonSchema, security_logout: commonSchema, security_overwritten_session: commonSchema, - securitySolution: commonSchema, - 'securitySolution:overview': commonSchema, - 'securitySolution:detections': commonSchema, - 'securitySolution:hosts': commonSchema, - 'securitySolution:network': commonSchema, - 'securitySolution:timelines': commonSchema, - 'securitySolution:case': commonSchema, - 'securitySolution:administration': commonSchema, + securitySolutionUI: commonSchema, + 'securitySolutionUI:overview': commonSchema, + 'securitySolutionUI:detections': commonSchema, + 'securitySolutionUI:hosts': commonSchema, + 'securitySolutionUI:network': commonSchema, + 'securitySolutionUI:timelines': commonSchema, + 'securitySolutionUI:case': commonSchema, + 'securitySolutionUI:administration': commonSchema, + /** + * @deprecated legacy key for users that still have bookmarks to the old siem name. "securitySolutionUI" key is the replacement + */ siem: commonSchema, space_selector: commonSchema, uptime: commonSchema, diff --git a/src/plugins/maps_ems/common/ems_defaults.ts b/src/plugins/maps_ems/common/ems_defaults.ts index 6d2d97ded0fc..7b964c10ab06 100644 --- a/src/plugins/maps_ems/common/ems_defaults.ts +++ b/src/plugins/maps_ems/common/ems_defaults.ts @@ -8,7 +8,7 @@ export const DEFAULT_EMS_FILE_API_URL = 'https://vector.maps.elastic.co'; export const DEFAULT_EMS_TILE_API_URL = 'https://tiles.maps.elastic.co'; -export const DEFAULT_EMS_LANDING_PAGE_URL = 'https://maps.elastic.co/v8.0'; +export const DEFAULT_EMS_LANDING_PAGE_URL = 'https://maps.elastic.co/v8.1'; export const DEFAULT_EMS_FONT_LIBRARY_URL = 'https://tiles.maps.elastic.co/fonts/{fontstack}/{range}.pbf'; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx index b74fe5249e66..1c55519e2325 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx @@ -24,6 +24,8 @@ export interface TopNavMenuData { isLoading?: boolean; iconType?: string; iconSide?: EuiButtonProps['iconSide']; + target?: string; + href?: string; } export interface RegisteredTopNavMenuData extends TopNavMenuData { diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index 721a0fae0e62..495e5c4ac959 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -50,12 +50,19 @@ export function TopNavMenuItem(props: TopNavMenuData) { className: props.className, }; + // If the item specified a href, then override the suppress the onClick + // and make it become a regular link + const overrideProps = + props.target && props.href + ? { onClick: undefined, href: props.href, target: props.target } + : {}; + const btn = props.emphasize ? ( {getButtonContainer()} ) : ( - + {getButtonContainer()} ); diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx index fa678dbdaae8..141a5c16d7d9 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx @@ -10,7 +10,6 @@ import React, { ReactElement } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { - AddFromLibraryButton, QuickButtonGroup, PrimaryActionButton, SolutionToolbarButton, @@ -23,8 +22,9 @@ import './solution_toolbar.scss'; interface NamedSlots { primaryActionButton: ReactElement; quickButtonGroup?: ReactElement; - addFromLibraryButton?: ReactElement; - extraButtons?: Array>; + extraButtons?: Array< + ReactElement | undefined + >; } export interface Props { @@ -33,12 +33,7 @@ export interface Props { } export const SolutionToolbar = ({ isDarkModeEnabled, children }: Props) => { - const { - primaryActionButton, - quickButtonGroup, - addFromLibraryButton, - extraButtons = [], - } = children; + const { primaryActionButton, quickButtonGroup, extraButtons = [] } = children; const extra = extraButtons.map((button, index) => button ? ( @@ -61,9 +56,6 @@ export const SolutionToolbar = ({ isDarkModeEnabled, children }: Props) => { {quickButtonGroup ? {quickButtonGroup} : null} {extra} - {addFromLibraryButton ? ( - {addFromLibraryButton} - ) : null} diff --git a/src/plugins/presentation_util/public/services/create/dependency_manager.test.ts b/src/plugins/presentation_util/public/services/create/dependency_manager.test.ts index 29702c335686..8e67dee3f8b6 100644 --- a/src/plugins/presentation_util/public/services/create/dependency_manager.test.ts +++ b/src/plugins/presentation_util/public/services/create/dependency_manager.test.ts @@ -24,6 +24,16 @@ describe('DependencyManager', () => { expect(DependencyManager.orderDependencies(graph)).toEqual(sortedTopology); }); + it('should include final vertex if it has dependencies', () => { + const graph = { + A: [], + B: [], + C: ['A', 'B'], + }; + const sortedTopology = ['A', 'B', 'C']; + expect(DependencyManager.orderDependencies(graph)).toEqual(sortedTopology); + }); + it('orderDependencies. Should return base topology if no depended vertices', () => { const graph = { N: [], @@ -34,22 +44,34 @@ describe('DependencyManager', () => { expect(DependencyManager.orderDependencies(graph)).toEqual(sortedTopology); }); - it('orderDependencies. Should detect circular dependencies and throw error with path', () => { - const graph = { - N: ['R'], - R: ['A'], - A: ['B'], - B: ['C'], - C: ['D'], - D: ['E'], - E: ['F'], - F: ['L'], - L: ['G'], - G: ['N'], - }; - const circularPath = ['N', 'R', 'A', 'B', 'C', 'D', 'E', 'F', 'L', 'G', 'N'].join(' -> '); - const errorMessage = `Circular dependency detected while setting up services: ${circularPath}`; + describe('circular dependencies', () => { + it('should detect circular dependencies and throw error with path', () => { + const graph = { + N: ['R'], + R: ['A'], + A: ['B'], + B: ['C'], + C: ['D'], + D: ['E'], + E: ['F'], + F: ['L'], + L: ['G'], + G: ['N'], + }; + const circularPath = ['G', 'L', 'F', 'E', 'D', 'C', 'B', 'A', 'R', 'N'].join(' -> '); + const errorMessage = `Circular dependency detected while setting up services: ${circularPath}`; + + expect(() => DependencyManager.orderDependencies(graph)).toThrowError(errorMessage); + }); + + it('should detect circular dependency if circular reference is the first dependency for a vertex', () => { + const graph = { + A: ['B'], + B: ['A', 'C'], + C: [], + }; - expect(() => DependencyManager.orderDependencies(graph)).toThrowError(errorMessage); + expect(() => DependencyManager.orderDependencies(graph)).toThrow(); + }); }); }); diff --git a/src/plugins/presentation_util/public/services/create/dependency_manager.ts b/src/plugins/presentation_util/public/services/create/dependency_manager.ts index de30b180607f..3925f3e9d9c4 100644 --- a/src/plugins/presentation_util/public/services/create/dependency_manager.ts +++ b/src/plugins/presentation_util/public/services/create/dependency_manager.ts @@ -41,7 +41,14 @@ export class DependencyManager { return cycleInfo; } - return DependencyManager.sortVerticesFrom(srcVertex, graph, sortedVertices, {}, {}); + return DependencyManager.sortVerticesFrom( + srcVertex, + graph, + sortedVertices, + {}, + {}, + cycleInfo + ); }, DependencyManager.createCycleInfo()); } @@ -58,24 +65,30 @@ export class DependencyManager { graph: Graph, sortedVertices: Set, visited: BreadCrumbs = {}, - inpath: BreadCrumbs = {} + inpath: BreadCrumbs = {}, + cycle: CycleDetectionResult ): CycleDetectionResult { visited[srcVertex] = true; inpath[srcVertex] = true; - const cycleInfo = graph[srcVertex]?.reduce | undefined>( - (info, vertex) => { - if (inpath[vertex]) { - const path = (Object.keys(inpath) as T[]).filter( - (visitedVertex) => inpath[visitedVertex] - ); - return DependencyManager.createCycleInfo([...path, vertex], true); - } else if (!visited[vertex]) { - return DependencyManager.sortVerticesFrom(vertex, graph, sortedVertices, visited, inpath); - } - return info; - }, - undefined - ); + + const vertexEdges = + graph[srcVertex] === undefined || graph[srcVertex] === null ? [] : graph[srcVertex]; + + cycle = vertexEdges!.reduce>((info, vertex) => { + if (inpath[vertex]) { + return { ...info, hasCycle: true }; + } else if (!visited[vertex]) { + return DependencyManager.sortVerticesFrom( + vertex, + graph, + sortedVertices, + visited, + inpath, + info + ); + } + return info; + }, cycle); inpath[srcVertex] = false; @@ -83,7 +96,10 @@ export class DependencyManager { sortedVertices.add(srcVertex); } - return cycleInfo ?? DependencyManager.createCycleInfo([...sortedVertices]); + return { + ...cycle, + path: [...sortedVertices], + }; } private static createCycleInfo( diff --git a/src/plugins/shared_ux/public/components/index.ts b/src/plugins/shared_ux/public/components/index.ts index 108b2f4b2154..82648193e7a9 100644 --- a/src/plugins/shared_ux/public/components/index.ts +++ b/src/plugins/shared_ux/public/components/index.ts @@ -19,6 +19,14 @@ export const LazyExitFullScreenButton = React.lazy(() => })) ); +export const LazySolutionToolbarButton = React.lazy(() => + import('./toolbar/index').then(({ SolutionToolbarButton }) => ({ + default: SolutionToolbarButton, + })) +); + +export const RedirectAppLinks = React.lazy(() => import('./redirect_app_links')); + /** * A `ExitFullScreenButton` component that is wrapped by the `withSuspense` HOC. This component can * be used directly by consumers and will load the `LazyExitFullScreenButton` component lazily with @@ -26,6 +34,13 @@ export const LazyExitFullScreenButton = React.lazy(() => */ export const ExitFullScreenButton = withSuspense(LazyExitFullScreenButton); +/** + * A `SolutionToolbarButton` component that is wrapped by the `withSuspense` HOC. This component can + * be used directly by consumers and will load the `LazySolutionToolbarButton` component lazily with + * a predefined fallback and error boundary. + */ +export const SolutionToolbarButton = withSuspense(LazySolutionToolbarButton); + /** * The Lazily-loaded `NoDataViews` component. Consumers should use `React.Suspennse` or the * `withSuspense` HOC to load this component. diff --git a/src/plugins/shared_ux/public/components/page_template/index.tsx b/src/plugins/shared_ux/public/components/page_template/index.tsx new file mode 100644 index 000000000000..1f686183c2ed --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/index.tsx @@ -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 { NoDataCard } from './no_data_page/no_data_card'; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/no_data_card.test.tsx.snap b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/no_data_card.test.tsx.snap new file mode 100644 index 000000000000..fccbbe3a9e8e --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/no_data_card.test.tsx.snap @@ -0,0 +1,155 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NoDataCard props button 1`] = ` +
+
+ + Card title + +
+

+ Description +

+
+
+ +
+`; + +exports[`NoDataCard props href 1`] = ` +
+
+ + + Card title + + +
+

+ Description +

+
+
+ +
+`; + +exports[`NoDataCard props recommended 1`] = ` +
+
+ + Card title + +
+

+ Description +

+
+
+ + + Recommended + + +
+`; + +exports[`NoDataCard renders 1`] = ` +
+
+ + Card title + +
+

+ Description +

+
+
+
+`; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/index.ts b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/index.ts new file mode 100644 index 000000000000..5f226aba691a --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/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 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 { NoDataCard } from './no_data_card'; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.stories.tsx b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.stories.tsx new file mode 100644 index 000000000000..6f496d50d727 --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.stories.tsx @@ -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 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 { NoDataCard } from './no_data_card'; +import type { NoDataCardProps } from './types'; + +export default { + title: 'No Data Card', + description: 'A wrapper around EuiCard, to be used on NoData page', +}; + +type Params = Pick; + +export const PureComponent = (params: Params) => { + return ( +
+ +
+ ); +}; + +PureComponent.argTypes = { + recommended: { + control: 'boolean', + defaultValue: false, + }, + button: { + control: { + type: 'text', + }, + defaultValue: 'Button text', + }, + description: { + control: { + type: 'text', + }, + defaultValue: 'This is a description', + }, +}; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.test.tsx b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.test.tsx new file mode 100644 index 000000000000..a809ede2dc61 --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.test.tsx @@ -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 { render } from 'enzyme'; +import React from 'react'; +import { NoDataCard } from './no_data_card'; + +describe('NoDataCard', () => { + test('renders', () => { + const component = render(); + expect(component).toMatchSnapshot(); + }); + + describe('props', () => { + test('recommended', () => { + const component = render( + + ); + expect(component).toMatchSnapshot(); + }); + + test('button', () => { + const component = render( + + ); + expect(component).toMatchSnapshot(); + }); + + test('href', () => { + const component = render( + + ); + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.tsx b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.tsx new file mode 100644 index 000000000000..9f545985065d --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.tsx @@ -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 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, { FunctionComponent } from 'react'; +import { EuiButton, EuiCard } from '@elastic/eui'; +import type { NoDataCardProps } from './types'; + +const recommendedLabel = i18n.translate('sharedUX.pageTemplate.noDataPage.recommendedLabel', { + defaultMessage: 'Recommended', +}); + +const defaultDescription = i18n.translate('sharedUX.pageTemplate.noDataCard.description', { + defaultMessage: `Proceed without collecting data`, +}); + +export const NoDataCard: FunctionComponent = ({ + recommended, + title, + button, + description, + ...cardRest +}) => { + const footer = () => { + if (typeof button !== 'string') { + return button; + } + return {button || title}; + }; + const label = recommended ? recommendedLabel : undefined; + const cardDescription = description || defaultDescription; + + return ( + + ); +}; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/types.ts b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/types.ts new file mode 100644 index 000000000000..e08d9fdeaaa3 --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/types.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 { EuiCardProps } from '@elastic/eui'; +import { MouseEventHandler, ReactNode } from 'react'; + +export type NoDataCardProps = Partial> & { + /** + * Applies the `Recommended` beta badge and makes the button `fill` + */ + recommended?: boolean; + /** + * Provide just a string for the button's label, or a whole component + */ + button?: string | ReactNode; + /** + * Remapping `onClick` to any element + */ + onClick?: MouseEventHandler; + /** + * Description for the card. If not provided, the default will be used. + */ + description?: string; +}; diff --git a/src/plugins/shared_ux/public/components/redirect_app_links/click_handler.test.ts b/src/plugins/shared_ux/public/components/redirect_app_links/click_handler.test.ts new file mode 100644 index 000000000000..1569203c394d --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/click_handler.test.ts @@ -0,0 +1,211 @@ +/* + * Copyright 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 { ApplicationStart } from 'src/core/public'; +import { createNavigateToUrlClickHandler } from './click_handler'; + +const createLink = ({ + href = '/base-path/app/targetApp', + target = '', +}: { href?: string; target?: string } = {}): HTMLAnchorElement => { + const el = document.createElement('a'); + if (href) { + el.href = href; + } + el.target = target; + return el; +}; + +const createEvent = ({ + target = createLink(), + button = 0, + defaultPrevented = false, + modifierKey = false, +}: { + target?: HTMLElement; + button?: number; + defaultPrevented?: boolean; + modifierKey?: boolean; +}): MouseEvent => { + return { + target, + button, + defaultPrevented, + ctrlKey: modifierKey, + preventDefault: jest.fn(), + } as unknown as MouseEvent; +}; + +describe('createNavigateToUrlClickHandler', () => { + let container: HTMLElement; + let navigateToUrl: jest.MockedFunction; + + const createHandler = () => + createNavigateToUrlClickHandler({ + container, + navigateToUrl, + }); + + beforeEach(() => { + container = document.createElement('div'); + navigateToUrl = jest.fn(); + }); + + it('calls `navigateToUrl` with the link url', () => { + const handler = createHandler(); + + const event = createEvent({ + target: createLink({ href: '/base-path/app/targetApp' }), + }); + handler(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(navigateToUrl).toHaveBeenCalledWith('http://localhost/base-path/app/targetApp'); + }); + + 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); + expect(navigateToUrl).toHaveBeenCalledWith('http://localhost/base-path/app/targetApp'); + }); + + 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(); + expect(navigateToUrl).not.toHaveBeenCalled(); + }); + + 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(); + expect(navigateToUrl).not.toHaveBeenCalled(); + }); + + 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(); + expect(navigateToUrl).not.toHaveBeenCalled(); + + event = createEvent({ + target: createLink({ target: 'some-target' }), + }); + handler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + + event = createEvent({ + target: createLink({ target: '_self' }), + }); + handler(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(navigateToUrl).toHaveBeenCalledTimes(1); + + event = createEvent({ + target: createLink({ target: '' }), + }); + handler(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(navigateToUrl).toHaveBeenCalledTimes(2); + }); + + it('is only triggered from left clicks', () => { + const handler = createHandler(); + + let event = createEvent({ + button: 1, + }); + handler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + + event = createEvent({ + button: 12, + }); + handler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + + event = createEvent({ + button: 0, + }); + handler(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(navigateToUrl).toHaveBeenCalledTimes(1); + }); + + 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(); + expect(navigateToUrl).not.toHaveBeenCalled(); + + event = createEvent({ + defaultPrevented: false, + }); + handler(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(navigateToUrl).toHaveBeenCalledTimes(1); + }); + + 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); + expect(navigateToUrl).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugins/shared_ux/public/components/redirect_app_links/click_handler.ts b/src/plugins/shared_ux/public/components/redirect_app_links/click_handler.ts new file mode 100644 index 000000000000..89b057ffd0ea --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/click_handler.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { ApplicationStart } from 'src/core/public'; +import { getClosestLink, hasActiveModifierKey } from '../utility/utils'; + +interface CreateCrossAppClickHandlerOptions { + navigateToUrl: ApplicationStart['navigateToUrl']; + 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/src/plugins/shared_ux/public/components/redirect_app_links/index.ts b/src/plugins/shared_ux/public/components/redirect_app_links/index.ts new file mode 100644 index 000000000000..e5f05f2c7074 --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/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 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/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.mdx b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.mdx new file mode 100644 index 000000000000..0023182940ae --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.mdx @@ -0,0 +1,12 @@ +--- +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/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.stories.tsx b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.stories.tsx new file mode 100644 index 000000000000..0ca0e2a8d997 --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.stories.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 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/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.test.tsx b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.test.tsx new file mode 100644 index 000000000000..d2cf5aa664cc --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.test.tsx @@ -0,0 +1,237 @@ +/* + * Copyright 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 { applicationServiceMock } from '../../../../../core/public/mocks'; +import { RedirectAppLinks } from './redirect_app_links'; +import { BehaviorSubject } from 'rxjs'; + +/* eslint-disable jsx-a11y/click-events-have-key-events */ + +describe('RedirectAppLinks', () => { + let application: ReturnType; + + beforeEach(() => { + application = applicationServiceMock.createStartContract(); + application.currentAppId$ = new BehaviorSubject('currentApp'); + }); + + it('intercept click events on children link elements', () => { + let event: MouseEvent; + const component = mount( +
+ ); + + 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/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.tsx b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.tsx new file mode 100644 index 000000000000..6354914684fb --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FunctionComponent, useRef, useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { ApplicationStart } from 'src/core/public'; +import { createNavigateToUrlClickHandler } from './click_handler'; + +type Props = React.HTMLAttributes & + Pick; + +export interface RedirectAppLinksProps extends Props { + className?: string; + 'data-test-subj'?: string; +} + +/** + * 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: FunctionComponent = ({ + 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/src/plugins/shared_ux/public/components/toolbar/index.ts b/src/plugins/shared_ux/public/components/toolbar/index.ts new file mode 100644 index 000000000000..de15e73eaade --- /dev/null +++ b/src/plugins/shared_ux/public/components/toolbar/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 { SolutionToolbarButton } from './solution_toolbar/button/primary'; diff --git a/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/__snapshots__/primary.test.tsx.snap b/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/__snapshots__/primary.test.tsx.snap new file mode 100644 index 000000000000..1d7e3acb0b76 --- /dev/null +++ b/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/__snapshots__/primary.test.tsx.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` is rendered 1`] = ` + + + + + + + + + +`; diff --git a/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/primary.mdx b/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/primary.mdx new file mode 100644 index 000000000000..6693277b370a --- /dev/null +++ b/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/primary.mdx @@ -0,0 +1,12 @@ +--- +id: sharedUX/Components/SolutionToolbarButton +slug: /shared-ux/components/toolbar/solution_toolbar/button/primary +title: Solution Toolbar Button +summary: An opinionated implementation of the toolbar extracted to just the button. +tags: ['shared-ux', 'component'] +date: 2022-02-17 +--- + +> This documentation is in-progress. + +This button is a part of the solution toolbar component. This button has primary styling and requires a label. OnClick handlers and icon types are supported as an extension of EuiButtonProps. Icons are always on the left of any labels within the button. diff --git a/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/primary.stories.tsx b/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/primary.stories.tsx new file mode 100644 index 000000000000..56c15ec7749a --- /dev/null +++ b/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/primary.stories.tsx @@ -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 { Story } from '@storybook/react'; +import React from 'react'; +import { SolutionToolbarButton } from './primary'; +import mdx from './primary.mdx'; + +export default { + title: 'Solution Toolbar Button', + description: 'A button that is a part of the solution toolbar.', + parameters: { + docs: { + page: mdx, + }, + }, + argTypes: { + iconType: { + control: { + type: 'radio', + expanded: true, + options: ['apps', 'logoGithub', 'folderCheck', 'documents'], + }, + }, + }, +}; + +export const Component: Story<{ + iconType: any; +}> = ({ iconType }) => { + return ; +}; + +Component.args = { + iconType: 'apps', +}; diff --git a/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/primary.test.tsx b/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/primary.test.tsx new file mode 100644 index 000000000000..c2e5fd1ce7ab --- /dev/null +++ b/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/primary.test.tsx @@ -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 { mount as enzymeMount, ReactWrapper } from 'enzyme'; +import React from 'react'; +import { ServicesProvider, SharedUXServices } from '../../../../services'; +import { servicesFactory } from '../../../../services/mocks'; + +import { SolutionToolbarButton } from './primary'; + +describe('', () => { + let services: SharedUXServices; + let mount: (element: JSX.Element) => ReactWrapper; + + beforeEach(() => { + services = servicesFactory(); + mount = (element: JSX.Element) => + enzymeMount({element}); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('is rendered', () => { + const component = mount(); + + expect(component).toMatchSnapshot(); + }); + test('it can be passed a functional onClick handler', () => { + const mockHandler = jest.fn(); + const component = mount(); + component.simulate('click'); + expect(mockHandler).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/primary.tsx b/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/primary.tsx new file mode 100644 index 000000000000..b99af852ed7e --- /dev/null +++ b/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/primary.tsx @@ -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 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 { EuiButton } from '@elastic/eui'; +import { EuiButtonPropsForButton } from '@elastic/eui/src/components/button/button'; + +export interface Props extends Pick { + label: string; +} + +export const SolutionToolbarButton = ({ label, ...rest }: Props) => { + return ( + + {label} + + ); +}; diff --git a/src/plugins/shared_ux/public/components/utility/utils.test.ts b/src/plugins/shared_ux/public/components/utility/utils.test.ts new file mode 100644 index 000000000000..2c04038d253a --- /dev/null +++ b/src/plugins/shared_ux/public/components/utility/utils.test.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 { getClosestLink } from './utils'; + +const createBranch = (...tags: string[]): HTMLElement[] => { + const elements: HTMLElement[] = []; + let parent: HTMLElement | undefined; + for (const tag of tags) { + const element = document.createElement(tag); + elements.push(element); + if (parent) { + parent.appendChild(element); + } + parent = element; + } + + return elements; +}; + +describe('getClosestLink', () => { + it(`returns the element itself if it's a link`, () => { + const [target] = createBranch('A'); + expect(getClosestLink(target)).toBe(target); + }); + + it('returns the closest parent that is a link', () => { + const [, , link, , target] = createBranch('A', 'DIV', 'A', 'DIV', 'SPAN'); + expect(getClosestLink(target)).toBe(link); + }); + + it('returns undefined if the closest link is further than the container', () => { + const [, container, target] = createBranch('A', 'DIV', 'SPAN'); + expect(getClosestLink(target, container)).toBe(undefined); + }); +}); diff --git a/src/plugins/shared_ux/public/components/utility/utils.ts b/src/plugins/shared_ux/public/components/utility/utils.ts new file mode 100644 index 000000000000..0ac501d16081 --- /dev/null +++ b/src/plugins/shared_ux/public/components/utility/utils.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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'; + +/** + * Returns true if any modifier key is active on the event, false otherwise. + */ +export const hasActiveModifierKey = (event: React.MouseEvent): boolean => { + return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey; +}; + +/** + * Returns the closest anchor (``) element in the element parents (self included) up to the given container (excluded), or undefined if none is found. + */ +export const getClosestLink = ( + element: HTMLElement | null | undefined, + container?: HTMLElement +): HTMLAnchorElement | undefined => { + let current = element; + do { + if (current?.tagName.toLowerCase() === 'a') { + return current as HTMLAnchorElement; + } + const parent = current?.parentElement; + if (!parent || parent === document.body || parent === container) { + break; + } + current = parent; + } while (parent || parent !== document.body || parent !== container); + return undefined; +}; diff --git a/src/plugins/telemetry/kibana.json b/src/plugins/telemetry/kibana.json index 09cc6accb68f..a6796e42f922 100644 --- a/src/plugins/telemetry/kibana.json +++ b/src/plugins/telemetry/kibana.json @@ -8,6 +8,7 @@ "server": true, "ui": true, "requiredPlugins": ["telemetryCollectionManager", "usageCollection", "screenshotMode"], + "optionalPlugins": ["home", "security"], "extraPublicDirs": ["common/constants"], "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/telemetry/public/plugin.ts b/src/plugins/telemetry/public/plugin.ts index 3072ff67703d..794183cb8a8f 100644 --- a/src/plugins/telemetry/public/plugin.ts +++ b/src/plugins/telemetry/public/plugin.ts @@ -31,6 +31,8 @@ import { } from '../common/telemetry_config'; import { getNotifyUserAboutOptInDefault } from '../common/telemetry_config/get_telemetry_notify_user_about_optin_default'; import { PRIVACY_STATEMENT_URL } from '../common/constants'; +import { HomePublicPluginSetup } from '../../home/public'; +import { renderWelcomeTelemetryNotice } from './render_welcome_telemetry_notice'; /** * Publicly exposed APIs from the Telemetry Service @@ -82,6 +84,7 @@ export interface TelemetryPluginStart { interface TelemetryPluginSetupDependencies { screenshotMode: ScreenshotModePluginSetup; + home?: HomePublicPluginSetup; } /** @@ -121,7 +124,7 @@ export class TelemetryPlugin implements Plugin { + if (this.telemetryService?.userCanChangeSettings) { + this.telemetryNotifications?.setOptedInNoticeSeen(); + } + }); + + home.welcomeScreen.registerTelemetryNoticeRenderer(() => + renderWelcomeTelemetryNotice(this.telemetryService!, http.basePath.prepend) + ); + } + return { telemetryService: this.getTelemetryServicePublicApis(), }; diff --git a/src/plugins/telemetry/public/render_welcome_telemetry_notice.test.ts b/src/plugins/telemetry/public/render_welcome_telemetry_notice.test.ts new file mode 100644 index 000000000000..6da76db91565 --- /dev/null +++ b/src/plugins/telemetry/public/render_welcome_telemetry_notice.test.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 { mountWithIntl } from '@kbn/test-jest-helpers'; +import { renderWelcomeTelemetryNotice } from './render_welcome_telemetry_notice'; +import { mockTelemetryService } from './mocks'; + +describe('renderWelcomeTelemetryNotice', () => { + test('it should show the opt-out message', () => { + const telemetryService = mockTelemetryService(); + const component = mountWithIntl(renderWelcomeTelemetryNotice(telemetryService, (url) => url)); + expect(component.exists('[id="telemetry.dataManagementDisableCollectionLink"]')).toBe(true); + }); + + test('it should show the opt-in message', () => { + const telemetryService = mockTelemetryService({ config: { optIn: false } }); + const component = mountWithIntl(renderWelcomeTelemetryNotice(telemetryService, (url) => url)); + expect(component.exists('[id="telemetry.dataManagementEnableCollectionLink"]')).toBe(true); + }); + + test('it should not show opt-in/out options if user cannot change the settings', () => { + const telemetryService = mockTelemetryService({ config: { allowChangingOptInStatus: false } }); + const component = mountWithIntl(renderWelcomeTelemetryNotice(telemetryService, (url) => url)); + expect(component.exists('[id="telemetry.dataManagementDisableCollectionLink"]')).toBe(false); + expect(component.exists('[id="telemetry.dataManagementEnableCollectionLink"]')).toBe(false); + }); +}); diff --git a/src/plugins/telemetry/public/render_welcome_telemetry_notice.tsx b/src/plugins/telemetry/public/render_welcome_telemetry_notice.tsx new file mode 100644 index 000000000000..8ef26fb797d5 --- /dev/null +++ b/src/plugins/telemetry/public/render_welcome_telemetry_notice.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 from 'react'; +import { EuiLink, EuiSpacer, EuiTextColor } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { TelemetryService } from './services'; +import { PRIVACY_STATEMENT_URL } from '../common/constants'; + +export function renderWelcomeTelemetryNotice( + telemetryService: TelemetryService, + addBasePath: (url: string) => string +) { + return ( + <> + + + + + + {renderTelemetryEnabledOrDisabledText(telemetryService, addBasePath)} + + + + ); +} + +function renderTelemetryEnabledOrDisabledText( + telemetryService: TelemetryService, + addBasePath: (url: string) => string +) { + if (!telemetryService.userCanChangeSettings || !telemetryService.getCanChangeOptInStatus()) { + return null; + } + + const isOptedIn = telemetryService.getIsOptedIn(); + + if (isOptedIn) { + return ( + <> + + + + + + ); + } else { + return ( + <> + + + + + + ); + } +} diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index dcbf91969824..8545f6b39d86 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7337,6 +7337,12 @@ "description": "Non-default value of setting." } }, + "securitySolution:enableCcsWarning": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "search:includeFrozen": { "type": "boolean", "_meta": { @@ -7808,6 +7814,12 @@ "description": "Non-default value of setting." } }, + "observability:enableServiceGroups": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "banners:placement": { "type": "keyword", "_meta": { @@ -8869,7 +8881,26 @@ } } }, - "securitySolution:overview": { + "securitySolutionUI": { + "type": "array", + "items": { + "properties": { + "key": { + "type": "keyword", + "_meta": { + "description": "The event that is tracked" + } + }, + "value": { + "type": "long", + "_meta": { + "description": "The value of the event" + } + } + } + } + }, + "securitySolutionUI:overview": { "type": "array", "items": { "properties": { @@ -8888,7 +8919,7 @@ } } }, - "securitySolution:detections": { + "securitySolutionUI:detections": { "type": "array", "items": { "properties": { @@ -8907,7 +8938,7 @@ } } }, - "securitySolution:hosts": { + "securitySolutionUI:hosts": { "type": "array", "items": { "properties": { @@ -8926,7 +8957,7 @@ } } }, - "securitySolution:network": { + "securitySolutionUI:network": { "type": "array", "items": { "properties": { @@ -8945,7 +8976,7 @@ } } }, - "securitySolution:timelines": { + "securitySolutionUI:timelines": { "type": "array", "items": { "properties": { @@ -8964,7 +8995,7 @@ } } }, - "securitySolution:case": { + "securitySolutionUI:case": { "type": "array", "items": { "properties": { @@ -8983,7 +9014,7 @@ } } }, - "securitySolution:administration": { + "securitySolutionUI:administration": { "type": "array", "items": { "properties": { diff --git a/src/plugins/telemetry/server/plugin.ts b/src/plugins/telemetry/server/plugin.ts index 73c61ea1c503..681a871ba105 100644 --- a/src/plugins/telemetry/server/plugin.ts +++ b/src/plugins/telemetry/server/plugin.ts @@ -23,6 +23,7 @@ import type { Plugin, Logger, } from 'src/core/server'; +import type { SecurityPluginStart } from '../../../../x-pack/plugins/security/server'; import { SavedObjectsClient } from '../../../core/server'; import { registerRoutes } from './routes'; import { registerCollection } from './telemetry_collection'; @@ -42,6 +43,7 @@ interface TelemetryPluginsDepsSetup { interface TelemetryPluginsDepsStart { telemetryCollectionManager: TelemetryCollectionManagerPluginStart; + security?: SecurityPluginStart; } /** @@ -90,6 +92,8 @@ export class TelemetryPlugin implements Plugin(1); + private security?: SecurityPluginStart; + constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); this.isDev = initializerContext.env.mode.dev; @@ -119,6 +123,7 @@ export class TelemetryPlugin implements Plugin this.security, }); this.registerMappings((opts) => savedObjects.registerType(opts)); @@ -137,11 +142,17 @@ export class TelemetryPlugin implements Plugin; + getSecurity: SecurityGetter; } export function registerRoutes(options: RegisterRoutesParams) { - const { isDev, telemetryCollectionManager, router, savedObjectsInternalClient$ } = options; + const { isDev, telemetryCollectionManager, router, savedObjectsInternalClient$, getSecurity } = + options; registerTelemetryOptInRoutes(options); - registerTelemetryUsageStatsRoutes(router, telemetryCollectionManager, isDev); + registerTelemetryUsageStatsRoutes(router, telemetryCollectionManager, isDev, getSecurity); registerTelemetryOptInStatsRoutes(router, telemetryCollectionManager); registerTelemetryUserHasSeenNotice(router); registerTelemetryLastReported(router, savedObjectsInternalClient$); diff --git a/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts index 2a9566566219..6139eee3e10c 100644 --- a/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts +++ b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts @@ -75,7 +75,6 @@ export function registerTelemetryOptInStatsRoutes( const statsGetterConfig: StatsGetterConfig = { unencrypted, - request: req, }; const optInStatus = await telemetryCollectionManager.getOptInStats( diff --git a/src/plugins/telemetry/server/routes/telemetry_usage_stats.test.ts b/src/plugins/telemetry/server/routes/telemetry_usage_stats.test.ts index 736367446d3c..bc7569585c12 100644 --- a/src/plugins/telemetry/server/routes/telemetry_usage_stats.test.ts +++ b/src/plugins/telemetry/server/routes/telemetry_usage_stats.test.ts @@ -8,7 +8,8 @@ import { registerTelemetryUsageStatsRoutes } from './telemetry_usage_stats'; import { coreMock, httpServerMock } from 'src/core/server/mocks'; -import type { RequestHandlerContext, IRouter } from 'kibana/server'; +import type { RequestHandlerContext, IRouter } from 'src/core/server'; +import { securityMock } from '../../../../../x-pack/plugins/security/server/mocks'; import { telemetryCollectionManagerPluginMock } from '../../../telemetry_collection_manager/server/mocks'; async function runRequest( @@ -35,13 +36,18 @@ describe('registerTelemetryUsageStatsRoutes', () => { }; const telemetryCollectionManager = telemetryCollectionManagerPluginMock.createSetupContract(); const mockCoreSetup = coreMock.createSetup(); - const mockRouter = mockCoreSetup.http.createRouter(); const mockStats = [{ clusterUuid: 'text', stats: 'enc_str' }]; telemetryCollectionManager.getStats.mockResolvedValue(mockStats); + const getSecurity = jest.fn(); + + let mockRouter: IRouter; + beforeEach(() => { + mockRouter = mockCoreSetup.http.createRouter(); + }); describe('clusters/_stats POST route', () => { it('registers _stats POST route and accepts body configs', () => { - registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true); + registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true, getSecurity); expect(mockRouter.post).toBeCalledTimes(1); const [routeConfig, handler] = (mockRouter.post as jest.Mock).mock.calls[0]; expect(routeConfig.path).toMatchInlineSnapshot(`"/api/telemetry/v2/clusters/_stats"`); @@ -50,11 +56,10 @@ describe('registerTelemetryUsageStatsRoutes', () => { }); it('responds with encrypted stats with no cache refresh by default', async () => { - registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true); + registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true, getSecurity); - const { mockRequest, mockResponse } = await runRequest(mockRouter); + const { mockResponse } = await runRequest(mockRouter); expect(telemetryCollectionManager.getStats).toBeCalledWith({ - request: mockRequest, unencrypted: undefined, refreshCache: undefined, }); @@ -63,39 +68,99 @@ describe('registerTelemetryUsageStatsRoutes', () => { }); it('when unencrypted is set getStats is called with unencrypted and refreshCache', async () => { - registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true); + registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true, getSecurity); - const { mockRequest } = await runRequest(mockRouter, { unencrypted: true }); + await runRequest(mockRouter, { unencrypted: true }); expect(telemetryCollectionManager.getStats).toBeCalledWith({ - request: mockRequest, unencrypted: true, refreshCache: true, }); }); it('calls getStats with refreshCache when set in body', async () => { - registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true); - const { mockRequest } = await runRequest(mockRouter, { refreshCache: true }); + registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true, getSecurity); + await runRequest(mockRouter, { refreshCache: true }); expect(telemetryCollectionManager.getStats).toBeCalledWith({ - request: mockRequest, unencrypted: undefined, refreshCache: true, }); }); it('calls getStats with refreshCache:true even if set to false in body when unencrypted is set to true', async () => { - registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true); - const { mockRequest } = await runRequest(mockRouter, { + registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true, getSecurity); + await runRequest(mockRouter, { refreshCache: false, unencrypted: true, }); expect(telemetryCollectionManager.getStats).toBeCalledWith({ - request: mockRequest, unencrypted: true, refreshCache: true, }); }); + it('returns 403 when the user does not have enough permissions to request unencrypted telemetry', async () => { + const getSecurityMock = jest.fn().mockImplementation(() => { + const securityStartMock = securityMock.createStart(); + securityStartMock.authz.checkPrivilegesWithRequest.mockReturnValue({ + globally: () => ({ hasAllRequested: false }), + }); + return securityStartMock; + }); + registerTelemetryUsageStatsRoutes( + mockRouter, + telemetryCollectionManager, + true, + getSecurityMock + ); + const { mockResponse } = await runRequest(mockRouter, { + refreshCache: false, + unencrypted: true, + }); + expect(mockResponse.forbidden).toBeCalled(); + }); + + it('returns 200 when the user has enough permissions to request unencrypted telemetry', async () => { + const getSecurityMock = jest.fn().mockImplementation(() => { + const securityStartMock = securityMock.createStart(); + securityStartMock.authz.checkPrivilegesWithRequest.mockReturnValue({ + globally: () => ({ hasAllRequested: true }), + }); + return securityStartMock; + }); + registerTelemetryUsageStatsRoutes( + mockRouter, + telemetryCollectionManager, + true, + getSecurityMock + ); + const { mockResponse } = await runRequest(mockRouter, { + refreshCache: false, + unencrypted: true, + }); + expect(mockResponse.ok).toBeCalled(); + }); + + it('returns 200 when the user does not have enough permissions to request unencrypted telemetry but it requests encrypted', async () => { + const getSecurityMock = jest.fn().mockImplementation(() => { + const securityStartMock = securityMock.createStart(); + securityStartMock.authz.checkPrivilegesWithRequest.mockReturnValue({ + globally: () => ({ hasAllRequested: false }), + }); + return securityStartMock; + }); + registerTelemetryUsageStatsRoutes( + mockRouter, + telemetryCollectionManager, + true, + getSecurityMock + ); + const { mockResponse } = await runRequest(mockRouter, { + refreshCache: false, + unencrypted: false, + }); + expect(mockResponse.ok).toBeCalled(); + }); + it.todo('always returns an empty array on errors on encrypted payload'); it.todo('returns the actual request error object when in development mode'); it.todo('returns forbidden on unencrypted and ES returns 403 in getStats'); diff --git a/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts b/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts index 2f72ae818f11..4647f5afe076 100644 --- a/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts +++ b/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts @@ -12,11 +12,15 @@ import { TelemetryCollectionManagerPluginSetup, StatsGetterConfig, } from 'src/plugins/telemetry_collection_manager/server'; +import type { SecurityPluginStart } from '../../../../../x-pack/plugins/security/server'; + +export type SecurityGetter = () => SecurityPluginStart | undefined; export function registerTelemetryUsageStatsRoutes( router: IRouter, telemetryCollectionManager: TelemetryCollectionManagerPluginSetup, - isDev: boolean + isDev: boolean, + getSecurity: SecurityGetter ) { router.post( { @@ -31,9 +35,22 @@ export function registerTelemetryUsageStatsRoutes( async (context, req, res) => { const { unencrypted, refreshCache } = req.body; + const security = getSecurity(); + if (security && unencrypted) { + // Normally we would use `options: { tags: ['access:decryptedTelemetry'] }` in the route definition to check authorization for an + // API action, however, we want to check this conditionally based on the `unencrypted` parameter. In this case we need to use the + // security API directly to check privileges for this action. Note that the 'decryptedTelemetry' API privilege string is only + // granted to users that have "Global All" or "Global Read" privileges in Kibana. + const { checkPrivilegesWithRequest, actions } = security.authz; + const privileges = { kibana: actions.api.get('decryptedTelemetry') }; + const { hasAllRequested } = await checkPrivilegesWithRequest(req).globally(privileges); + if (!hasAllRequested) { + return res.forbidden(); + } + } + try { const statsConfig: StatsGetterConfig = { - request: req, unencrypted, refreshCache: unencrypted || refreshCache, }; diff --git a/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts index 83f33a894b90..4340eaafd2d8 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts @@ -7,10 +7,9 @@ */ import { omit } from 'lodash'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; -import { StatsCollectionContext } from 'src/plugins/telemetry_collection_manager/server'; -import { ElasticsearchClient } from 'src/core/server'; +import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import type { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import type { StatsCollectionContext } from 'src/plugins/telemetry_collection_manager/server'; export interface KibanaUsageStats { kibana: { @@ -71,9 +70,8 @@ export function handleKibanaStats( export async function getKibana( usageCollection: UsageCollectionSetup, asInternalUser: ElasticsearchClient, - soClient: SavedObjectsClientContract, - kibanaRequest: KibanaRequest | undefined // intentionally `| undefined` to enforce providing the parameter + soClient: SavedObjectsClientContract ): Promise { - const usage = await usageCollection.bulkFetch(asInternalUser, soClient, kibanaRequest); + const usage = await usageCollection.bulkFetch(asInternalUser, soClient); return usageCollection.toObject(usage); } diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts index 2392ac570ecb..fa45438e00fb 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts @@ -14,7 +14,7 @@ import { usageCollectionPluginMock, createCollectorFetchContextMock, } from '../../../usage_collection/server/mocks'; -import { elasticsearchServiceMock, httpServerMock } from '../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { StatsCollectionConfig } from '../../../telemetry_collection_manager/server'; function mockUsageCollection(kibanaUsage = {}) { @@ -74,7 +74,6 @@ function mockStatsCollectionConfig( ...createCollectorFetchContextMock(), esClient: mockGetLocalStats(clusterInfo, clusterStats), usageCollection: mockUsageCollection(kibana), - kibanaRequest: httpServerMock.createKibanaRequest(), refreshCache: false, }; } diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts index ae2a849ccfa1..73de59ae8156 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts @@ -65,7 +65,7 @@ export const getLocalStats: StatsGetter = async ( config, context ) => { - const { usageCollection, esClient, soClient, kibanaRequest } = config; + const { usageCollection, esClient, soClient } = config; return await Promise.all( clustersDetails.map(async (clustersDetail) => { @@ -73,7 +73,7 @@ export const getLocalStats: StatsGetter = async ( getClusterInfo(esClient), // cluster info getClusterStats(esClient), // cluster stats (not to be confused with cluster _state_) getNodesUsage(esClient), // nodes_usage info - getKibana(usageCollection, esClient, soClient, kibanaRequest), + getKibana(usageCollection, esClient, soClient), getDataTelemetry(esClient), ]); return handleLocalStats( diff --git a/src/plugins/telemetry/tsconfig.json b/src/plugins/telemetry/tsconfig.json index d50ccd563fe5..052d484447e4 100644 --- a/src/plugins/telemetry/tsconfig.json +++ b/src/plugins/telemetry/tsconfig.json @@ -17,10 +17,12 @@ ], "references": [ { "path": "../../core/tsconfig.json" }, + { "path": "../../plugins/home/tsconfig.json" }, { "path": "../../plugins/kibana_react/tsconfig.json" }, { "path": "../../plugins/kibana_utils/tsconfig.json" }, { "path": "../../plugins/screenshot_mode/tsconfig.json" }, { "path": "../../plugins/telemetry_collection_manager/tsconfig.json" }, - { "path": "../../plugins/usage_collection/tsconfig.json" } + { "path": "../../plugins/usage_collection/tsconfig.json" }, + { "path": "../../../x-pack/plugins/security/tsconfig.json" } ] } diff --git a/src/plugins/telemetry_collection_manager/server/plugin.test.ts b/src/plugins/telemetry_collection_manager/server/plugin.test.ts index ca932e92d98b..990e237b6b27 100644 --- a/src/plugins/telemetry_collection_manager/server/plugin.test.ts +++ b/src/plugins/telemetry_collection_manager/server/plugin.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { coreMock, httpServerMock } from '../../../core/server/mocks'; +import { coreMock } from '../../../core/server/mocks'; import { usageCollectionPluginMock } from '../../usage_collection/server/mocks'; import { TelemetryCollectionManagerPlugin } from './plugin'; import type { BasicStatsPayload, CollectionStrategyConfig, StatsGetterConfig } from './types'; @@ -217,19 +217,17 @@ describe('Telemetry Collection Manager', () => { }); }); describe('unencrypted: true', () => { - const mockRequest = httpServerMock.createKibanaRequest(); const config: StatsGetterConfig = { unencrypted: true, - request: mockRequest, }; describe('getStats', () => { - test('getStats returns empty because clusterDetails returns empty, and the soClient is not an instance of the TelemetrySavedObjectsClient', async () => { + test('getStats returns empty because clusterDetails returns empty, and the soClient is an instance of the TelemetrySavedObjectsClient', async () => { collectionStrategy.clusterDetailsGetter.mockResolvedValue([]); await expect(setupApi.getStats(config)).resolves.toStrictEqual([]); expect( collectionStrategy.clusterDetailsGetter.mock.calls[0][0].soClient - ).not.toBeInstanceOf(TelemetrySavedObjectsClient); + ).toBeInstanceOf(TelemetrySavedObjectsClient); }); test('returns encrypted payload (assumes opted-in when no explicitly opted-out)', async () => { collectionStrategy.clusterDetailsGetter.mockResolvedValue([ @@ -249,7 +247,7 @@ describe('Telemetry Collection Manager', () => { expect( collectionStrategy.clusterDetailsGetter.mock.calls[0][0].soClient - ).not.toBeInstanceOf(TelemetrySavedObjectsClient); + ).toBeInstanceOf(TelemetrySavedObjectsClient); }); it('calls getStats with config { refreshCache: true } even if set to false', async () => { @@ -267,7 +265,6 @@ describe('Telemetry Collection Manager', () => { expect(getStatsCollectionConfig).toReturnWith( expect.objectContaining({ refreshCache: true, - kibanaRequest: mockRequest, }) ); @@ -281,7 +278,7 @@ describe('Telemetry Collection Manager', () => { await expect(setupApi.getOptInStats(true, config)).resolves.toStrictEqual([]); expect( collectionStrategy.clusterDetailsGetter.mock.calls[0][0].soClient - ).not.toBeInstanceOf(TelemetrySavedObjectsClient); + ).toBeInstanceOf(TelemetrySavedObjectsClient); }); test('returns results for opt-in true', async () => { @@ -296,7 +293,7 @@ describe('Telemetry Collection Manager', () => { ]); expect( collectionStrategy.clusterDetailsGetter.mock.calls[0][0].soClient - ).not.toBeInstanceOf(TelemetrySavedObjectsClient); + ).toBeInstanceOf(TelemetrySavedObjectsClient); }); test('returns results for opt-in false', async () => { @@ -311,7 +308,7 @@ describe('Telemetry Collection Manager', () => { ]); expect( collectionStrategy.clusterDetailsGetter.mock.calls[0][0].soClient - ).not.toBeInstanceOf(TelemetrySavedObjectsClient); + ).toBeInstanceOf(TelemetrySavedObjectsClient); }); }); }); diff --git a/src/plugins/telemetry_collection_manager/server/plugin.ts b/src/plugins/telemetry_collection_manager/server/plugin.ts index fad51ca1dbfd..cffe736f8eea 100644 --- a/src/plugins/telemetry_collection_manager/server/plugin.ts +++ b/src/plugins/telemetry_collection_manager/server/plugin.ts @@ -126,11 +126,10 @@ export class TelemetryCollectionManagerPlugin const esClient = this.getElasticsearchClient(config); const soClient = this.getSavedObjectsClient(config); // Provide the kibanaRequest so opted-in plugins can scope their custom clients only if the request is not encrypted - const kibanaRequest = config.unencrypted ? config.request : void 0; const refreshCache = config.unencrypted ? true : !!config.refreshCache; if (esClient && soClient) { - return { usageCollection, esClient, soClient, kibanaRequest, refreshCache }; + return { usageCollection, esClient, soClient, refreshCache }; } } @@ -142,9 +141,7 @@ export class TelemetryCollectionManagerPlugin * @private */ private getElasticsearchClient(config: StatsGetterConfig): ElasticsearchClient | undefined { - return config.unencrypted - ? this.elasticsearchClient?.asScoped(config.request).asCurrentUser - : this.elasticsearchClient?.asInternalUser; + return this.elasticsearchClient?.asInternalUser; } /** @@ -155,11 +152,7 @@ export class TelemetryCollectionManagerPlugin * @private */ private getSavedObjectsClient(config: StatsGetterConfig): SavedObjectsClientContract | undefined { - if (config.unencrypted) { - // Intentionally using the scoped client here to make use of all the security wrappers. - // It also returns spaces-scoped telemetry. - return this.savedObjectsService?.getScopedClient(config.request); - } else if (this.savedObjectsService) { + if (this.savedObjectsService) { // Wrapping the internalRepository with the `TelemetrySavedObjectsClient` // to ensure some best practices when collecting "all the telemetry" // (i.e.: `.find` requests should query all spaces) diff --git a/src/plugins/telemetry_collection_manager/server/types.ts b/src/plugins/telemetry_collection_manager/server/types.ts index 7ea32844a858..9658c0d68d05 100644 --- a/src/plugins/telemetry_collection_manager/server/types.ts +++ b/src/plugins/telemetry_collection_manager/server/types.ts @@ -6,14 +6,9 @@ * Side Public License, v 1. */ -import { - ElasticsearchClient, - Logger, - KibanaRequest, - SavedObjectsClientContract, -} from 'src/core/server'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { TelemetryCollectionManagerPlugin } from './plugin'; +import type { ElasticsearchClient, Logger, SavedObjectsClientContract } from 'src/core/server'; +import type { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import type { TelemetryCollectionManagerPlugin } from './plugin'; export interface TelemetryCollectionManagerPluginSetup { setCollectionStrategy: ( @@ -36,7 +31,6 @@ export interface TelemetryOptInStats { export interface BaseStatsGetterConfig { unencrypted: boolean; refreshCache?: boolean; - request?: KibanaRequest; } export interface EncryptedStatsGetterConfig extends BaseStatsGetterConfig { @@ -45,7 +39,6 @@ export interface EncryptedStatsGetterConfig extends BaseStatsGetterConfig { export interface UnencryptedStatsGetterConfig extends BaseStatsGetterConfig { unencrypted: true; - request: KibanaRequest; } export interface ClusterDetails { @@ -56,7 +49,6 @@ export interface StatsCollectionConfig { usageCollection: UsageCollectionSetup; esClient: ElasticsearchClient; soClient: SavedObjectsClientContract; - kibanaRequest: KibanaRequest | undefined; // intentionally `| undefined` to enforce providing the parameter refreshCache: boolean; } diff --git a/src/plugins/usage_collection/README.mdx b/src/plugins/usage_collection/README.mdx index a58f197818bf..03d8f7badb8c 100644 --- a/src/plugins/usage_collection/README.mdx +++ b/src/plugins/usage_collection/README.mdx @@ -297,8 +297,7 @@ Some background: - `isReady` (added in v7.2.0 and v6.8.4) is a way for a usage collector to announce that some async process must finish first before it can return data in the `fetch` method (e.g. a client needs to ne initialized, or the task manager needs to run a task first). If any collector reports that it is not ready when we call its `fetch` method, we reset a flag to try again and, after a set amount of time, collect data from those collectors that are ready and skip any that are not. This means that if a collector returns `true` for `isReady` and it actually isn't ready to return data, there won't be telemetry data from that collector in that telemetry report (usually once per day). You should consider what it means if your collector doesn't return data in the first few documents when Kibana starts or, if we should wait for any other reason (e.g. the task manager needs to run your task first). If you need to tell telemetry collection to wait, you should implement this function with custom logic. If your `fetch` method can run without the need of any previous dependencies, then you can return true for `isReady` as shown in the example below. -- The `fetch` method needs to support multiple contexts in which it is called. For example, when a user requests the example of what we collect in the **Kibana>Advanced Settings>Usage data** section, the clients provided in the context of the function (`CollectorFetchContext`) are scoped to that user's privileges. The reason is to avoid exposing via telemetry any data that user should not have access to (i.e.: if the user does not have access to certain indices, they shouldn't be allowed to see the number of documents that exists in it). In this case, the `fetch` method receives the clients `esClient` and `soClient` scoped to the user who performed the HTTP API request. Alternatively, when requesting the usage data to be reported to the Remote Telemetry Service, the clients are scoped to the internal Kibana user (`kibana_system`). Please, mind it might have lower-level access than the default super-admin `elastic` test user. -In some scenarios, your collector might need to maintain its own client. An example of that is the `monitoring` plugin, that maintains a connection to the Remote Monitoring Cluster to push its monitoring data. If that's the case, your plugin can opt-in to receive the additional `kibanaRequest` parameter by adding `extendFetchContext.kibanaRequest: true` to the collector's config: it will be appended to the context of the `fetch` method only if the request needs to be scoped to a user other than Kibana Internal, so beware that your collector will need to work for both scenarios (especially for the scenario when `kibanaRequest` is missing). +- The clients provided to the `fetch` method are scoped to the internal Kibana user (`kibana_system`). Note: there will be many cases where you won't need to use the `esClient` or `soClient` function that gets passed in to your `fetch` method at all. Your feature might have an accumulating value in server memory, or read something from the OS. diff --git a/src/plugins/usage_collection/server/collector/collector.ts b/src/plugins/usage_collection/server/collector/collector.ts index 74373d44a359..1ff04cf3650c 100644 --- a/src/plugins/usage_collection/server/collector/collector.ts +++ b/src/plugins/usage_collection/server/collector/collector.ts @@ -7,20 +7,14 @@ */ import type { Logger } from 'src/core/server'; -import type { - CollectorFetchMethod, - CollectorOptions, - CollectorOptionsFetchExtendedContext, - ICollector, -} from './types'; +import type { CollectorFetchMethod, CollectorOptions, ICollector } from './types'; export class Collector implements ICollector { - public readonly extendFetchContext: CollectorOptionsFetchExtendedContext; - public readonly type: CollectorOptions['type']; - public readonly fetch: CollectorFetchMethod; - public readonly isReady: CollectorOptions['isReady']; + public readonly type: CollectorOptions['type']; + public readonly fetch: CollectorFetchMethod; + public readonly isReady: CollectorOptions['isReady']; /** * @private Constructor of a Collector. It should be called via the CollectorSet factory methods: `makeStatsCollector` and `makeUsageCollector` * @param log {@link Logger} @@ -28,15 +22,7 @@ export class Collector */ constructor( public readonly log: Logger, - { - type, - fetch, - isReady, - extendFetchContext = {}, - ...options - }: // Any does not affect here, but needs to be set so it doesn't affect anything else down the line - // eslint-disable-next-line @typescript-eslint/no-explicit-any - CollectorOptions + { type, fetch, isReady, ...options }: CollectorOptions ) { if (type === undefined) { throw new Error('Collector must be instantiated with a options.type string property'); @@ -50,6 +36,5 @@ export class Collector this.type = type; this.fetch = fetch; this.isReady = typeof isReady === 'function' ? isReady : () => true; - this.extendFetchContext = extendFetchContext; } } 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 5e0698b286f7..87e841f3de4c 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.test.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.test.ts @@ -15,7 +15,6 @@ import { elasticsearchServiceMock, loggingSystemMock, savedObjectsClientMock, - httpServerMock, executionContextServiceMock, } from '../../../../core/server/mocks'; import type { ExecutionContextSetup, Logger } from 'src/core/server'; @@ -39,7 +38,6 @@ describe('CollectorSet', () => { }); const mockEsClient = elasticsearchServiceMock.createClusterClient().asInternalUser; const mockSoClient = savedObjectsClientMock.create(); - const req = void 0; // No need to instantiate any KibanaRequest in these tests it('should throw an error if non-Collector type of object is registered', () => { const collectors = new CollectorSet(collectorSetConfig); @@ -88,7 +86,7 @@ describe('CollectorSet', () => { }) ); - const result = await collectors.bulkFetch(mockEsClient, mockSoClient, req); + const result = await collectors.bulkFetch(mockEsClient, mockSoClient); expect(logger.debug).toHaveBeenCalledTimes(2); expect(logger.debug).toHaveBeenCalledWith('Getting ready collectors'); expect(logger.debug).toHaveBeenCalledWith('Fetching data from MY_TEST_COLLECTOR collector'); @@ -121,7 +119,7 @@ describe('CollectorSet', () => { let result; try { - result = await collectors.bulkFetch(mockEsClient, mockSoClient, req); + result = await collectors.bulkFetch(mockEsClient, mockSoClient); } catch (err) { // Do nothing } @@ -150,7 +148,7 @@ describe('CollectorSet', () => { }) ); - const result = await collectors.bulkFetch(mockEsClient, mockSoClient, req); + const result = await collectors.bulkFetch(mockEsClient, mockSoClient); expect(result).toStrictEqual([ { type: 'MY_TEST_COLLECTOR', @@ -178,7 +176,7 @@ describe('CollectorSet', () => { }) ); - const result = await collectors.bulkFetch(mockEsClient, mockSoClient, req); + const result = await collectors.bulkFetch(mockEsClient, mockSoClient); expect(result).toStrictEqual([ { type: 'MY_TEST_COLLECTOR', @@ -269,50 +267,6 @@ describe('CollectorSet', () => { collectorSet = new CollectorSet(collectorSetConfig); }); - test('TS should hide kibanaRequest when not opted-in', () => { - collectorSet.makeStatsCollector({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - // @ts-expect-error - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - }); - }); - - test('TS should hide kibanaRequest when not opted-in (explicit false)', () => { - collectorSet.makeStatsCollector({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - // @ts-expect-error - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - extendFetchContext: { - kibanaRequest: false, - }, - }); - }); - - test('TS should allow using kibanaRequest when opted-in (explicit true)', () => { - collectorSet.makeStatsCollector({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - extendFetchContext: { - kibanaRequest: true, - }, - }); - }); - test('fetch can use the logger (TS allows it)', () => { const collector = collectorSet.makeStatsCollector({ type: 'MY_TEST_COLLECTOR', @@ -339,188 +293,6 @@ describe('CollectorSet', () => { collectorSet = new CollectorSet(collectorSetConfig); }); - describe('TS validations', () => { - describe('when types are inferred', () => { - test('TS should hide kibanaRequest when not opted-in', () => { - collectorSet.makeUsageCollector({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - // @ts-expect-error - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - }); - }); - - test('TS should hide kibanaRequest when not opted-in (explicit false)', () => { - collectorSet.makeUsageCollector({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - // @ts-expect-error - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - extendFetchContext: { - kibanaRequest: false, - }, - }); - }); - - test('TS should allow using kibanaRequest when opted-in (explicit true)', () => { - collectorSet.makeUsageCollector({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - extendFetchContext: { - kibanaRequest: true, - }, - }); - }); - }); - - describe('when types are explicit', () => { - test('TS should hide `kibanaRequest` from ctx when undefined or false', () => { - collectorSet.makeUsageCollector<{ test: number }>({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - // @ts-expect-error - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - }); - collectorSet.makeUsageCollector<{ test: number }, false>({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - // @ts-expect-error - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - extendFetchContext: { - kibanaRequest: false, - }, - }); - collectorSet.makeUsageCollector<{ test: number }, false>({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - // @ts-expect-error - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - }); - }); - test('TS should not allow `true` when types declare false', () => { - // false is the default when at least 1 type is specified - collectorSet.makeUsageCollector<{ test: number }>({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - // @ts-expect-error - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - extendFetchContext: { - // @ts-expect-error - kibanaRequest: true, - }, - }); - collectorSet.makeUsageCollector<{ test: number }, false>({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - // @ts-expect-error - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - extendFetchContext: { - // @ts-expect-error - kibanaRequest: true, - }, - }); - }); - - test('TS should allow `true` when types explicitly declare `true` and do not allow `false` or undefined', () => { - // false is the default when at least 1 type is specified - collectorSet.makeUsageCollector<{ test: number }, true>({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - extendFetchContext: { - kibanaRequest: true, - }, - }); - collectorSet.makeUsageCollector<{ test: number }, true>({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - extendFetchContext: { - // @ts-expect-error - kibanaRequest: false, - }, - }); - collectorSet.makeUsageCollector<{ test: number }, true>({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - extendFetchContext: { - // @ts-expect-error - kibanaRequest: undefined, - }, - }); - collectorSet.makeUsageCollector<{ test: number }, true>({ - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - // @ts-expect-error - extendFetchContext: {}, - }); - collectorSet.makeUsageCollector<{ test: number }, true>( - // @ts-expect-error - { - type: 'MY_TEST_COLLECTOR', - isReady: () => true, - schema: { test: { type: 'long' } }, - fetch: (ctx) => { - const { kibanaRequest } = ctx; - return { test: kibanaRequest ? 1 : 0 }; - }, - } - ); - }); - }); - }); - test('fetch can use the logger (TS allows it)', () => { const collector = collectorSet.makeUsageCollector({ type: 'MY_TEST_COLLECTOR', @@ -777,31 +549,5 @@ describe('CollectorSet', () => { expect.any(Function) ); }); - - it('adds extra context to collectors with extendFetchContext config', async () => { - const mockReadyFetch = jest.fn().mockResolvedValue({}); - collectorSet.registerCollector( - collectorSet.makeUsageCollector({ - type: 'ready_col', - isReady: () => true, - schema: {}, - fetch: mockReadyFetch, - extendFetchContext: { kibanaRequest: true }, - }) - ); - - const mockEsClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - const mockSoClient = savedObjectsClientMock.create(); - const request = httpServerMock.createKibanaRequest(); - const results = await collectorSet.bulkFetch(mockEsClient, mockSoClient, request); - - expect(mockReadyFetch).toBeCalledTimes(1); - expect(mockReadyFetch).toBeCalledWith({ - esClient: mockEsClient, - soClient: mockSoClient, - kibanaRequest: request, - }); - expect(results).toHaveLength(2); - }); }); }); diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index 49332b0a1826..3a7c0a66ac60 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -11,7 +11,6 @@ import type { Logger, ElasticsearchClient, SavedObjectsClientContract, - KibanaRequest, KibanaExecutionContext, ExecutionContextSetup, } from 'src/core/server'; @@ -64,12 +63,8 @@ export class CollectorSet { * Instantiates a stats collector with the definition provided in the options * @param options Definition of the collector {@link CollectorOptions} */ - public makeStatsCollector = < - TFetchReturn, - WithKibanaRequest extends boolean, - ExtraOptions extends object = {} - >( - options: CollectorOptions + public makeStatsCollector = ( + options: CollectorOptions ) => { return new Collector(this.logger, options); }; @@ -78,15 +73,8 @@ export class CollectorSet { * Instantiates an usage collector with the definition provided in the options * @param options Definition of the collector {@link CollectorOptions} */ - public makeUsageCollector = < - TFetchReturn, - // TODO: Right now, users will need to explicitly claim `true` for TS to allow `kibanaRequest` usage. - // If we improve `telemetry-check-tools` so plugins do not need to specify TFetchReturn, - // we'll be able to remove the type defaults and TS will successfully infer the config value as provided in JS. - WithKibanaRequest extends boolean = false, - ExtraOptions extends object = {} - >( - options: UsageCollectorOptions + public makeUsageCollector = ( + options: UsageCollectorOptions ) => { return new UsageCollector(this.logger, options); }; @@ -191,7 +179,6 @@ export class CollectorSet { public bulkFetch = async ( esClient: ElasticsearchClient, soClient: SavedObjectsClientContract, - kibanaRequest: KibanaRequest | undefined, // intentionally `| undefined` to enforce providing the parameter collectors: Map = this.collectors ) => { this.logger.debug(`Getting ready collectors`); @@ -209,11 +196,7 @@ export class CollectorSet { readyCollectors.map(async (collector) => { this.logger.debug(`Fetching data from ${collector.type} collector`); try { - const context = { - esClient, - soClient, - ...(collector.extendFetchContext.kibanaRequest && { kibanaRequest }), - }; + const context = { esClient, soClient }; const executionContext: KibanaExecutionContext = { type: 'usage_collection', name: 'collector.fetch', @@ -254,16 +237,10 @@ export class CollectorSet { public bulkFetchUsage = async ( esClient: ElasticsearchClient, - savedObjectsClient: SavedObjectsClientContract, - kibanaRequest: KibanaRequest | undefined // intentionally `| undefined` to enforce providing the parameter + savedObjectsClient: SavedObjectsClientContract ) => { const usageCollectors = this.getFilteredCollectorSet((c) => c instanceof UsageCollector); - return await this.bulkFetch( - esClient, - savedObjectsClient, - kibanaRequest, - usageCollectors.collectors - ); + return await this.bulkFetch(esClient, savedObjectsClient, usageCollectors.collectors); }; /** diff --git a/src/plugins/usage_collection/server/collector/index.ts b/src/plugins/usage_collection/server/collector/index.ts index ca240a520ee2..e284844b34c3 100644 --- a/src/plugins/usage_collection/server/collector/index.ts +++ b/src/plugins/usage_collection/server/collector/index.ts @@ -17,7 +17,6 @@ export type { CollectorOptions, CollectorFetchContext, CollectorFetchMethod, - CollectorOptionsFetchExtendedContext, ICollector as Collector, } from './types'; export type { UsageCollectorOptions } from './usage_collector'; diff --git a/src/plugins/usage_collection/server/collector/types.ts b/src/plugins/usage_collection/server/collector/types.ts index bf1e9f4644b1..8d427d211a19 100644 --- a/src/plugins/usage_collection/server/collector/types.ts +++ b/src/plugins/usage_collection/server/collector/types.ts @@ -6,12 +6,7 @@ * Side Public License, v 1. */ -import type { - ElasticsearchClient, - KibanaRequest, - SavedObjectsClientContract, - Logger, -} from 'src/core/server'; +import type { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'src/core/server'; /** Types matching number values **/ export type AllowedSchemaNumberTypes = @@ -73,7 +68,7 @@ export type MakeSchemaFrom = { * * @remark Bear in mind when testing your collector that your user has the same privileges as the Kibana Internal user to ensure the expected data is sent to the remote cluster. */ -export type CollectorFetchContext = { +export interface CollectorFetchContext { /** * Request-scoped Elasticsearch client * @remark Bear in mind when testing your collector that your user has the same privileges as the Kibana Internal user to ensure the expected data is sent to the remote cluster (more info: {@link CollectorFetchContext}) @@ -84,58 +79,22 @@ export type CollectorFetchContext = ( +export type CollectorFetchMethod = ( this: ICollector & ExtraOptions, // Specify the context of `this` for this.log and others to become available - context: CollectorFetchContext + context: CollectorFetchContext ) => Promise | TReturn; -export interface ICollectorOptionsFetchExtendedContext { - /** - * Set to `true` if your `fetch` method requires the `KibanaRequest` object to be added in its context {@link CollectorFetchContextWithRequest}. - * @remark You should fully acknowledge that by using the `KibanaRequest` in your collector, you need to ensure it should specially work without it because it won't be provided when building the telemetry payload actually sent to the remote telemetry service. - */ - kibanaRequest?: WithKibanaRequest; -} - -/** - * The options to extend the context provided to the `fetch` method. - * @remark Only to be used in very rare scenarios when this is really needed. - */ -export type CollectorOptionsFetchExtendedContext = - ICollectorOptionsFetchExtendedContext & - (WithKibanaRequest extends true // If enforced to true via Types, the config must be expected - ? Required, 'kibanaRequest'>> - : {}); - /** * Options to instantiate a collector */ -export type CollectorOptions< - TFetchReturn = unknown, - WithKibanaRequest extends boolean = boolean, - ExtraOptions extends object = {} -> = { +export type CollectorOptions = { /** * Unique string identifier for the collector */ @@ -152,17 +111,8 @@ export type CollectorOptions< * The method that will collect and return the data in the final format. * @param collectorFetchContext {@link CollectorFetchContext} */ - fetch: CollectorFetchMethod; -} & ExtraOptions & - (WithKibanaRequest extends true // If enforced to true via Types, the config must be enforced - ? { - /** {@link CollectorOptionsFetchExtendedContext} **/ - extendFetchContext: CollectorOptionsFetchExtendedContext; - } - : { - /** {@link CollectorOptionsFetchExtendedContext} **/ - extendFetchContext?: CollectorOptionsFetchExtendedContext; - }); + fetch: CollectorFetchMethod; +} & ExtraOptions; /** * Common interface for Usage and Stats Collectors @@ -170,13 +120,8 @@ export type CollectorOptions< export interface ICollector { /** Logger **/ readonly log: Logger; - /** - * The options to extend the context provided to the `fetch` method: {@link CollectorOptionsFetchExtendedContext}. - * @remark Only to be used in very rare scenarios when this is really needed. - */ - readonly extendFetchContext: CollectorOptionsFetchExtendedContext; /** The registered type (aka name) of the collector **/ - readonly type: CollectorOptions['type']; + readonly type: CollectorOptions['type']; /** * The actual logic that reports the Usage collection. * It will be called on every collection request. @@ -188,9 +133,9 @@ export interface ICollector { * [type]: await fetch(context) * } */ - readonly fetch: CollectorFetchMethod; + readonly fetch: CollectorFetchMethod; /** * Should return `true` when it's safe to call the `fetch` method. */ - readonly isReady: CollectorOptions['isReady']; + readonly isReady: CollectorOptions['isReady']; } diff --git a/src/plugins/usage_collection/server/collector/usage_collector.ts b/src/plugins/usage_collection/server/collector/usage_collector.ts index 15f7cd9c627f..2ed8c2a50dba 100644 --- a/src/plugins/usage_collection/server/collector/usage_collector.ts +++ b/src/plugins/usage_collection/server/collector/usage_collector.ts @@ -15,10 +15,9 @@ import { Collector } from './collector'; */ export type UsageCollectorOptions< TFetchReturn = unknown, - WithKibanaRequest extends boolean = false, ExtraOptions extends object = {} -> = CollectorOptions & - Required, 'schema'>>; +> = CollectorOptions & + Required, 'schema'>>; /** * @private Only used in fixtures as a type @@ -27,12 +26,7 @@ export class UsageCollector exte TFetchReturn, ExtraOptions > { - constructor( - log: Logger, - // Needed because it doesn't affect on anything here but being explicit creates a lot of pain down the line - // eslint-disable-next-line @typescript-eslint/no-explicit-any - collectorOptions: UsageCollectorOptions - ) { + constructor(log: Logger, collectorOptions: UsageCollectorOptions) { super(log, collectorOptions); } } diff --git a/src/plugins/usage_collection/server/index.ts b/src/plugins/usage_collection/server/index.ts index 74fa77be9843..907a61a75205 100644 --- a/src/plugins/usage_collection/server/index.ts +++ b/src/plugins/usage_collection/server/index.ts @@ -17,7 +17,6 @@ export type { UsageCollectorOptions, CollectorFetchContext, CollectorFetchMethod, - CollectorOptionsFetchExtendedContext, } from './collector'; export type { diff --git a/src/plugins/usage_collection/server/mocks.ts b/src/plugins/usage_collection/server/mocks.ts index 6f7d4f19cbaf..ac7ad69ed4bc 100644 --- a/src/plugins/usage_collection/server/mocks.ts +++ b/src/plugins/usage_collection/server/mocks.ts @@ -9,7 +9,6 @@ import { elasticsearchServiceMock, executionContextServiceMock, - httpServerMock, loggingSystemMock, savedObjectsClientMock, } from '../../../../src/core/server/mocks'; @@ -45,25 +44,14 @@ export const createUsageCollectionSetupMock = () => { return usageCollectionSetupMock; }; -export function createCollectorFetchContextMock(): jest.Mocked> { - const collectorFetchClientsMock: jest.Mocked> = { +export function createCollectorFetchContextMock(): jest.Mocked { + const collectorFetchClientsMock: jest.Mocked = { esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, soClient: savedObjectsClientMock.create(), }; return collectorFetchClientsMock; } -export function createCollectorFetchContextWithKibanaMock(): jest.Mocked< - CollectorFetchContext -> { - const collectorFetchClientsMock: jest.Mocked> = { - esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - soClient: savedObjectsClientMock.create(), - kibanaRequest: httpServerMock.createKibanaRequest(), - }; - return collectorFetchClientsMock; -} - export const usageCollectionPluginMock = { createSetupContract: createUsageCollectionSetupMock, }; diff --git a/src/plugins/usage_collection/server/plugin.ts b/src/plugins/usage_collection/server/plugin.ts index f415dd768dc2..7cde8bad706d 100644 --- a/src/plugins/usage_collection/server/plugin.ts +++ b/src/plugins/usage_collection/server/plugin.ts @@ -15,7 +15,6 @@ import type { Plugin, ElasticsearchClient, SavedObjectsClientContract, - KibanaRequest, } from 'src/core/server'; import type { ConfigType } from './config'; import { CollectorSet } from './collector'; @@ -39,12 +38,8 @@ export interface UsageCollectionSetup { * Creates a usage collector to collect plugin telemetry data. * registerCollector must be called to connect the created collector with the service. */ - makeUsageCollector: < - TFetchReturn, - WithKibanaRequest extends boolean = false, - ExtraOptions extends object = {} - >( - options: UsageCollectorOptions + makeUsageCollector: ( + options: UsageCollectorOptions ) => Collector; /** * Register a usage collector or a stats collector. @@ -66,7 +61,6 @@ export interface UsageCollectionSetup { bulkFetch: ( esClient: ElasticsearchClient, soClient: SavedObjectsClientContract, - kibanaRequest: KibanaRequest | undefined, // intentionally `| undefined` to enforce providing the parameter collectors?: Map> ) => Promise>; /** @@ -88,12 +82,8 @@ export interface UsageCollectionSetup { * registerCollector must be called to connect the created collector with the service. * @internal: telemetry and monitoring use */ - makeStatsCollector: < - TFetchReturn, - WithKibanaRequest extends boolean, - ExtraOptions extends object = {} - >( - options: CollectorOptions + makeStatsCollector: ( + options: CollectorOptions ) => Collector; } diff --git a/src/plugins/usage_collection/server/routes/stats/stats.ts b/src/plugins/usage_collection/server/routes/stats/stats.ts index 8e5382d16317..72cbd2e5899f 100644 --- a/src/plugins/usage_collection/server/routes/stats/stats.ts +++ b/src/plugins/usage_collection/server/routes/stats/stats.ts @@ -15,7 +15,6 @@ import { first } from 'rxjs/operators'; import { ElasticsearchClient, IRouter, - KibanaRequest, MetricsServiceSetup, SavedObjectsClientContract, ServiceStatus, @@ -55,10 +54,9 @@ export function registerStatsRoute({ }) { const getUsage = async ( esClient: ElasticsearchClient, - savedObjectsClient: SavedObjectsClientContract, - kibanaRequest: KibanaRequest + savedObjectsClient: SavedObjectsClientContract ): Promise => { - const usage = await collectorSet.bulkFetchUsage(esClient, savedObjectsClient, kibanaRequest); + const usage = await collectorSet.bulkFetchUsage(esClient, savedObjectsClient); return collectorSet.toObject(usage); }; @@ -97,7 +95,7 @@ export function registerStatsRoute({ const [usage, clusterUuid] = await Promise.all([ shouldGetUsage - ? getUsage(asCurrentUser, savedObjectsClient, req) + ? getUsage(asCurrentUser, savedObjectsClient) : Promise.resolve({}), getClusterUuid(asCurrentUser), ]); diff --git a/src/plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap b/src/plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap index b25444d16c46..dd9a92326929 100644 --- a/src/plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap +++ b/src/plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap @@ -17,7 +17,7 @@ exports[`DefaultEditorAgg component should init with the default set of props 1` data-test-subj="visEditorAggAccordion1" element="div" extraAction={ -
+ -
+ } id="visEditorAggAccordion1" initialIsOpen={true} diff --git a/src/plugins/vis_default_editor/public/components/__snapshots__/agg_group.test.tsx.snap b/src/plugins/vis_default_editor/public/components/__snapshots__/agg_group.test.tsx.snap index 373ff6b4c3ee..c9c7b91e8fc1 100644 --- a/src/plugins/vis_default_editor/public/components/__snapshots__/agg_group.test.tsx.snap +++ b/src/plugins/vis_default_editor/public/components/__snapshots__/agg_group.test.tsx.snap @@ -23,6 +23,7 @@ exports[`DefaultEditorAgg component should init with the default set of props 1` > { it('should not have actions', () => { const comp = shallow(); - const actions = shallow(comp.prop('extraAction')); + const actions = comp.prop('extraAction'); - expect(actions.children().exists()).toBeFalsy(); + expect(actions).toBeNull(); }); it('should have disable and remove actions', () => { diff --git a/src/plugins/vis_default_editor/public/components/agg.tsx b/src/plugins/vis_default_editor/public/components/agg.tsx index 0c1ddefa59e4..b813519d8caf 100644 --- a/src/plugins/vis_default_editor/public/components/agg.tsx +++ b/src/plugins/vis_default_editor/public/components/agg.tsx @@ -13,7 +13,6 @@ import { EuiButtonIcon, EuiButtonIconProps, EuiSpacer, - EuiIconTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -198,6 +197,7 @@ function DefaultEditorAgg({ if (isDraggable) { actionIcons.push({ id: 'dragHandle', + color: 'text', type: 'grab', tooltip: i18n.translate('visDefaultEditor.agg.modifyPriorityButtonTooltip', { defaultMessage: 'Modify priority of {schemaTitle} {aggTitle} by dragging', @@ -219,39 +219,23 @@ function DefaultEditorAgg({ dataTestSubj: 'removeDimensionBtn', }); } - return ( -
- {actionIcons.map((icon) => { - if (icon.id === 'dragHandle') { - return ( - - ); - } - - return ( - - - - ); - })} -
- ); + return actionIcons.length ? ( + <> + {actionIcons.map((icon) => ( + + + + ))} + + ) : null; }; const buttonContent = ( diff --git a/src/plugins/vis_default_editor/public/components/agg_group.tsx b/src/plugins/vis_default_editor/public/components/agg_group.tsx index 03b06056c649..4d2ae0277766 100644 --- a/src/plugins/vis_default_editor/public/components/agg_group.tsx +++ b/src/plugins/vis_default_editor/public/components/agg_group.tsx @@ -153,6 +153,7 @@ function DefaultEditorAggGroup({ index={index} draggableId={`agg_group_dnd_${groupName}_${agg.id}`} customDragHandle={true} + disableInteractiveElementBlocking // Allows button to be drag handle > {(provided) => ( ; diff --git a/src/plugins/vis_types/gauge/jest.config.js b/src/plugins/vis_types/gauge/jest.config.js new file mode 100644 index 000000000000..87fd58fd42db --- /dev/null +++ b/src/plugins/vis_types/gauge/jest.config.js @@ -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 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: ['/src/plugins/vis_types/gauge'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/vis_types/gauge', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/vis_types/gauge/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/src/plugins/vis_types/gauge/kibana.json b/src/plugins/vis_types/gauge/kibana.json new file mode 100755 index 000000000000..5eb2794452de --- /dev/null +++ b/src/plugins/vis_types/gauge/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "visTypeGauge", + "version": "1.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["charts", "data", "expressions", "visualizations"], + "requiredBundles": ["visDefaultEditor"], + "optionalPlugins": ["expressionGauge"], + "extraPublicDirs": ["common/index"], + "owner": { + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" + }, + "description": "Contains the gauge chart implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy charts library advanced setting." +} diff --git a/src/plugins/vis_types/gauge/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/gauge/public/__snapshots__/to_ast.test.ts.snap new file mode 100644 index 000000000000..dbc909f9ede2 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/__snapshots__/to_ast.test.ts.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`gauge vis toExpressionAst function with minimal params 1`] = ` +Object { + "chain": Array [ + Object { + "arguments": Object { + "aggs": Array [], + "index": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "id": Array [ + "123", + ], + }, + "function": "indexPatternLoad", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "metricsAtAllLevels": Array [ + false, + ], + "partialRows": Array [ + false, + ], + }, + "function": "esaggs", + "type": "function", + }, + Object { + "arguments": Object { + "centralMajorMode": Array [ + "custom", + ], + "colorMode": Array [ + "palette", + ], + "labelMajorMode": Array [ + "auto", + ], + "labelMinor": Array [ + "some custom sublabel", + ], + "metric": Array [], + "shape": Array [ + "circle", + ], + "ticksPosition": Array [ + "hidden", + ], + }, + "function": "gauge", + "type": "function", + }, + ], + "type": "expression", +} +`; diff --git a/src/plugins/vis_types/vislib/public/editor/collections.ts b/src/plugins/vis_types/gauge/public/editor/collections.ts similarity index 67% rename from src/plugins/vis_types/vislib/public/editor/collections.ts rename to src/plugins/vis_types/gauge/public/editor/collections.ts index e7905ccaf1c2..3f52ffbead01 100644 --- a/src/plugins/vis_types/vislib/public/editor/collections.ts +++ b/src/plugins/vis_types/gauge/public/editor/collections.ts @@ -7,21 +7,18 @@ */ import { i18n } from '@kbn/i18n'; - import { colorSchemas } from '../../../../charts/public'; -import { getPositions, getScaleTypes } from '../../../xy/public'; - import { Alignment, GaugeType } from '../types'; export const getGaugeTypes = () => [ { - text: i18n.translate('visTypeVislib.gauge.gaugeTypes.arcText', { + text: i18n.translate('visTypeGauge.gauge.gaugeTypes.arcText', { defaultMessage: 'Arc', }), value: GaugeType.Arc, }, { - text: i18n.translate('visTypeVislib.gauge.gaugeTypes.circleText', { + text: i18n.translate('visTypeGauge.gauge.gaugeTypes.circleText', { defaultMessage: 'Circle', }), value: GaugeType.Circle, @@ -30,19 +27,19 @@ export const getGaugeTypes = () => [ export const getAlignments = () => [ { - text: i18n.translate('visTypeVislib.gauge.alignmentAutomaticTitle', { + text: i18n.translate('visTypeGauge.gauge.alignmentAutomaticTitle', { defaultMessage: 'Automatic', }), value: Alignment.Automatic, }, { - text: i18n.translate('visTypeVislib.gauge.alignmentHorizontalTitle', { + text: i18n.translate('visTypeGauge.gauge.alignmentHorizontalTitle', { defaultMessage: 'Horizontal', }), value: Alignment.Horizontal, }, { - text: i18n.translate('visTypeVislib.gauge.alignmentVerticalTitle', { + text: i18n.translate('visTypeGauge.gauge.alignmentVerticalTitle', { defaultMessage: 'Vertical', }), value: Alignment.Vertical, @@ -54,9 +51,3 @@ export const getGaugeCollections = () => ({ alignments: getAlignments(), colorSchemas, }); - -export const getHeatmapCollections = () => ({ - legendPositions: getPositions(), - scales: getScaleTypes(), - colorSchemas, -}); diff --git a/src/plugins/vis_types/vislib/public/editor/components/gauge/index.tsx b/src/plugins/vis_types/gauge/public/editor/components/gauge/index.tsx similarity index 84% rename from src/plugins/vis_types/vislib/public/editor/components/gauge/index.tsx rename to src/plugins/vis_types/gauge/public/editor/components/gauge/index.tsx index 5a741ffbadd8..8fbe8b1567ae 100644 --- a/src/plugins/vis_types/vislib/public/editor/components/gauge/index.tsx +++ b/src/plugins/vis_types/gauge/public/editor/components/gauge/index.tsx @@ -10,19 +10,21 @@ import React, { useCallback } from 'react'; import { EuiSpacer } from '@elastic/eui'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { GaugeVisParams } from '../../../gauge'; +import { GaugeTypeProps, GaugeVisParams } from '../../../types'; import { RangesPanel } from './ranges_panel'; import { StylePanel } from './style_panel'; import { LabelsPanel } from './labels_panel'; -export type GaugeOptionsInternalProps = VisEditorOptionsProps & { +export interface GaugeOptionsProps extends VisEditorOptionsProps, GaugeTypeProps {} + +export type GaugeOptionsInternalProps = GaugeOptionsProps & { setGaugeValue: ( paramName: T, value: GaugeVisParams['gauge'][T] ) => void; }; -function GaugeOptions(props: VisEditorOptionsProps) { +function GaugeOptions(props: GaugeOptionsProps) { const { stateParams, setValue } = props; const setGaugeValue: GaugeOptionsInternalProps['setGaugeValue'] = useCallback( @@ -37,13 +39,9 @@ function GaugeOptions(props: VisEditorOptionsProps) { return ( <> - - - - ); diff --git a/src/plugins/vis_types/vislib/public/editor/components/gauge/labels_panel.tsx b/src/plugins/vis_types/gauge/public/editor/components/gauge/labels_panel.tsx similarity index 87% rename from src/plugins/vis_types/vislib/public/editor/components/gauge/labels_panel.tsx rename to src/plugins/vis_types/gauge/public/editor/components/gauge/labels_panel.tsx index fb5c1594e601..087a43c5dd00 100644 --- a/src/plugins/vis_types/vislib/public/editor/components/gauge/labels_panel.tsx +++ b/src/plugins/vis_types/gauge/public/editor/components/gauge/labels_panel.tsx @@ -19,7 +19,7 @@ function LabelsPanel({ stateParams, setValue, setGaugeValue }: GaugeOptionsInter

@@ -27,7 +27,7 @@ function LabelsPanel({ stateParams, setValue, setGaugeValue }: GaugeOptionsInter

@@ -66,13 +67,20 @@ function RangesPanel({ /> + ); + return (

@@ -35,7 +53,7 @@ function StylePanel({ aggs, setGaugeValue, stateParams }: GaugeOptionsInternalPr - - + {showElasticChartsOptions ? ( + <> + + + {alignmentSelect} + + + + ) : ( + alignmentSelect + )}
); } diff --git a/src/plugins/vis_types/vislib/public/editor/components/index.tsx b/src/plugins/vis_types/gauge/public/editor/components/index.tsx similarity index 64% rename from src/plugins/vis_types/vislib/public/editor/components/index.tsx rename to src/plugins/vis_types/gauge/public/editor/components/index.tsx index ab7e34b576e8..7cb1ca9a26c6 100644 --- a/src/plugins/vis_types/vislib/public/editor/components/index.tsx +++ b/src/plugins/vis_types/gauge/public/editor/components/index.tsx @@ -9,10 +9,11 @@ import React, { lazy } from 'react'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { GaugeVisParams } from '../../gauge'; +import { GaugeTypeProps, GaugeVisParams } from '../../types'; const GaugeOptionsLazy = lazy(() => import('./gauge')); -export const GaugeOptions = (props: VisEditorOptionsProps) => ( - -); +export const getGaugeOptions = + ({ showElasticChartsOptions }: GaugeTypeProps) => + (props: VisEditorOptionsProps) => + ; diff --git a/src/plugins/vis_types/vislib/public/editor/index.ts b/src/plugins/vis_types/gauge/public/editor/index.ts similarity index 100% rename from src/plugins/vis_types/vislib/public/editor/index.ts rename to src/plugins/vis_types/gauge/public/editor/index.ts diff --git a/src/plugins/vis_types/gauge/public/index.ts b/src/plugins/vis_types/gauge/public/index.ts new file mode 100755 index 000000000000..78aa55f59486 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { VisTypeGaugePlugin } from './plugin'; + +export function plugin() { + return new VisTypeGaugePlugin(); +} + +export type { VisTypeGaugePluginSetup, VisTypeGaugePluginStart } from './types'; + +export { gaugeVisType, goalVisType } from './vis_type'; diff --git a/src/plugins/vis_types/gauge/public/plugin.ts b/src/plugins/vis_types/gauge/public/plugin.ts new file mode 100755 index 000000000000..8c1189219276 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/plugin.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 { VisualizationsSetup } from '../../../../plugins/visualizations/public'; +import { DataPublicPluginStart } from '../../../../plugins/data/public'; +import { CoreSetup } from '../../../../core/public'; +import { LEGACY_GAUGE_CHARTS_LIBRARY } from '../common'; +import { VisTypeGaugePluginSetup } from './types'; +import { gaugeVisType, goalVisType } from './vis_type'; + +/** @internal */ +export interface VisTypeGaugeSetupDependencies { + visualizations: VisualizationsSetup; +} + +/** @internal */ +export interface VisTypePiePluginStartDependencies { + data: DataPublicPluginStart; +} + +export class VisTypeGaugePlugin { + public setup( + core: CoreSetup, + { visualizations }: VisTypeGaugeSetupDependencies + ): VisTypeGaugePluginSetup { + if (!core.uiSettings.get(LEGACY_GAUGE_CHARTS_LIBRARY)) { + const visTypeProps = { showElasticChartsOptions: true }; + visualizations.createBaseVisualization(gaugeVisType(visTypeProps)); + visualizations.createBaseVisualization(goalVisType(visTypeProps)); + } + + return {}; + } + + public start() {} +} diff --git a/src/plugins/vis_types/gauge/public/to_ast.test.ts b/src/plugins/vis_types/gauge/public/to_ast.test.ts new file mode 100644 index 000000000000..4f76e8e5f727 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/to_ast.test.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 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 { TimefilterContract } from 'src/plugins/data/public'; +import { Vis } from 'src/plugins/visualizations/public'; +import { toExpressionAst } from './to_ast'; +import { GaugeVisParams } from './types'; + +describe('gauge vis toExpressionAst function', () => { + let vis: Vis; + + beforeEach(() => { + vis = { + isHierarchical: () => false, + type: {}, + params: { + gauge: { + gaugeType: 'Circle', + scale: { + show: false, + labels: false, + color: 'rgba(105,112,125,0.2)', + }, + labels: { + show: true, + }, + style: { + subText: 'some custom sublabel', + }, + }, + }, + data: { + indexPattern: { id: '123' } as any, + aggs: { + getResponseAggs: () => [], + aggs: [], + } as any, + }, + } as unknown as Vis; + }); + + it('with minimal params', () => { + const actual = toExpressionAst(vis, { + timefilter: {} as TimefilterContract, + }); + expect(actual).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/vis_types/gauge/public/to_ast.ts b/src/plugins/vis_types/gauge/public/to_ast.ts new file mode 100644 index 000000000000..dfb483d47fd2 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/to_ast.ts @@ -0,0 +1,94 @@ +/* + * Copyright 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 { getVisSchemas, SchemaConfig, VisToExpressionAst } from '../../../visualizations/public'; +import { buildExpression, buildExpressionFunction } from '../../../expressions/public'; +import type { + GaugeExpressionFunctionDefinition, + GaugeShape, +} from '../../../chart_expressions/expression_gauge/common'; +import { GaugeType, GaugeVisParams } from './types'; +import { getStopsWithColorsFromRanges } from './utils'; +import { getEsaggsFn } from './to_ast_esaggs'; + +const prepareDimension = (params: SchemaConfig) => { + const visdimension = buildExpressionFunction('visdimension', { accessor: params.accessor }); + + if (params.format) { + visdimension.addArgument('format', params.format.id); + visdimension.addArgument('formatParams', JSON.stringify(params.format.params)); + } + + return buildExpression([visdimension]); +}; + +const gaugeTypeToShape = (type: GaugeType): GaugeShape => { + const arc: GaugeShape = 'arc'; + const circle: GaugeShape = 'circle'; + + return { + [GaugeType.Arc]: arc, + [GaugeType.Circle]: circle, + }[type]; +}; + +export const toExpressionAst: VisToExpressionAst = (vis, params) => { + const schemas = getVisSchemas(vis, params); + + const { + gaugeType, + percentageMode, + percentageFormatPattern, + colorSchema, + colorsRange, + invertColors, + scale, + style, + labels, + } = vis.params.gauge; + + // fix formatter for percentage mode + if (percentageMode === true) { + schemas.metric.forEach((metric: SchemaConfig) => { + metric.format = { + id: 'percent', + params: { pattern: percentageFormatPattern }, + }; + }); + } + + const centralMajorMode = labels.show ? (style.subText ? 'custom' : 'auto') : 'none'; + const gauge = buildExpressionFunction('gauge', { + shape: gaugeTypeToShape(gaugeType), + metric: schemas.metric.map(prepareDimension), + ticksPosition: scale.show ? 'auto' : 'hidden', + labelMajorMode: 'auto', + colorMode: 'palette', + centralMajorMode, + ...(centralMajorMode === 'custom' ? { labelMinor: style.subText } : {}), + percentageMode, + }); + + if (colorsRange && colorsRange.length) { + const stopsWithColors = getStopsWithColorsFromRanges(colorsRange, colorSchema, invertColors); + const palette = buildExpressionFunction('palette', { + ...stopsWithColors, + range: percentageMode ? 'percent' : 'number', + continuity: 'none', + gradient: true, + rangeMax: percentageMode ? 100 : stopsWithColors.stop[stopsWithColors.stop.length - 1], + rangeMin: stopsWithColors.stop[0], + }); + + gauge.addArgument('palette', buildExpression([palette])); + } + + const ast = buildExpression([getEsaggsFn(vis), gauge]); + + return ast.toAst(); +}; diff --git a/src/plugins/vis_types/gauge/public/to_ast_esaggs.ts b/src/plugins/vis_types/gauge/public/to_ast_esaggs.ts new file mode 100644 index 000000000000..ecf3f3e63717 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/to_ast_esaggs.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 { Vis } from '../../../visualizations/public'; +import { buildExpression, buildExpressionFunction } from '../../../expressions/public'; +import { + EsaggsExpressionFunctionDefinition, + IndexPatternLoadExpressionFunctionDefinition, +} from '../../../data/public'; + +import { GaugeVisParams } from './types'; + +/** + * Get esaggs expressions function + * @param vis + */ +export function getEsaggsFn(vis: Vis) { + return buildExpressionFunction('esaggs', { + index: buildExpression([ + buildExpressionFunction('indexPatternLoad', { + id: vis.data.indexPattern!.id!, + }), + ]), + metricsAtAllLevels: vis.isHierarchical(), + partialRows: false, + aggs: vis.data.aggs!.aggs.map((agg) => buildExpression(agg.toExpressionAst())), + }); +} diff --git a/src/plugins/vis_types/gauge/public/types.ts b/src/plugins/vis_types/gauge/public/types.ts new file mode 100755 index 000000000000..c160b2ccf2f3 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/types.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 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 { $Values } from '@kbn/utility-types'; +import { Range } from '../../../expressions/public'; +import { ColorSchemaParams, Labels, Style } from '../../../charts/public'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface VisTypeGaugePluginSetup {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface VisTypeGaugePluginStart {} + +/** + * Gauge title alignment + */ +export const Alignment = { + Automatic: 'automatic', + Horizontal: 'horizontal', + Vertical: 'vertical', +} as const; + +export type Alignment = $Values; + +export const GaugeType = { + Arc: 'Arc', + Circle: 'Circle', +} as const; + +export type GaugeType = $Values; + +export interface Gauge extends ColorSchemaParams { + backStyle: 'Full'; + gaugeStyle: 'Full'; + orientation: 'vertical'; + type: 'meter'; + alignment: Alignment; + colorsRange: Range[]; + extendRange: boolean; + gaugeType: GaugeType; + labels: Labels; + percentageMode: boolean; + percentageFormatPattern?: string; + outline?: boolean; + scale: { + show: boolean; + labels: false; + color: 'rgba(105,112,125,0.2)'; + }; + style: Style; +} + +export interface GaugeVisParams { + type: 'gauge'; + addTooltip: boolean; + addLegend: boolean; + isDisplayWarning: boolean; + gauge: Gauge; +} + +export interface GaugeTypeProps { + showElasticChartsOptions?: boolean; +} diff --git a/src/plugins/vis_types/gauge/public/utils/index.ts b/src/plugins/vis_types/gauge/public/utils/index.ts new file mode 100644 index 000000000000..fb23c97d835f --- /dev/null +++ b/src/plugins/vis_types/gauge/public/utils/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 { getStopsWithColorsFromRanges } from './palette'; diff --git a/src/plugins/vis_types/gauge/public/utils/palette.ts b/src/plugins/vis_types/gauge/public/utils/palette.ts new file mode 100644 index 000000000000..ff3a4b10a011 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/utils/palette.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { ColorSchemas, getHeatmapColors } from '../../../../charts/common'; +import { Range } from '../../../../expressions'; + +export interface PaletteConfig { + color: Array; + stop: number[]; +} + +const TRANSPARENT = 'rgb(0, 0, 0, 0)'; + +const getColor = ( + index: number, + elementsCount: number, + colorSchema: ColorSchemas, + invertColors: boolean = false +) => { + const divider = Math.max(elementsCount - 1, 1); + const value = invertColors ? 1 - index / divider : index / divider; + return getHeatmapColors(value, colorSchema); +}; + +export const getStopsWithColorsFromRanges = ( + ranges: Range[], + colorSchema: ColorSchemas, + invertColors: boolean = false +) => { + return ranges.reduce( + (acc, range, index, rangesArr) => { + if ((index && range.from !== rangesArr[index - 1].to) || index === 0) { + acc.color.push(TRANSPARENT); + acc.stop.push(range.from); + } + + acc.color.push(getColor(index, rangesArr.length, colorSchema, invertColors)); + acc.stop.push(range.to); + + return acc; + }, + { color: [], stop: [] } + ); +}; diff --git a/src/plugins/vis_types/gauge/public/vis_type/gauge.tsx b/src/plugins/vis_types/gauge/public/vis_type/gauge.tsx new file mode 100644 index 000000000000..648d34cdee7b --- /dev/null +++ b/src/plugins/vis_types/gauge/public/vis_type/gauge.tsx @@ -0,0 +1,130 @@ +/* + * Copyright 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 { ColorMode, ColorSchemas } from '../../../../charts/public'; +import { AggGroupNames } from '../../../../data/public'; +import { VisTypeDefinition, VIS_EVENT_TO_TRIGGER } from '../../../../visualizations/public'; + +import { Alignment, GaugeType, GaugeTypeProps } from '../types'; +import { toExpressionAst } from '../to_ast'; +import { getGaugeOptions } from '../editor/components'; +import { GaugeVisParams } from '../types'; +import { SplitTooltip } from './split_tooltip'; + +export const getGaugeVisTypeDefinition = ( + props: GaugeTypeProps +): VisTypeDefinition => ({ + name: 'gauge', + title: i18n.translate('visTypeGauge.gauge.gaugeTitle', { defaultMessage: 'Gauge' }), + icon: 'visGauge', + description: i18n.translate('visTypeGauge.gauge.gaugeDescription', { + defaultMessage: 'Show the status of a metric.', + }), + getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], + toExpressionAst, + visConfig: { + defaults: { + type: 'gauge', + addTooltip: true, + addLegend: true, + isDisplayWarning: false, + gauge: { + alignment: Alignment.Automatic, + extendRange: true, + percentageMode: false, + gaugeType: GaugeType.Arc, + gaugeStyle: 'Full', + backStyle: 'Full', + orientation: 'vertical', + colorSchema: ColorSchemas.GreenToRed, + gaugeColorMode: ColorMode.Labels, + colorsRange: [ + { from: 0, to: 50 }, + { from: 50, to: 75 }, + { from: 75, to: 100 }, + ], + invertColors: false, + labels: { + show: true, + color: 'black', + }, + scale: { + show: true, + labels: false, + color: 'rgba(105,112,125,0.2)', + }, + type: 'meter', + style: { + bgWidth: 0.9, + width: 0.9, + mask: false, + bgMask: false, + maskBars: 50, + bgFill: 'rgba(105,112,125,0.2)', + bgColor: true, + subText: '', + fontSize: 60, + }, + }, + }, + }, + editorConfig: { + optionsTemplate: getGaugeOptions(props), + schemas: [ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypeGauge.gauge.metricTitle', { defaultMessage: 'Metric' }), + min: 1, + ...(props.showElasticChartsOptions ? { max: 1 } : {}), + aggFilter: [ + '!std_dev', + '!geo_centroid', + '!percentiles', + '!percentile_ranks', + '!derivative', + '!serial_diff', + '!moving_avg', + '!cumulative_sum', + '!geo_bounds', + '!filtered_metric', + '!single_percentile', + ], + defaults: [{ schema: 'metric', type: 'count' }], + }, + { + group: AggGroupNames.Buckets, + name: 'group', + // TODO: Remove when split chart aggs are supported + ...(props.showElasticChartsOptions && { + disabled: true, + tooltip: , + }), + title: i18n.translate('visTypeGauge.gauge.groupTitle', { + defaultMessage: 'Split group', + }), + min: 0, + max: 1, + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!rare_terms', + '!multi_terms', + '!significant_text', + ], + }, + ], + }, + requiresSearch: true, +}); diff --git a/src/plugins/vis_types/gauge/public/vis_type/goal.tsx b/src/plugins/vis_types/gauge/public/vis_type/goal.tsx new file mode 100644 index 000000000000..e56e87ee70df --- /dev/null +++ b/src/plugins/vis_types/gauge/public/vis_type/goal.tsx @@ -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 React from 'react'; +import { i18n } from '@kbn/i18n'; + +import { AggGroupNames } from '../../../../data/public'; +import { ColorMode, ColorSchemas } from '../../../../charts/public'; +import { VisTypeDefinition } from '../../../../visualizations/public'; + +import { getGaugeOptions } from '../editor/components'; +import { toExpressionAst } from '../to_ast'; +import { GaugeVisParams, GaugeType, GaugeTypeProps } from '../types'; +import { SplitTooltip } from './split_tooltip'; + +export const getGoalVisTypeDefinition = ( + props: GaugeTypeProps +): VisTypeDefinition => ({ + name: 'goal', + title: i18n.translate('visTypeGauge.goal.goalTitle', { defaultMessage: 'Goal' }), + icon: 'visGoal', + description: i18n.translate('visTypeGauge.goal.goalDescription', { + defaultMessage: 'Track how a metric progresses to a goal.', + }), + toExpressionAst, + visConfig: { + defaults: { + addTooltip: true, + addLegend: false, + isDisplayWarning: false, + type: 'gauge', + gauge: { + verticalSplit: false, + autoExtend: false, + percentageMode: true, + gaugeType: GaugeType.Arc, + gaugeStyle: 'Full', + backStyle: 'Full', + orientation: 'vertical', + useRanges: false, + colorSchema: ColorSchemas.GreenToRed, + gaugeColorMode: ColorMode.None, + colorsRange: [{ from: 0, to: 10000 }], + invertColors: false, + labels: { + show: true, + color: 'black', + }, + scale: { + show: false, + labels: false, + color: 'rgba(105,112,125,0.2)', + width: 2, + }, + type: 'meter', + style: { + bgFill: 'rgba(105,112,125,0.2)', + bgColor: false, + labelColor: false, + subText: '', + fontSize: 60, + }, + }, + }, + }, + editorConfig: { + optionsTemplate: getGaugeOptions(props), + schemas: [ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypeGauge.goal.metricTitle', { defaultMessage: 'Metric' }), + min: 1, + ...(props.showElasticChartsOptions ? { max: 1 } : {}), + aggFilter: [ + '!std_dev', + '!geo_centroid', + '!percentiles', + '!percentile_ranks', + '!derivative', + '!serial_diff', + '!moving_avg', + '!cumulative_sum', + '!geo_bounds', + '!filtered_metric', + '!single_percentile', + ], + defaults: [{ schema: 'metric', type: 'count' }], + }, + { + group: AggGroupNames.Buckets, + name: 'group', + // TODO: Remove when split chart aggs are supported + ...(props.showElasticChartsOptions && { + disabled: true, + tooltip: , + }), + title: i18n.translate('visTypeGauge.goal.groupTitle', { + defaultMessage: 'Split group', + }), + min: 0, + max: 1, + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!rare_terms', + '!multi_terms', + '!significant_text', + ], + }, + ], + }, + requiresSearch: true, +}); diff --git a/src/plugins/vis_types/vislib/public/vis_type_vislib_vis_types.ts b/src/plugins/vis_types/gauge/public/vis_type/index.ts similarity index 50% rename from src/plugins/vis_types/vislib/public/vis_type_vislib_vis_types.ts rename to src/plugins/vis_types/gauge/public/vis_type/index.ts index 220c69afb21d..cc78afedc02b 100644 --- a/src/plugins/vis_types/vislib/public/vis_type_vislib_vis_types.ts +++ b/src/plugins/vis_types/gauge/public/vis_type/index.ts @@ -6,13 +6,14 @@ * Side Public License, v 1. */ -import { VisTypeDefinition } from 'src/plugins/visualizations/public'; -import { gaugeVisTypeDefinition } from './gauge'; -import { goalVisTypeDefinition } from './goal'; +import { GaugeTypeProps } from '../types'; +import { getGaugeVisTypeDefinition } from './gauge'; +import { getGoalVisTypeDefinition } from './goal'; -export { pieVisTypeDefinition } from './pie'; +export const gaugeVisType = (props: GaugeTypeProps) => { + return getGaugeVisTypeDefinition(props); +}; -export const visLibVisTypeDefinitions: Array> = [ - gaugeVisTypeDefinition, - goalVisTypeDefinition, -]; +export const goalVisType = (props: GaugeTypeProps) => { + return getGoalVisTypeDefinition(props); +}; diff --git a/src/plugins/vis_types/gauge/public/vis_type/split_tooltip.tsx b/src/plugins/vis_types/gauge/public/vis_type/split_tooltip.tsx new file mode 100644 index 000000000000..8c92b6d65ff7 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/vis_type/split_tooltip.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 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 { FormattedMessage } from '@kbn/i18n-react'; + +export function SplitTooltip() { + return ( + + ); +} diff --git a/src/plugins/vis_types/gauge/server/index.ts b/src/plugins/vis_types/gauge/server/index.ts new file mode 100755 index 000000000000..8d958e63356e --- /dev/null +++ b/src/plugins/vis_types/gauge/server/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { PluginConfigDescriptor } from 'src/core/server'; +import { configSchema, ConfigSchema } from '../config'; +import { VisTypeGaugeServerPlugin } from './plugin'; + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; + +export const plugin = () => new VisTypeGaugeServerPlugin(); diff --git a/src/plugins/vis_types/gauge/server/plugin.ts b/src/plugins/vis_types/gauge/server/plugin.ts new file mode 100755 index 000000000000..0334f963c720 --- /dev/null +++ b/src/plugins/vis_types/gauge/server/plugin.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { schema } from '@kbn/config-schema'; + +import { CoreSetup, Plugin, UiSettingsParams } from 'kibana/server'; + +import { LEGACY_GAUGE_CHARTS_LIBRARY } from '../common'; + +export const getUiSettingsConfig: () => Record> = () => ({ + [LEGACY_GAUGE_CHARTS_LIBRARY]: { + name: i18n.translate( + 'visTypeGauge.advancedSettings.visualization.legacyGaugeChartsLibrary.name', + { + defaultMessage: 'Gauge legacy charts library', + } + ), + requiresPageReload: true, + value: true, + description: i18n.translate( + 'visTypeGauge.advancedSettings.visualization.legacyGaugeChartsLibrary.description', + { + defaultMessage: 'Enables legacy charts library for gauge charts in visualize.', + } + ), + category: ['visualization'], + schema: schema.boolean(), + }, +}); + +export class VisTypeGaugeServerPlugin implements Plugin { + public setup(core: CoreSetup) { + core.uiSettings.register(getUiSettingsConfig()); + + return {}; + } + + public start() { + return {}; + } +} diff --git a/src/plugins/vis_types/gauge/tsconfig.json b/src/plugins/vis_types/gauge/tsconfig.json new file mode 100644 index 000000000000..b1717173757e --- /dev/null +++ b/src/plugins/vis_types/gauge/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "*.ts" + ], + "references": [ + { "path": "../../../core/tsconfig.json" }, + { "path": "../../charts/tsconfig.json" }, + { "path": "../../data/tsconfig.json" }, + { "path": "../../expressions/tsconfig.json" }, + { "path": "../../chart_expressions/expression_gauge/tsconfig.json" }, + { "path": "../../visualizations/tsconfig.json" }, + { "path": "../../usage_collection/tsconfig.json" }, + { "path": "../../vis_default_editor/tsconfig.json" }, + { "path": "../../field_formats/tsconfig.json" }, + { "path": "../../chart_expressions/expression_partition_vis/tsconfig.json" } + ] + } \ No newline at end of file diff --git a/src/plugins/vis_types/heatmap/public/to_ast.ts b/src/plugins/vis_types/heatmap/public/to_ast.ts index b0bb4b2d6de8..d6dc8a803859 100644 --- a/src/plugins/vis_types/heatmap/public/to_ast.ts +++ b/src/plugins/vis_types/heatmap/public/to_ast.ts @@ -20,6 +20,7 @@ const prepareLegend = (params: HeatmapVisParams) => { position: params.legendPosition, shouldTruncate: params.truncateLegend ?? true, maxLines: params.maxLegendLines ?? 1, + legendSize: params.legendSize, }); return buildExpression([legend]); diff --git a/src/plugins/vis_types/heatmap/public/types.ts b/src/plugins/vis_types/heatmap/public/types.ts index b02dad8656c8..9806b6de772a 100644 --- a/src/plugins/vis_types/heatmap/public/types.ts +++ b/src/plugins/vis_types/heatmap/public/types.ts @@ -23,6 +23,7 @@ export interface HeatmapVisParams { legendPosition: Position; truncateLegend?: boolean; maxLegendLines?: number; + legendSize?: number; lastRangeIsRightOpen: boolean; percentageMode: boolean; valueAxes: ValueAxis[]; diff --git a/src/plugins/vis_types/timeseries/common/calculate_label.test.ts b/src/plugins/vis_types/timeseries/common/calculate_label.test.ts index 7083711246e7..7e612ed1aadd 100644 --- a/src/plugins/vis_types/timeseries/common/calculate_label.test.ts +++ b/src/plugins/vis_types/timeseries/common/calculate_label.test.ts @@ -9,6 +9,7 @@ import { calculateLabel } from './calculate_label'; import type { Metric } from './types'; import { SanitizedFieldType } from './types'; +import { KBN_FIELD_TYPES } from '../../../data/common'; describe('calculateLabel(metric, metrics)', () => { test('returns the metric.alias if set', () => { @@ -90,7 +91,7 @@ describe('calculateLabel(metric, metrics)', () => { { id: 1, type: 'max', field: 'network.out.bytes', alias: 'Outbound Traffic' }, metric, ] as unknown as Metric[]; - const fields: SanitizedFieldType[] = [{ name: '2', label: '2', type: 'field' }]; + const fields: SanitizedFieldType[] = [{ name: '2', label: '2', type: KBN_FIELD_TYPES.DATE }]; expect(() => calculateLabel(metric, metrics, fields)).toThrowError('Field "3" not found'); }); @@ -101,7 +102,7 @@ describe('calculateLabel(metric, metrics)', () => { { id: 1, type: 'max', field: 'network.out.bytes', alias: 'Outbound Traffic' }, metric, ] as unknown as Metric[]; - const fields: SanitizedFieldType[] = [{ name: '2', label: '2', type: 'field' }]; + const fields: SanitizedFieldType[] = [{ name: '2', label: '2', type: KBN_FIELD_TYPES.DATE }]; expect(calculateLabel(metric, metrics, fields, false)).toBe('Max of 3'); }); diff --git a/src/plugins/vis_types/timeseries/common/fields_utils.test.ts b/src/plugins/vis_types/timeseries/common/fields_utils.test.ts index 228dfbfd2db9..6dd00d803b7c 100644 --- a/src/plugins/vis_types/timeseries/common/fields_utils.test.ts +++ b/src/plugins/vis_types/timeseries/common/fields_utils.test.ts @@ -6,8 +6,16 @@ * Side Public License, v 1. */ -import { toSanitizedFieldType } from './fields_utils'; -import type { FieldSpec } from '../../../data/common'; +import { + getFieldsForTerms, + toSanitizedFieldType, + getMultiFieldLabel, + createCachedFieldValueFormatter, +} from './fields_utils'; +import { FieldSpec, KBN_FIELD_TYPES } from '../../../data/common'; +import { DataView } from '../../../data_views/common'; +import { stubLogstashDataView } from '../../../data/common/stubs'; +import { FieldFormatsRegistry, StringFormat } from '../../../field_formats/common'; describe('fields_utils', () => { describe('toSanitizedFieldType', () => { @@ -59,4 +67,92 @@ describe('fields_utils', () => { expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); }); }); + + describe('getFieldsForTerms', () => { + test('should return fields as array', () => { + expect(getFieldsForTerms('field')).toEqual(['field']); + expect(getFieldsForTerms(['field', 'field1'])).toEqual(['field', 'field1']); + }); + + test('should exclude empty values', () => { + expect(getFieldsForTerms([null, ''])).toEqual([]); + }); + + test('should return empty array in case of undefined field', () => { + expect(getFieldsForTerms(undefined)).toEqual([]); + }); + }); + + describe('getMultiFieldLabel', () => { + test('should return label for single field', () => { + expect( + getMultiFieldLabel( + ['field'], + [{ name: 'field', label: 'Label', type: KBN_FIELD_TYPES.DATE }] + ) + ).toBe('Label'); + }); + + test('should return label for multi fields', () => { + expect( + getMultiFieldLabel( + ['field', 'field1'], + [ + { name: 'field', label: 'Label', type: KBN_FIELD_TYPES.DATE }, + { name: 'field2', label: 'Label1', type: KBN_FIELD_TYPES.DATE }, + ] + ) + ).toBe('Label + 1 other'); + }); + + test('should return label for multi fields (2 others)', () => { + expect( + getMultiFieldLabel( + ['field', 'field1', 'field2'], + [ + { name: 'field', label: 'Label', type: KBN_FIELD_TYPES.DATE }, + { name: 'field1', label: 'Label1', type: KBN_FIELD_TYPES.DATE }, + { name: 'field3', label: 'Label2', type: KBN_FIELD_TYPES.DATE }, + ] + ) + ).toBe('Label + 2 others'); + }); + }); + + describe('createCachedFieldValueFormatter', () => { + let dataView: DataView; + + beforeEach(() => { + dataView = stubLogstashDataView; + }); + + test('should use data view formatters', () => { + const getFormatterForFieldSpy = jest.spyOn(dataView, 'getFormatterForField'); + + const cache = createCachedFieldValueFormatter(dataView); + + cache('bytes', '10001'); + cache('bytes', '20002'); + + expect(getFormatterForFieldSpy).toHaveBeenCalledTimes(1); + }); + + test('should use default formatters in case of Data view not defined', () => { + const fieldFormatServiceMock = { + getDefaultInstance: jest.fn().mockReturnValue(new StringFormat()), + } as unknown as FieldFormatsRegistry; + + const cache = createCachedFieldValueFormatter( + null, + [{ name: 'field', label: 'Label', type: KBN_FIELD_TYPES.STRING }], + fieldFormatServiceMock + ); + + cache('field', '10001'); + cache('field', '20002'); + + expect(fieldFormatServiceMock.getDefaultInstance).toHaveBeenCalledTimes(1); + expect(fieldFormatServiceMock.getDefaultInstance).toHaveBeenCalledWith('string'); + }); + }); }); diff --git a/src/plugins/vis_types/timeseries/common/fields_utils.ts b/src/plugins/vis_types/timeseries/common/fields_utils.ts index d6987b9cdae9..02a62b9246d5 100644 --- a/src/plugins/vis_types/timeseries/common/fields_utils.ts +++ b/src/plugins/vis_types/timeseries/common/fields_utils.ts @@ -5,11 +5,12 @@ * 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 { FieldSpec } from '../../../data/common'; -import { isNestedField } from '../../../data/common'; -import { FetchedIndexPattern, SanitizedFieldType } from './types'; +import { isNestedField, FieldSpec, DataView } from '../../../data/common'; import { FieldNotFoundError } from './errors'; +import type { FetchedIndexPattern, SanitizedFieldType } from './types'; +import { FieldFormat, FieldFormatsRegistry, FIELD_FORMAT_IDS } from '../../../field_formats/common'; export const extractFieldLabel = ( fields: SanitizedFieldType[], @@ -49,3 +50,63 @@ export const toSanitizedFieldType = (fields: FieldSpec[]) => type: field.type, } as SanitizedFieldType) ); + +export const getFieldsForTerms = (fields: string | Array | undefined): string[] => { + return fields ? ([fields].flat().filter(Boolean) as string[]) : []; +}; + +export const getMultiFieldLabel = (fieldForTerms: string[], fields?: SanitizedFieldType[]) => { + const firstFieldLabel = fields ? extractFieldLabel(fields, fieldForTerms[0]) : fieldForTerms[0]; + + if (fieldForTerms.length > 1) { + return i18n.translate('visTypeTimeseries.fieldUtils.multiFieldLabel', { + defaultMessage: '{firstFieldLabel} + {count} {count, plural, one {other} other {others}}', + values: { + firstFieldLabel, + count: fieldForTerms.length - 1, + }, + }); + } + return firstFieldLabel ?? ''; +}; + +export const createCachedFieldValueFormatter = ( + dataView?: DataView | null, + fields?: SanitizedFieldType[], + fieldFormatService?: FieldFormatsRegistry, + excludedFieldFormatsIds: FIELD_FORMAT_IDS[] = [] +) => { + const cache = new Map(); + + return (fieldName: string, value: string, contentType: 'text' | 'html' = 'text') => { + const cachedFormatter = cache.get(fieldName); + if (cachedFormatter) { + return cachedFormatter.convert(value, contentType); + } + + if (dataView && !excludedFieldFormatsIds.includes(dataView.fieldFormatMap?.[fieldName]?.id)) { + const field = dataView.fields.getByName(fieldName); + if (field) { + const formatter = dataView.getFormatterForField(field); + + if (formatter) { + cache.set(fieldName, formatter); + return formatter.convert(value, contentType); + } + } + } else if (fieldFormatService && fields) { + const f = fields.find((item) => item.name === fieldName); + + if (f) { + const formatter = fieldFormatService.getDefaultInstance(f.type); + + if (formatter) { + cache.set(fieldName, formatter); + return formatter.convert(value, contentType); + } + } + } + }; +}; + +export const MULTI_FIELD_VALUES_SEPARATOR = ' › '; diff --git a/src/plugins/vis_types/timeseries/common/types/index.ts b/src/plugins/vis_types/timeseries/common/types/index.ts index 01b200c6774d..001ea02eb355 100644 --- a/src/plugins/vis_types/timeseries/common/types/index.ts +++ b/src/plugins/vis_types/timeseries/common/types/index.ts @@ -7,7 +7,7 @@ */ import { Filter } from '@kbn/es-query'; -import { IndexPattern, Query } from '../../../../data/common'; +import { IndexPattern, KBN_FIELD_TYPES, Query } from '../../../../data/common'; import { Panel } from './panel_model'; export type { Metric, Series, Panel, MetricType } from './panel_model'; @@ -28,7 +28,7 @@ export interface FetchedIndexPattern { export interface SanitizedFieldType { name: string; - type: string; + type: KBN_FIELD_TYPES; label?: string; } diff --git a/src/plugins/vis_types/timeseries/common/types/panel_model.ts b/src/plugins/vis_types/timeseries/common/types/panel_model.ts index 40bd5632c3a8..1ccf7412a3e9 100644 --- a/src/plugins/vis_types/timeseries/common/types/panel_model.ts +++ b/src/plugins/vis_types/timeseries/common/types/panel_model.ts @@ -6,10 +6,15 @@ * Side Public License, v 1. */ -import { METRIC_TYPES, Query } from '../../../../data/common'; +import { Query, METRIC_TYPES, KBN_FIELD_TYPES } from '../../../../data/common'; import { PANEL_TYPES, TOOLTIP_MODES, TSVB_METRIC_TYPES } from '../enums'; -import { IndexPatternValue, Annotation } from './index'; -import { ColorRules, BackgroundColorRules, BarColorRules, GaugeColorRules } from './color_rules'; +import type { IndexPatternValue, Annotation } from './index'; +import type { + ColorRules, + BackgroundColorRules, + BarColorRules, + GaugeColorRules, +} from './color_rules'; interface MetricVariable { field?: string; @@ -109,7 +114,7 @@ export interface Series { steps: number; terms_direction?: string; terms_exclude?: string; - terms_field?: string; + terms_field?: string | Array; terms_include?: string; terms_order_by?: string; terms_size?: string; @@ -155,10 +160,10 @@ export interface Panel { markdown_scrollbars: number; markdown_vertical_align?: string; max_bars: number; - pivot_id?: string; + pivot_id?: string | Array; pivot_label?: string; pivot_rows?: string; - pivot_type?: string; + pivot_type?: KBN_FIELD_TYPES | Array; series: Series[]; show_grid: number; show_legend: number; diff --git a/src/plugins/vis_types/timeseries/common/types/vis_data.ts b/src/plugins/vis_types/timeseries/common/types/vis_data.ts index 07c078a6e8aa..de507fb9ecc3 100644 --- a/src/plugins/vis_types/timeseries/common/types/vis_data.ts +++ b/src/plugins/vis_types/timeseries/common/types/vis_data.ts @@ -46,7 +46,7 @@ export interface PanelSeries { export interface PanelData { id: string; - label: string; + label: string | string[]; labelFormatted?: string; data: PanelDataArray[]; seriesId: string; diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select.tsx b/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select.tsx deleted file mode 100644 index d5665211b7f7..000000000000 --- a/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select.tsx +++ /dev/null @@ -1,149 +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, { ReactNode } from 'react'; -import { - EuiComboBox, - EuiComboBoxProps, - EuiComboBoxOptionOption, - EuiFormRow, - htmlIdGenerator, -} from '@elastic/eui'; -import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; -import type { SanitizedFieldType, IndexPatternValue } from '../../../../common/types'; -import type { TimeseriesUIRestrictions } from '../../../../common/ui_restrictions'; - -import { isFieldEnabled } from '../../../../common/check_ui_restrictions'; - -interface FieldSelectProps { - label: string | ReactNode; - type: string; - fields: Record; - indexPattern: IndexPatternValue; - value?: string | null; - onChange: (options: Array>) => void; - disabled?: boolean; - restrict?: string[]; - placeholder?: string; - uiRestrictions?: TimeseriesUIRestrictions; - 'data-test-subj'?: string; -} - -const defaultPlaceholder = i18n.translate('visTypeTimeseries.fieldSelect.selectFieldPlaceholder', { - defaultMessage: 'Select field...', -}); - -const isFieldTypeEnabled = (fieldRestrictions: string[], fieldType: string) => - fieldRestrictions.length ? fieldRestrictions.includes(fieldType) : true; - -const sortByLabel = (a: EuiComboBoxOptionOption, b: EuiComboBoxOptionOption) => { - const getNormalizedString = (option: EuiComboBoxOptionOption) => - (option.label || '').toLowerCase(); - - return getNormalizedString(a).localeCompare(getNormalizedString(b)); -}; - -export function FieldSelect({ - label, - type, - fields, - indexPattern = '', - value = '', - onChange, - disabled = false, - restrict = [], - placeholder = defaultPlaceholder, - uiRestrictions, - 'data-test-subj': dataTestSubj = 'metricsIndexPatternFieldsSelect', -}: FieldSelectProps) { - const htmlId = htmlIdGenerator(); - - let selectedOptions: Array> = []; - let newPlaceholder = placeholder; - const fieldsSelector = getIndexPatternKey(indexPattern); - - const groupedOptions: EuiComboBoxProps['options'] = Object.values( - (fields[fieldsSelector] || []).reduce>>( - (acc, field) => { - if (placeholder === field?.name) { - newPlaceholder = field.label ?? field.name; - } - - if ( - isFieldTypeEnabled(restrict, field.type) && - isFieldEnabled(field.name, type, uiRestrictions) - ) { - const item: EuiComboBoxOptionOption = { - value: field.name, - label: field.label ?? field.name, - }; - - const fieldTypeOptions = acc[field.type]?.options; - - if (fieldTypeOptions) { - fieldTypeOptions.push(item); - } else { - acc[field.type] = { - options: [item], - label: field.type, - }; - } - - if (value === item.value) { - selectedOptions.push(item); - } - } - - return acc; - }, - {} - ) - ); - - // sort groups - groupedOptions.sort(sortByLabel); - - // sort items - groupedOptions.forEach((group) => { - if (Array.isArray(group.options)) { - group.options.sort(sortByLabel); - } - }); - - const isInvalid = Boolean(value && fields[fieldsSelector] && !selectedOptions.length); - - if (value && !selectedOptions.length) { - selectedOptions = [{ label: value, id: 'INVALID_FIELD' }]; - } - - return ( - - - - ); -} diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/field_select.tsx b/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/field_select.tsx new file mode 100644 index 000000000000..27f4d96381fd --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/field_select.tsx @@ -0,0 +1,185 @@ +/* + * Copyright 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, useMemo, ReactNode } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiComboBoxOptionOption, + EuiComboBoxProps, + EuiFormRow, + htmlIdGenerator, + DragDropContextProps, +} from '@elastic/eui'; + +import { FieldSelectItem } from './field_select_item'; +import { IndexPatternValue, SanitizedFieldType } from '../../../../../common/types'; +import { TimeseriesUIRestrictions } from '../../../../../common/ui_restrictions'; +import { getIndexPatternKey } from '../../../../../common/index_patterns_utils'; +import { MultiFieldSelect } from './multi_field_select'; +import { + addNewItem, + deleteItem, + swapItems, + getGroupedOptions, + findInGroupedOptions, + INVALID_FIELD_ID, + MAX_MULTI_FIELDS_ITEMS, + updateItem, +} from './field_select_utils'; + +interface FieldSelectProps { + label: string | ReactNode; + type: string; + uiRestrictions?: TimeseriesUIRestrictions; + restrict?: string[]; + value?: string | Array | null; + fields: Record; + indexPattern: IndexPatternValue; + onChange: (selectedValues: Array) => void; + disabled?: boolean; + placeholder?: string; + allowMultiSelect?: boolean; + 'data-test-subj'?: string; + fullWidth?: boolean; +} + +const getPreselectedFields = ( + placeholder?: string, + options?: Array> +) => placeholder && findInGroupedOptions(options, placeholder)?.label; + +export function FieldSelect({ + label, + fullWidth, + type, + value, + fields, + indexPattern, + uiRestrictions, + restrict, + onChange, + disabled, + placeholder, + allowMultiSelect = false, + 'data-test-subj': dataTestSubj, +}: FieldSelectProps) { + const htmlId = htmlIdGenerator(); + const fieldsSelector = getIndexPatternKey(indexPattern); + const selectedIds = useMemo(() => [value ?? null].flat(), [value]); + + const groupedOptions = useMemo( + () => getGroupedOptions(type, selectedIds, fields[fieldsSelector], uiRestrictions, restrict), + [fields, fieldsSelector, restrict, selectedIds, type, uiRestrictions] + ); + + const selectedOptionsMap = useMemo(() => { + const map = new Map['selectedOptions']>(); + if (selectedIds) { + const addIntoSet = (item: string) => { + const option = findInGroupedOptions(groupedOptions, item); + if (option) { + map.set(item, [option]); + } else { + map.set(item, [{ label: item, id: INVALID_FIELD_ID }]); + } + }; + + selectedIds.forEach((v) => v && addIntoSet(v)); + } + return map; + }, [groupedOptions, selectedIds]); + + const invalidSelectedOptions = useMemo( + () => + [...selectedOptionsMap.values()] + .flat() + .filter((item) => item?.label && item?.id === INVALID_FIELD_ID) + .map((item) => item!.label), + [selectedOptionsMap] + ); + + const onFieldSelectItemChange = useCallback( + (index: number = 0, [selectedItem]) => { + onChange(updateItem(selectedIds, selectedItem?.value, index)); + }, + [selectedIds, onChange] + ); + + const onNewItemAdd = useCallback( + (index?: number) => onChange(addNewItem(selectedIds, index)), + [selectedIds, onChange] + ); + + const onDeleteItem = useCallback( + (index?: number) => onChange(deleteItem(selectedIds, index)), + [onChange, selectedIds] + ); + + const onDragEnd: DragDropContextProps['onDragEnd'] = useCallback( + ({ source, destination }) => { + if (destination && source.index !== destination?.index) { + onChange(swapItems(selectedIds, source.index, destination.index)); + } + }, + [onChange, selectedIds] + ); + + const FieldSelectItemFactory = useMemo( + () => (props: { value?: string | null; index?: number }) => + ( + = MAX_MULTI_FIELDS_ITEMS} + disableDelete={!allowMultiSelect || selectedIds?.length <= 1} + /> + ), + [ + groupedOptions, + selectedOptionsMap, + disabled, + onNewItemAdd, + onDeleteItem, + onFieldSelectItemChange, + placeholder, + allowMultiSelect, + selectedIds?.length, + ] + ); + + return ( + + {selectedIds?.length > 1 ? ( + + ) : ( + + )} + + ); +} diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/field_select_item.tsx b/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/field_select_item.tsx new file mode 100644 index 000000000000..79ebd247fffa --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/field_select_item.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 { i18n } from '@kbn/i18n'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiComboBoxProps, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +import { AddDeleteButtons } from '../../add_delete_buttons'; +import { INVALID_FIELD_ID } from './field_select_utils'; + +export interface FieldSelectItemProps { + onChange: (options: Array>) => void; + options: EuiComboBoxProps['options']; + selectedOptions: EuiComboBoxProps['selectedOptions']; + placeholder?: string; + disabled?: boolean; + disableAdd?: boolean; + disableDelete?: boolean; + onNewItemAdd?: () => void; + onDeleteItem?: () => void; +} + +const defaultPlaceholder = i18n.translate('visTypeTimeseries.fieldSelect.selectFieldPlaceholder', { + defaultMessage: 'Select field...', +}); + +export function FieldSelectItem({ + options, + selectedOptions, + placeholder = defaultPlaceholder, + disabled, + disableAdd, + disableDelete, + + onChange, + onDeleteItem, + onNewItemAdd, +}: FieldSelectItemProps) { + const isInvalid = Boolean(selectedOptions?.find((item) => item.id === INVALID_FIELD_ID)); + + return ( + + + + + + + + + ); +} diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/field_select_utils.ts b/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/field_select_utils.ts new file mode 100644 index 000000000000..40d80a014e36 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/field_select_utils.ts @@ -0,0 +1,109 @@ +/* + * Copyright 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 { EuiComboBoxOptionOption, EuiComboBoxProps } from '@elastic/eui'; +import { isFieldEnabled } from '../../../../../common/check_ui_restrictions'; + +import type { SanitizedFieldType } from '../../../../..//common/types'; +import type { TimeseriesUIRestrictions } from '../../../../../common/ui_restrictions'; + +export const INVALID_FIELD_ID = 'INVALID_FIELD'; +export const MAX_MULTI_FIELDS_ITEMS = 4; + +export const getGroupedOptions = ( + type: string, + selectedIds: Array, + fields: SanitizedFieldType[] = [], + uiRestrictions: TimeseriesUIRestrictions | undefined, + restrict: string[] = [] +): EuiComboBoxProps['options'] => { + const isFieldTypeEnabled = (fieldType: string) => + restrict.length ? restrict.includes(fieldType) : true; + + const sortByLabel = (a: EuiComboBoxOptionOption, b: EuiComboBoxOptionOption) => { + const getNormalizedString = (option: EuiComboBoxOptionOption) => + (option.label || '').toLowerCase(); + + return getNormalizedString(a).localeCompare(getNormalizedString(b)); + }; + + const groupedOptions: EuiComboBoxProps['options'] = Object.values( + fields.reduce>>((acc, field) => { + if (isFieldTypeEnabled(field.type) && isFieldEnabled(field.name, type, uiRestrictions)) { + const item: EuiComboBoxOptionOption = { + value: field.name, + label: field.label ?? field.name, + disabled: selectedIds.includes(field.name), + }; + + const fieldTypeOptions = acc[field.type]?.options; + + if (fieldTypeOptions) { + fieldTypeOptions.push(item); + } else { + acc[field.type] = { + options: [item], + label: field.type, + }; + } + } + + return acc; + }, {}) + ); + + // sort groups + groupedOptions.sort(sortByLabel); + + // sort items + groupedOptions.forEach((group) => { + if (Array.isArray(group.options)) { + group.options.sort(sortByLabel); + } + }); + + return groupedOptions; +}; + +export const findInGroupedOptions = ( + groupedOptions: EuiComboBoxProps['options'], + fieldName: string +) => + (groupedOptions || []) + .map((i) => i.options) + .flat() + .find((i) => i?.value === fieldName); + +export const updateItem = ( + existingItems: Array, + value: string | null = null, + index: number = 0 +) => { + const arr = [...existingItems]; + arr[index] = value; + return arr; +}; + +export const addNewItem = (existingItems: Array, insertAfter: number = 0) => { + const arr = [...existingItems]; + arr.splice(insertAfter + 1, 0, null); + return arr; +}; + +export const deleteItem = (existingItems: Array, index: number = 0) => + existingItems.filter((item, i) => i !== index); + +export const swapItems = ( + existingItems: Array, + source: number = 0, + destination: number = 0 +) => { + const arr = [...existingItems]; + arr.splice(destination, 0, arr.splice(source, 1)[0]); + return arr; +}; diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/index.ts b/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/index.ts new file mode 100644 index 000000000000..5dc0b1edaaff --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/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 { FieldSelect } from './field_select'; diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/multi_field_select.tsx b/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/multi_field_select.tsx new file mode 100644 index 000000000000..7b96a599c8a4 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select/multi_field_select.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { i18n } from '@kbn/i18n'; +import { + EuiDragDropContext, + EuiDroppable, + DragDropContextProps, + EuiDraggable, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiIcon, +} from '@elastic/eui'; +import React, { FunctionComponent } from 'react'; + +const DROPPABLE_ID = 'onDragEnd'; + +const dragAriaLabel = i18n.translate('visTypeTimeseries.fieldSelect.dragAriaLabel', { + defaultMessage: 'Drag field', +}); + +export function MultiFieldSelect(props: { + values: Array; + onDragEnd: DragDropContextProps['onDragEnd']; + WrappedComponent: FunctionComponent<{ value?: string | null; index?: number }>; +}) { + return ( + + + {props.values.map((value, index) => ( + + {(provided) => ( + + + + + + + + + + + )} + + ))} + + + ); +} diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/filter_ratio.js b/src/plugins/vis_types/timeseries/public/application/components/aggs/filter_ratio.js index 9f8285bc97e2..b24ac1471756 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/aggs/filter_ratio.js +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/filter_ratio.js @@ -168,7 +168,11 @@ export const FilterRatioAgg = (props) => { restrict={getSupportedFieldsByMetricType(model.metric_agg)} indexPattern={indexPattern} value={model.field} - onChange={handleSelectChange('field')} + onChange={(value) => + handleChange({ + field: value?.[0], + }) + } /> ) : null} diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/filter_ratio.test.js b/src/plugins/vis_types/timeseries/public/application/components/aggs/filter_ratio.test.js index 38305395bfbb..bebbbc6f8614 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/aggs/filter_ratio.test.js +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/filter_ratio.test.js @@ -72,6 +72,7 @@ describe('TSVB Filter Ratio', () => { label: 'number', options: [ { + disabled: false, label: 'system.cpu.user.pct', value: 'system.cpu.user.pct', }, @@ -95,6 +96,7 @@ describe('TSVB Filter Ratio', () => { "label": "date", "options": Array [ Object { + "disabled": false, "label": "@timestamp", "value": "@timestamp", }, @@ -104,6 +106,7 @@ describe('TSVB Filter Ratio', () => { "label": "number", "options": Array [ Object { + "disabled": false, "label": "system.cpu.user.pct", "value": "system.cpu.user.pct", }, diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/percentile.js b/src/plugins/vis_types/timeseries/public/application/components/aggs/percentile.js index b9512249de94..c098eb83ddf9 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/aggs/percentile.js +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/percentile.js @@ -90,7 +90,11 @@ export function PercentileAgg(props) { restrict={RESTRICT_FIELDS} indexPattern={indexPattern} value={model.field} - onChange={handleSelectChange('field')} + onChange={(value) => + handleChange({ + field: value?.[0], + }) + } /> 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 664c59b27fa3..57dfa23c815d 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 @@ -45,7 +45,7 @@ interface PercentileRankAggProps { series: Series; dragHandleProps: DragHandleProps; onAdd(): void; - onChange(): void; + onChange(partialModel: Record): void; onDelete(): void; } @@ -111,7 +111,11 @@ export const PercentileRankAgg = (props: PercentileRankAggProps) => { restrict={RESTRICT_FIELDS} indexPattern={indexPattern} value={model.field ?? ''} - onChange={handleSelectChange('field')} + onChange={(value) => + props.onChange({ + field: value?.[0], + }) + } /> diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/positive_rate.js b/src/plugins/vis_types/timeseries/public/application/components/aggs/positive_rate.js index 20ae5ecd2431..35786efa98c5 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/aggs/positive_rate.js +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/positive_rate.js @@ -111,7 +111,11 @@ export const PositiveRateAgg = (props) => { restrict={[KBN_FIELD_TYPES.NUMBER]} indexPattern={indexPattern} value={model.field} - onChange={handleSelectChange('field')} + onChange={(value) => + handleChange({ + field: value?.[0], + }) + } uiRestrictions={props.uiRestrictions} fullWidth /> diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/std_agg.js b/src/plugins/vis_types/timeseries/public/application/components/aggs/std_agg.js index 722e9021b8a6..61579c9656d5 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/aggs/std_agg.js +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/std_agg.js @@ -68,7 +68,11 @@ export function StandardAgg(props) { restrict={restrictFields} indexPattern={indexPattern} value={model.field} - onChange={handleSelectChange('field')} + onChange={(value) => + handleChange({ + field: value?.[0], + }) + } uiRestrictions={uiRestrictions} fullWidth /> diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/std_deviation.js b/src/plugins/vis_types/timeseries/public/application/components/aggs/std_deviation.js index f9a54cb11174..375d576f8cf2 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/aggs/std_deviation.js +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/std_deviation.js @@ -119,7 +119,11 @@ const StandardDeviationAggUi = (props) => { restrict={RESTRICT_FIELDS} indexPattern={indexPattern} value={model.field} - onChange={handleSelectChange('field')} + onChange={(value) => + handleChange({ + field: value?.[0], + }) + } /> diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/top_hit.js b/src/plugins/vis_types/timeseries/public/application/components/aggs/top_hit.js index 7fa708331ac5..7dec7d94236e 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/aggs/top_hit.js +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/top_hit.js @@ -180,7 +180,11 @@ const TopHitAggUi = (props) => { restrict={aggWithOptionsRestrictFields} indexPattern={indexPattern} value={model.field} - onChange={handleSelectChange('field')} + onChange={(value) => + handleChange({ + field: value?.[0], + }) + } /> @@ -242,7 +246,11 @@ const TopHitAggUi = (props) => { } restrict={ORDER_DATE_RESTRICT_FIELDS} value={model.order_by} - onChange={handleSelectChange('order_by')} + onChange={(value) => + handleChange({ + order_by: value?.[0], + }) + } indexPattern={indexPattern} fields={fields} data-test-subj="topHitOrderByFieldSelect" diff --git a/src/plugins/vis_types/timeseries/public/application/components/annotation_row.tsx b/src/plugins/vis_types/timeseries/public/application/components/annotation_row.tsx index 856948cb7601..562fb75089e1 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/annotation_row.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/annotation_row.tsx @@ -148,8 +148,12 @@ export const AnnotationRow = ({ /> } restrict={RESTRICT_FIELDS} - value={model.time_field} - onChange={handleChange(TIME_FIELD_KEY)} + value={model[TIME_FIELD_KEY]} + onChange={(value) => + onChange({ + [TIME_FIELD_KEY]: value?.[0] ?? undefined, + }) + } indexPattern={model.index_pattern} fields={fields} /> diff --git a/src/plugins/vis_types/timeseries/public/application/components/index_pattern.js b/src/plugins/vis_types/timeseries/public/application/components/index_pattern.js index 217b3948e1cd..7b3ae5f3e16e 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/index_pattern.js +++ b/src/plugins/vis_types/timeseries/public/application/components/index_pattern.js @@ -259,7 +259,11 @@ export const IndexPattern = ({ restrict={RESTRICT_FIELDS} value={model[timeFieldName]} disabled={disabled} - onChange={handleSelectChange(timeFieldName)} + onChange={(value) => + onChange({ + [timeFieldName]: value?.[0], + }) + } indexPattern={model[indexPatternName]} fields={fields} placeholder={ diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/check_if_numeric_metric.test.ts b/src/plugins/vis_types/timeseries/public/application/components/lib/check_if_numeric_metric.test.ts index eb6ea561fec8..2eaee9609911 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/check_if_numeric_metric.test.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/check_if_numeric_metric.test.ts @@ -11,6 +11,7 @@ import { TSVB_METRIC_TYPES } from '../../../../common/enums'; import { checkIfNumericMetric } from './check_if_numeric_metric'; import type { Metric } from '../../../../common/types'; +import type { VisFields } from '../../lib/fetch_fields'; describe('checkIfNumericMetric(metric, fields, indexPattern)', () => { const indexPattern = { id: 'some_id' }; @@ -20,7 +21,7 @@ describe('checkIfNumericMetric(metric, fields, indexPattern)', () => { { name: 'string field', type: 'string' }, { name: 'date field', type: 'date' }, ], - }; + } as VisFields; it('should return true for Count metric', () => { const metric = { type: METRIC_TYPES.COUNT } as Metric; diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.test.ts b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.test.ts index 15151a9e21bc..08ee275d144e 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.test.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.test.ts @@ -201,11 +201,11 @@ describe('convert series to datatables', () => { expect(tables.series1.rows.length).toEqual(8); const expected1 = series[0].data.map((d) => { - d.push(parseInt(series[0].label, 10)); + d.push(parseInt([series[0].label].flat()[0], 10)); return d; }); const expected2 = series[1].data.map((d) => { - d.push(parseInt(series[1].label, 10)); + d.push(parseInt([series[1].label].flat()[0], 10)); return d; }); expect(tables.series1.rows).toEqual([...expected1, ...expected2]); diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.ts b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.ts index 8e7c1694357c..62397e3b1d8c 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.ts @@ -10,6 +10,7 @@ import { DatatableRow, DatatableColumn, DatatableColumnType } from 'src/plugins/ import { Query } from 'src/plugins/data/common'; import { TimeseriesVisParams } from '../../../types'; import type { PanelData, Metric } from '../../../../common/types'; +import { getMultiFieldLabel, getFieldsForTerms } from '../../../../common/fields_utils'; import { BUCKET_TYPES, TSVB_METRIC_TYPES } from '../../../../common/enums'; import { fetchIndexPattern } from '../../../../common/index_patterns_utils'; import { getDataStart } from '../../../services'; @@ -131,7 +132,7 @@ export const convertSeriesToDataTable = async ( id++; columns.push({ id, - name: layer.terms_field || '', + name: getMultiFieldLabel(getFieldsForTerms(layer.terms_field)), isMetric: false, type: BUCKET_TYPES.TERMS, }); @@ -154,7 +155,7 @@ export const convertSeriesToDataTable = async ( const row: DatatableRow = [rowData[0], rowData[1]]; // If the layer is split by terms aggregation, the data array should also contain the split value. if (isGroupedByTerms || filtersColumn) { - row.push(seriesPerLayer[j].label); + row.push([seriesPerLayer[j].label].flat()[0]); } return row; }); diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_vars.js b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_vars.js index 867ba673cf1d..34efcf678d7a 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_vars.js +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_vars.js @@ -16,6 +16,7 @@ import { getMetricsField } from './get_metrics_field'; import { createFieldFormatter } from './create_field_formatter'; import { labelDateFormatter } from './label_date_formatter'; import moment from 'moment'; +import { getFieldsForTerms } from '../../../../common/fields_utils'; export const convertSeriesToVars = (series, model, getConfig = null, fieldFormatMap) => { const variables = {}; @@ -50,10 +51,16 @@ export const convertSeriesToVars = (series, model, getConfig = null, fieldFormat }), }, }; - const rowLabel = - seriesModel.split_mode === BUCKET_TYPES.TERMS - ? createFieldFormatter(seriesModel.terms_field, fieldFormatMap)(row.label) - : row.label; + + let rowLabel = row.label; + if (seriesModel.split_mode === BUCKET_TYPES.TERMS) { + const fieldsForTerms = getFieldsForTerms(seriesModel.terms_field); + + if (fieldsForTerms.length === 1) { + rowLabel = createFieldFormatter(fieldsForTerms[0], fieldFormatMap)(row.label); + } + } + set(variables, varName, data); set(variables, `${label}.label`, rowLabel); diff --git a/src/plugins/vis_types/timeseries/public/application/components/panel_config/table.tsx b/src/plugins/vis_types/timeseries/public/application/components/panel_config/table.tsx index 57ae699d281a..cefcd9c2e542 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/panel_config/table.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/panel_config/table.tsx @@ -23,7 +23,6 @@ import { EuiHorizontalRule, EuiCode, EuiText, - EuiComboBoxOptionOption, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -65,16 +64,27 @@ export class TablePanelConfig extends Component< this.setState({ selectedTab }); } - handlePivotChange = (selectedOption: Array>) => { + handlePivotChange = (selectedOptions: Array) => { const { fields, model } = this.props; - const pivotId = get(selectedOption, '[0].value', null); - const field = fields[getIndexPatternKey(model.index_pattern)].find((f) => f.name === pivotId); - const pivotType = get(field, 'type', model.pivot_type); - this.props.onChange({ - pivot_id: pivotId, - pivot_type: pivotType, - }); + const getPivotType = (fieldName?: string | null): KBN_FIELD_TYPES | null => { + const field = fields[getIndexPatternKey(model.index_pattern)].find( + (f) => f.name === fieldName + ); + return get(field, 'type', null); + }; + + this.props.onChange( + selectedOptions.length === 1 + ? { + pivot_id: selectedOptions[0] || undefined, + pivot_type: getPivotType(selectedOptions[0]) || undefined, + } + : { + pivot_id: selectedOptions, + pivot_type: selectedOptions.map((item) => getPivotType(item)), + } + ); }; handleTextChange = @@ -129,6 +139,8 @@ export class TablePanelConfig extends Component< onChange={this.handlePivotChange} uiRestrictions={this.context.uiRestrictions} type={BUCKET_TYPES.TERMS} + allowMultiSelect={true} + fullWidth={true} /> diff --git a/src/plugins/vis_types/timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap b/src/plugins/vis_types/timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap index 524e35f9d29e..b7c5095535fb 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap +++ b/src/plugins/vis_types/timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap @@ -20,13 +20,14 @@ exports[`src/legacy/core_plugins/metrics/public/components/splits/terms.test.js } labelType="label" > - ({ - ...field, - disabled: !isGroupByFieldsEnabled(field.value, uiRestrictions), - })); - - const selectedValue = props.value || 'everything'; - const selectedOption = modeOptions.find((option) => { - return selectedValue === option.value; - }); - - return ( - - ); -} - -GroupBySelectUi.propTypes = { - onChange: PropTypes.func, - value: PropTypes.string, - uiRestrictions: PropTypes.object, -}; - -export const GroupBySelect = injectI18n(GroupBySelectUi); diff --git a/src/plugins/vis_types/timeseries/public/application/components/splits/group_by_select.tsx b/src/plugins/vis_types/timeseries/public/application/components/splits/group_by_select.tsx new file mode 100644 index 000000000000..9c0e26b642f8 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/application/components/splits/group_by_select.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiComboBoxProps } from '@elastic/eui'; +import { isGroupByFieldsEnabled } from '../../../../common/check_ui_restrictions'; +import type { TimeseriesUIRestrictions } from '../../../../common/ui_restrictions'; + +interface GroupBySelectProps { + id: string; + onChange: EuiComboBoxProps['onChange']; + value?: string; + uiRestrictions: TimeseriesUIRestrictions; +} + +const getAvailableOptions = () => [ + { + label: i18n.translate('visTypeTimeseries.splits.groupBySelect.modeOptions.everythingLabel', { + defaultMessage: 'Everything', + }), + value: 'everything', + }, + { + label: i18n.translate('visTypeTimeseries.splits.groupBySelect.modeOptions.filterLabel', { + defaultMessage: 'Filter', + }), + value: 'filter', + }, + { + label: i18n.translate('visTypeTimeseries.splits.groupBySelect.modeOptions.filtersLabel', { + defaultMessage: 'Filters', + }), + value: 'filters', + }, + { + label: i18n.translate('visTypeTimeseries.splits.groupBySelect.modeOptions.termsLabel', { + defaultMessage: 'Terms', + }), + value: 'terms', + }, +]; + +export const GroupBySelect = ({ + id, + onChange, + value = 'everything', + uiRestrictions, +}: GroupBySelectProps) => { + const modeOptions = getAvailableOptions().map((field) => ({ + ...field, + disabled: !isGroupByFieldsEnabled(field.value, uiRestrictions), + })); + + const selectedOption: EuiComboBoxOptionOption | undefined = modeOptions.find( + (option) => value === option.value + ); + + return ( + + ); +}; diff --git a/src/plugins/vis_types/timeseries/public/application/components/splits/terms.js b/src/plugins/vis_types/timeseries/public/application/components/splits/terms.js index 80810f552d3a..4810932c9b4a 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/splits/terms.js +++ b/src/plugins/vis_types/timeseries/public/application/components/splits/terms.js @@ -7,7 +7,7 @@ */ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useCallback } from 'react'; import { get, find } from 'lodash'; import { GroupBySelect } from './group_by_select'; import { createTextHandler } from '../lib/create_text_handler'; @@ -91,6 +91,15 @@ export const SplitByTermsUI = ({ const selectedField = find(fields[fieldsSelector], ({ name }) => name === model.terms_field); const selectedFieldType = get(selectedField, 'type'); + const onTermsFieldChange = useCallback( + (selectedOptions) => { + onChange({ + terms_field: selectedOptions.length === 1 ? selectedOptions[0] : selectedOptions, + }); + }, + [onChange] + ); + if ( seriesQuantity && model.stacked === STACKED_OPTIONS.PERCENT && @@ -142,11 +151,12 @@ export const SplitByTermsUI = ({ ]} data-test-subj="groupByField" indexPattern={indexPattern} - onChange={handleSelectChange('terms_field')} + onChange={onTermsFieldChange} value={model.terms_field} fields={fields} uiRestrictions={uiRestrictions} type={'terms'} + allowMultiSelect={true} /> 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 ae699880784a..7787f0f6929b 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 @@ -190,6 +190,7 @@ function TimeseriesVisualization({ onUiState={handleUiState} syncColors={syncColors} palettesService={palettesService} + indexPattern={indexPattern} fieldFormatMap={indexPattern?.fieldFormatMap} /> diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx b/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx index 4c71581fcb0b..59710cbcff61 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx @@ -239,6 +239,7 @@ export class VisEditor extends Component + this.props.onChange({ + aggregate_by: value?.[0], + }) + } fullWidth restrict={[ KBN_FIELD_TYPES.NUMBER, diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_types/table/vis.js b/src/plugins/vis_types/timeseries/public/application/components/vis_types/table/vis.js index 3e828a1b833b..549aa1b70082 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_types/table/vis.js +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_types/table/vis.js @@ -18,11 +18,17 @@ import { isSortable } from './is_sortable'; import { EuiToolTip, EuiIcon } from '@elastic/eui'; import { replaceVars } from '../../lib/replace_vars'; import { ExternalUrlErrorModal } from '../../lib/external_url_error_modal'; -import { FIELD_FORMAT_IDS } from '../../../../../../../../plugins/field_formats/common'; import { FormattedMessage } from '@kbn/i18n-react'; import { getFieldFormats, getCoreStart } from '../../../../services'; import { DATA_FORMATTERS } from '../../../../../common/enums'; -import { getValueOrEmpty } from '../../../../../common/empty_label'; +import { FIELD_FORMAT_IDS } from '../../../../../../../../plugins/field_formats/common'; + +import { + createCachedFieldValueFormatter, + getFieldsForTerms, + getMultiFieldLabel, + MULTI_FIELD_VALUES_SEPARATOR, +} from '../../../../../common/fields_utils'; function getColor(rules, colorKey, value) { let color; @@ -49,12 +55,7 @@ function sanitizeUrl(url) { class TableVis extends Component { constructor(props) { super(props); - - const fieldFormatsService = getFieldFormats(); - const DateFormat = fieldFormatsService.getType(FIELD_FORMAT_IDS.DATE); - - this.dateFormatter = new DateFormat({}, this.props.getConfig); - + this.fieldFormatsService = getFieldFormats(); this.state = { accessDeniedDrilldownUrl: null, }; @@ -74,17 +75,21 @@ class TableVis extends Component { } }; - renderRow = (row) => { + renderRow = (row, pivotIds, fieldValuesFormatter) => { const { model, fieldFormatMap, getConfig } = this.props; - let rowDisplay = getValueOrEmpty( - model.pivot_type === 'date' ? this.dateFormatter.convert(row.key) : row.key - ); + let rowDisplay = row.key; + + if (pivotIds.length) { + rowDisplay = pivotIds + .map((item, index) => { + const value = [row.key ?? null].flat()[index]; + const formatted = fieldValuesFormatter(item, value, 'html'); - // we should skip url field formatting for key if tsvb have drilldown_url - if (fieldFormatMap?.[model.pivot_id]?.id !== FIELD_FORMAT_IDS.URL || !model.drilldown_url) { - const formatter = createFieldFormatter(model?.pivot_id, fieldFormatMap, 'html'); - rowDisplay = ; // eslint-disable-line react/no-danger + // eslint-disable-next-line react/no-danger + return ; + }) + .reduce((prev, curr) => [prev, MULTI_FIELD_VALUES_SEPARATOR, curr]); } if (model.drilldown_url) { @@ -150,7 +155,7 @@ class TableVis extends Component { ); }; - renderHeader() { + renderHeader(pivotIds) { const { model, uiState, onUiState, visData } = this.props; const stateKey = `${model.type}.sort`; const sort = uiState.get(stateKey, { @@ -210,7 +215,7 @@ class TableVis extends Component { ); }); - const label = visData.pivot_label || model.pivot_label || model.pivot_id; + const label = visData.pivot_label || model.pivot_label || getMultiFieldLabel(pivotIds); let sortIcon; if (sort.column === '_default_') { sortIcon = sort.order === 'asc' ? 'sortUp' : 'sortDown'; @@ -240,13 +245,26 @@ class TableVis extends Component { closeExternalUrlErrorModal = () => this.setState({ accessDeniedDrilldownUrl: null }); render() { - const { visData } = this.props; + const { visData, model, indexPattern } = this.props; const { accessDeniedDrilldownUrl } = this.state; - const header = this.renderHeader(); + const fields = (model.pivot_type ? [model.pivot_type ?? null].flat() : []).map( + (type, index) => ({ + name: [model.pivot_id ?? null].flat()[index], + type, + }) + ); + const fieldValuesFormatter = createCachedFieldValueFormatter( + indexPattern, + fields, + this.fieldFormatsService, + model.drilldown_url ? [FIELD_FORMAT_IDS.URL] : [] + ); + const pivotIds = getFieldsForTerms(model.pivot_id); + const header = this.renderHeader(pivotIds); let rows = null; if (isArray(visData.series) && visData.series.length) { - rows = visData.series.map(this.renderRow); + rows = visData.series.map((item) => this.renderRow(item, pivotIds, fieldValuesFormatter)); } return ( @@ -285,6 +303,7 @@ TableVis.propTypes = { uiState: PropTypes.object, pageNumber: PropTypes.number, getConfig: PropTypes.func, + indexPattern: PropTypes.object, }; // default export required for React.Lazy diff --git a/src/plugins/vis_types/timeseries/public/metrics_fn.ts b/src/plugins/vis_types/timeseries/public/metrics_fn.ts index 9de92ea0d1ca..5eadf170b768 100644 --- a/src/plugins/vis_types/timeseries/public/metrics_fn.ts +++ b/src/plugins/vis_types/timeseries/public/metrics_fn.ts @@ -57,7 +57,13 @@ export const createMetricsFn = (): TimeseriesExpressionFunctionDefinition => ({ async fn( input, args, - { getSearchSessionId, isSyncColorsEnabled, getExecutionContext, inspectorAdapters } + { + getSearchSessionId, + isSyncColorsEnabled, + getExecutionContext, + inspectorAdapters, + abortSignal: expressionAbortSignal, + } ) { const visParams: TimeseriesVisParams = JSON.parse(args.params); const uiState = JSON.parse(args.uiState); @@ -70,6 +76,7 @@ export const createMetricsFn = (): TimeseriesExpressionFunctionDefinition => ({ searchSessionId: getSearchSessionId(), executionContext: getExecutionContext(), inspectorAdapters, + expressionAbortSignal, }); return { diff --git a/src/plugins/vis_types/timeseries/public/request_handler.ts b/src/plugins/vis_types/timeseries/public/request_handler.ts index bb15f32886cd..dcb1b0691602 100644 --- a/src/plugins/vis_types/timeseries/public/request_handler.ts +++ b/src/plugins/vis_types/timeseries/public/request_handler.ts @@ -22,6 +22,7 @@ interface MetricsRequestHandlerParams { searchSessionId?: string; executionContext?: KibanaExecutionContext; inspectorAdapters?: Adapters; + expressionAbortSignal: AbortSignal; } export const metricsRequestHandler = async ({ @@ -31,63 +32,72 @@ export const metricsRequestHandler = async ({ searchSessionId, executionContext, inspectorAdapters, + expressionAbortSignal, }: MetricsRequestHandlerParams): Promise => { - const config = getUISettings(); - const data = getDataStart(); - const theme = getCoreStart().theme; + if (!expressionAbortSignal.aborted) { + const config = getUISettings(); + const data = getDataStart(); + const theme = getCoreStart().theme; + const abortController = new AbortController(); + const expressionAbortHandler = function () { + abortController.abort(); + }; - const timezone = getTimezone(config); - const uiStateObj = uiState[visParams.type] ?? {}; - const dataSearch = data.search; - const parsedTimeRange = data.query.timefilter.timefilter.calculateBounds(input?.timeRange!); + expressionAbortSignal.addEventListener('abort', expressionAbortHandler); - if (visParams && visParams.id && !visParams.isModelInvalid) { - const untrackSearch = - dataSearch.session.isCurrentSession(searchSessionId) && - dataSearch.session.trackSearch({ - abort: () => { - // TODO: support search cancellations - }, - }); + const timezone = getTimezone(config); + const uiStateObj = uiState[visParams.type] ?? {}; + const dataSearch = data.search; + const parsedTimeRange = data.query.timefilter.timefilter.calculateBounds(input?.timeRange!); - try { - const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId); + if (visParams && visParams.id && !visParams.isModelInvalid && !expressionAbortSignal.aborted) { + const untrackSearch = + dataSearch.session.isCurrentSession(searchSessionId) && + dataSearch.session.trackSearch({ + abort: () => abortController.abort(), + }); - const visData: TimeseriesVisData = await getCoreStart().http.post(ROUTES.VIS_DATA, { - body: JSON.stringify({ - timerange: { - timezone, - ...parsedTimeRange, - }, - query: input?.query, - filters: input?.filters, - panels: [visParams], - state: uiStateObj, - ...(searchSessionOptions && { - searchSession: searchSessionOptions, + try { + const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId); + + const visData: TimeseriesVisData = await getCoreStart().http.post(ROUTES.VIS_DATA, { + body: JSON.stringify({ + timerange: { + timezone, + ...parsedTimeRange, + }, + query: input?.query, + filters: input?.filters, + panels: [visParams], + state: uiStateObj, + ...(searchSessionOptions && { + searchSession: searchSessionOptions, + }), }), - }), - context: executionContext, - }); + context: executionContext, + signal: abortController.signal, + }); - inspectorAdapters?.requests?.reset(); + inspectorAdapters?.requests?.reset(); - Object.entries(visData.trackedEsSearches || {}).forEach(([key, query]) => { - inspectorAdapters?.requests - ?.start(query.label ?? key, { searchSessionId }) - .json(query.body) - .ok({ time: query.time }); + Object.entries(visData.trackedEsSearches || {}).forEach(([key, query]) => { + inspectorAdapters?.requests + ?.start(query.label ?? key, { searchSessionId }) + .json(query.body) + .ok({ time: query.time }); - if (query.response && config.get(UI_SETTINGS.ALLOW_CHECKING_FOR_FAILED_SHARDS)) { - handleResponse({ body: query.body }, { rawResponse: query.response }, theme); - } - }); + if (query.response && config.get(UI_SETTINGS.ALLOW_CHECKING_FOR_FAILED_SHARDS)) { + handleResponse({ body: query.body }, { rawResponse: query.response }, theme); + } + }); - return visData; - } finally { - if (untrackSearch && dataSearch.session.isCurrentSession(searchSessionId)) { - // untrack if this search still belongs to current session - untrackSearch(); + return visData; + } finally { + if (untrackSearch && dataSearch.session.isCurrentSession(searchSessionId)) { + // untrack if this search still belongs to current session + untrackSearch(); + } + expressionAbortSignal.removeEventListener('abort', expressionAbortHandler); } } } diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_series.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_series.test.ts index 3b01aeebb2dc..31c807248661 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/get_series.test.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_series.test.ts @@ -17,7 +17,7 @@ describe('getSeries', () => { field: 'day_of_week_i', }, ] as Metric[]; - const config = getSeries(metric); + const config = getSeries(metric, 1); expect(config).toStrictEqual([ { agg: 'average', @@ -44,7 +44,7 @@ describe('getSeries', () => { }, }, ] as Metric[]; - const config = getSeries(metric); + const config = getSeries(metric, 1); expect(config).toStrictEqual([ { agg: 'formula', @@ -71,7 +71,7 @@ describe('getSeries', () => { field: '123456', }, ] as Metric[]; - const config = getSeries(metric); + const config = getSeries(metric, 1); expect(config).toStrictEqual([ { agg: 'formula', @@ -97,7 +97,7 @@ describe('getSeries', () => { field: '123456', }, ] as Metric[]; - const config = getSeries(metric); + const config = getSeries(metric, 1); expect(config).toStrictEqual([ { agg: 'formula', @@ -122,7 +122,7 @@ describe('getSeries', () => { field: '123456', }, ] as Metric[]; - const config = getSeries(metric); + const config = getSeries(metric, 1); expect(config).toStrictEqual([ { agg: 'cumulative_sum', @@ -147,7 +147,7 @@ describe('getSeries', () => { field: '123456', }, ] as Metric[]; - const config = getSeries(metric); + const config = getSeries(metric, 1); expect(config).toStrictEqual([ { agg: 'formula', @@ -174,7 +174,7 @@ describe('getSeries', () => { unit: '1m', }, ] as Metric[]; - const config = getSeries(metric); + const config = getSeries(metric, 1); expect(config).toStrictEqual([ { agg: 'differences', @@ -202,7 +202,7 @@ describe('getSeries', () => { window: 6, }, ] as Metric[]; - const config = getSeries(metric); + const config = getSeries(metric, 1); expect(config).toStrictEqual([ { agg: 'moving_average', @@ -246,7 +246,7 @@ describe('getSeries', () => { window: 6, }, ] as Metric[]; - const config = getSeries(metric); + const config = getSeries(metric, 1); expect(config).toStrictEqual([ { agg: 'formula', @@ -293,7 +293,7 @@ describe('getSeries', () => { ], }, ] as Metric[]; - const config = getSeries(metric); + const config = getSeries(metric, 1); expect(config).toStrictEqual([ { agg: 'percentile', @@ -335,7 +335,7 @@ describe('getSeries', () => { order_by: 'timestamp', }, ] as Metric[]; - const config = getSeries(metric); + const config = getSeries(metric, 1); expect(config).toStrictEqual([ { agg: 'last_value', @@ -357,10 +357,43 @@ describe('getSeries', () => { size: 2, }, ] as Metric[]; - const config = getSeries(metric); + const config = getSeries(metric, 1); expect(config).toBeNull(); }); + test('should return null for a static aggregation with 1 layer', () => { + const metric = [ + { + id: '12345', + type: 'static', + value: '10', + }, + ] as Metric[]; + const config = getSeries(metric, 1); + expect(config).toBeNull(); + }); + + test('should return the correct config for a static aggregation with 2 layers', () => { + const metric = [ + { + id: '12345', + type: 'static', + value: '10', + }, + ] as Metric[]; + const config = getSeries(metric, 2); + expect(config).toStrictEqual([ + { + agg: 'static_value', + fieldName: 'document', + isFullReference: true, + params: { + value: '10', + }, + }, + ]); + }); + test('should return the correct formula for the math aggregation with percentiles as variables', () => { const metric = [ { @@ -415,7 +448,7 @@ describe('getSeries', () => { ], }, ] as Metric[]; - const config = getSeries(metric); + const config = getSeries(metric, 1); expect(config).toStrictEqual([ { agg: 'formula', diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_series.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_series.ts index 5e7d39f3085f..641890b68b83 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/get_series.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_series.ts @@ -21,7 +21,10 @@ import { getTimeScale, } from './metrics_helpers'; -export const getSeries = (metrics: Metric[]): VisualizeEditorLayersContext['metrics'] | null => { +export const getSeries = ( + metrics: Metric[], + totalSeriesNum: number +): VisualizeEditorLayersContext['metrics'] | null => { const metricIdx = metrics.length - 1; const aggregation = metrics[metricIdx].type; const fieldName = metrics[metricIdx].field; @@ -50,12 +53,10 @@ export const getSeries = (metrics: Metric[]): VisualizeEditorLayersContext['metr const variables = metrics[mathMetricIdx].variables; const layerMetricsArray = metrics; if (!finalScript || !variables) return null; + const metricsWithoutMath = layerMetricsArray.filter((metric) => metric.type !== 'math'); // create the script - for (let layerMetricIdx = 0; layerMetricIdx < layerMetricsArray.length; layerMetricIdx++) { - if (layerMetricsArray[layerMetricIdx].type === 'math') { - continue; - } + for (let layerMetricIdx = 0; layerMetricIdx < metricsWithoutMath.length; layerMetricIdx++) { const currentMetric = metrics[layerMetricIdx]; // We can only support top_hit with size 1 if ( @@ -102,7 +103,7 @@ export const getSeries = (metrics: Metric[]): VisualizeEditorLayersContext['metr // percentile value is derived from the field Id. It has the format xxx-xxx-xxx-xxx[percentile] const [fieldId, meta] = metrics[metricIdx]?.field?.split('[') ?? []; const subFunctionMetric = metrics.find((metric) => metric.id === fieldId); - if (!subFunctionMetric) { + if (!subFunctionMetric || subFunctionMetric.type === 'static') { return null; } const pipelineAgg = getPipelineAgg(subFunctionMetric); @@ -184,6 +185,24 @@ export const getSeries = (metrics: Metric[]): VisualizeEditorLayersContext['metr ]; break; } + case 'static': { + // Lens support reference lines only when at least one layer data exists + if (totalSeriesNum === 1) { + return null; + } + const staticValue = metrics[metricIdx].value; + metricsArray = [ + { + agg: aggregationMap.name, + isFullReference: aggregationMap.isFullReference, + fieldName: 'document', + params: { + ...(staticValue && { value: staticValue }), + }, + }, + ]; + break; + } default: { const timeScale = getTimeScale(metrics[metricIdx]); metricsArray = [ diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts index 2fad7f1d3d70..e12077c0239e 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts @@ -136,6 +136,7 @@ describe('triggerTSVBtoLensConfiguration', () => { splitWithDateHistogram: false, timeFieldName: 'timeField2', timeInterval: 'auto', + dropPartialBuckets: false, }, }, }); @@ -254,6 +255,15 @@ describe('triggerTSVBtoLensConfiguration', () => { expect(triggerOptions?.layers[0]?.timeInterval).toBe('1h'); }); + test('should return dropPartialbuckets if enabled', async () => { + const modelWithDropBuckets = { + ...model, + drop_last_bucket: 1, + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithDropBuckets); + expect(triggerOptions?.layers[0]?.dropPartialBuckets).toBe(true); + }); + test('should return the correct chart configuration', async () => { const modelWithConfig = { ...model, @@ -299,6 +309,7 @@ describe('triggerTSVBtoLensConfiguration', () => { splitWithDateHistogram: false, timeFieldName: 'timeField2', timeInterval: 'auto', + dropPartialBuckets: false, }, }, }); diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/index.ts b/src/plugins/vis_types/timeseries/public/trigger_action/index.ts index d3329bee803a..0df0ac55e35e 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/index.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/index.ts @@ -16,6 +16,7 @@ import { getDataSourceInfo } from './get_datasource_info'; import { getFieldType } from './get_field_type'; import { getSeries } from './get_series'; import { getYExtents } from './get_extents'; +import { getFieldsForTerms } from '../../common/fields_utils'; const SUPPORTED_FORMATTERS = ['bytes', 'percent', 'number']; @@ -36,6 +37,11 @@ export const triggerTSVBtoLensConfiguration = async ( return null; } const layersConfiguration: { [key: string]: VisualizeEditorLayersContext } = {}; + // get the active series number + let seriesNum = 0; + model.series.forEach((series) => { + if (!series.hidden) seriesNum++; + }); // handle multiple layers/series for (let layerIdx = 0; layerIdx < model.series.length; layerIdx++) { @@ -63,7 +69,7 @@ export const triggerTSVBtoLensConfiguration = async ( } // handle multiple metrics - let metricsArray = getSeries(layer.metrics); + let metricsArray = getSeries(layer.metrics, seriesNum); if (!metricsArray) { return null; } @@ -99,13 +105,21 @@ export const triggerTSVBtoLensConfiguration = async ( } const palette = layer.palette as PaletteOutput; + const splitFields = getFieldsForTerms(layer.terms_field); // in case of terms in a date field, we want to apply the date_histogram let splitWithDateHistogram = false; - if (layer.terms_field && layer.split_mode === 'terms') { - const fieldType = await getFieldType(indexPatternId, layer.terms_field); - if (fieldType === 'date') { - splitWithDateHistogram = true; + if (layer.terms_field && layer.split_mode === 'terms' && splitFields) { + for (const f of splitFields) { + const fieldType = await getFieldType(indexPatternId, f); + + if (fieldType === 'date') { + if (splitFields.length === 1) { + splitWithDateHistogram = true; + } else { + return null; + } + } } } @@ -114,7 +128,7 @@ export const triggerTSVBtoLensConfiguration = async ( timeFieldName: timeField, chartType, axisPosition: layer.separate_axis ? layer.axis_position : model.axis_position, - ...(layer.terms_field && { splitField: layer.terms_field }), + ...(layer.terms_field && { splitFields }), splitWithDateHistogram, ...(layer.split_mode !== 'everything' && { splitMode: layer.split_mode }), ...(splitFilters.length > 0 && { splitFilters }), @@ -136,6 +150,9 @@ export const triggerTSVBtoLensConfiguration = async ( timeInterval: model.interval && !model.interval?.includes('=') ? model.interval : 'auto', ...(SUPPORTED_FORMATTERS.includes(layer.formatter) && { format: layer.formatter }), ...(layer.label && { label: layer.label }), + dropPartialBuckets: layer.override_index_pattern + ? layer.series_drop_last_bucket > 0 + : model.drop_last_bucket > 0, }; layersConfiguration[layerIdx] = layerConfiguration; } diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.ts b/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.ts index dc9457ac1faf..a71f43f2a6a2 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.ts @@ -91,7 +91,7 @@ export const getParentPipelineSeries = ( // percentile value is derived from the field Id. It has the format xxx-xxx-xxx-xxx[percentile] const [fieldId, meta] = currentMetric?.field?.split('[') ?? []; const subFunctionMetric = metrics.find((metric) => metric.id === fieldId); - if (!subFunctionMetric) { + if (!subFunctionMetric || subFunctionMetric.type === 'static') { return null; } const pipelineAgg = getPipelineAgg(subFunctionMetric); @@ -184,7 +184,7 @@ export const getSiblingPipelineSeriesFormula = ( metrics: Metric[] ) => { const subFunctionMetric = metrics.find((metric) => metric.id === currentMetric.field); - if (!subFunctionMetric) { + if (!subFunctionMetric || subFunctionMetric.type === 'static') { return null; } const pipelineAggMap = SUPPORTED_METRICS[subFunctionMetric.type]; @@ -311,6 +311,9 @@ export const getFormulaEquivalent = ( case 'filter_ratio': { return getFilterRatioFormula(currentMetric); } + case 'static': { + return `${currentMetric.value}`; + } default: { return `${aggregation}(${currentMetric.field})`; } diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/supported_metrics.ts b/src/plugins/vis_types/timeseries/public/trigger_action/supported_metrics.ts index 354b60c31854..3a304590b642 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/supported_metrics.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/supported_metrics.ts @@ -92,4 +92,8 @@ export const SUPPORTED_METRICS: { [key: string]: AggOptions } = { name: 'clamp', isFullReference: true, }, + static: { + name: 'static_value', + isFullReference: true, + }, }; diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts index 1a52132612f7..f52d1bd9b742 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts @@ -7,8 +7,8 @@ */ import { IndexPatternsService } from '../../../../../../data/common'; - import { from } from 'rxjs'; + import { AbstractSearchStrategy, EsSearchRequest } from './abstract_search_strategy'; import type { FieldSpec } from '../../../../../../data/common'; import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; @@ -76,6 +76,9 @@ describe('AbstractSearchStrategy', () => { isStored: true, }, }, + events: { + aborted$: from([]), + }, } as unknown as VisTypeTimeseriesVisDataRequest, searches ); @@ -90,6 +93,7 @@ describe('AbstractSearchStrategy', () => { indexType: undefined, }, { + abortSignal: new AbortController().signal, sessionId: '1', isRestore: false, isStored: true, diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 1d3650ccedbd..58c67f84a937 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -5,9 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import { tap } from 'rxjs/operators'; import { omit } from 'lodash'; +import type { Observable } from 'rxjs'; import { IndexPatternsService } from '../../../../../../data/server'; import { toSanitizedFieldType } from '../../../../common/fields_utils'; @@ -27,6 +27,12 @@ export interface EsSearchRequest { }; } +function getRequestAbortedSignal(aborted$: Observable): AbortSignal { + const controller = new AbortController(); + aborted$.subscribe(() => controller.abort()); + return controller.signal; +} + export abstract class AbstractSearchStrategy { async search( requestContext: VisTypeTimeseriesRequestHandlerContext, @@ -37,6 +43,10 @@ export abstract class AbstractSearchStrategy { ) { const requests: any[] = []; + // User may abort the request without waiting for the results + // we need to handle this scenario by aborting underlying server requests + const abortSignal = getRequestAbortedSignal(req.events.aborted$); + esRequests.forEach(({ body, index, trackingEsSearchMeta }) => { const startTime = Date.now(); requests.push( @@ -49,7 +59,7 @@ export abstract class AbstractSearchStrategy { index, }, }, - req.body.searchSession + { ...req.body.searchSession, abortSignal } ) .pipe( tap((data) => { diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/get_table_data.ts b/src/plugins/vis_types/timeseries/server/lib/vis_data/get_table_data.ts index 2b63749fac64..9f8bf18683ca 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/get_table_data.ts +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/get_table_data.ts @@ -14,7 +14,7 @@ import { handleErrorResponse } from './handle_error_response'; import { processBucket } from './table/process_bucket'; import { createFieldsFetcher } from '../search_strategies/lib/fields_fetcher'; -import { extractFieldLabel } from '../../../common/fields_utils'; +import { getFieldsForTerms, getMultiFieldLabel } from '../../../common/fields_utils'; import { isAggSupported } from './helpers/check_aggs'; import { isConfigurationFeatureEnabled } from '../../../common/check_ui_restrictions'; import { FilterCannotBeAppliedError, PivotNotSelectedForTableError } from '../../../common/errors'; @@ -62,12 +62,15 @@ export async function getTableData( }); const calculatePivotLabel = async () => { - if (panel.pivot_id && panelIndex.indexPattern?.id) { - const fields = await extractFields({ id: panelIndex.indexPattern.id }); + const pivotIds = getFieldsForTerms(panel.pivot_id); - return extractFieldLabel(fields, panel.pivot_id); + if (pivotIds.length) { + const fields = panelIndex.indexPattern?.id + ? await extractFields({ id: panelIndex.indexPattern.id }) + : []; + + return getMultiFieldLabel(pivotIds, fields); } - return panel.pivot_id; }; const meta: DataResponseMeta = { @@ -85,7 +88,7 @@ export async function getTableData( } }); - if (!panel.pivot_id) { + if (!getFieldsForTerms(panel.pivot_id).length) { throw new PivotNotSelectedForTableError(); } diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/helpers/get_splits.ts b/src/plugins/vis_types/timeseries/server/lib/vis_data/helpers/get_splits.ts index 1754fa6569dc..a28883b72980 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/helpers/get_splits.ts +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/helpers/get_splits.ts @@ -54,7 +54,7 @@ export async function getSplits { expect(doc.aggs.test.meta).toMatchInlineSnapshot(` Object { - "index": undefined, + "dataViewId": undefined, + "indexPatternString": undefined, "intervalString": "900000ms", "panelId": "panelId", "seriesId": "test", diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/series/split_by_everything.js b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/series/split_by_everything.js index 497b0106deec..6cefb08c2368 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/series/split_by_everything.js +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/series/split_by_everything.js @@ -7,12 +7,13 @@ */ import { overwrite } from '../../helpers'; +import { getFieldsForTerms } from '../../../../../common/fields_utils'; export function splitByEverything(req, panel, series) { return (next) => (doc) => { if ( series.split_mode === 'everything' || - (series.split_mode === 'terms' && !series.terms_field) + (series.split_mode === 'terms' && !getFieldsForTerms(series.terms_field).length) ) { overwrite(doc, `aggs.${series.id}.filter.match_all`, {}); } diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/series/split_by_terms.js b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/series/split_by_terms.js index 9c2bdbe03f88..07e8ef4a0e5f 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/series/split_by_terms.js +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/series/split_by_terms.js @@ -10,25 +10,41 @@ import { overwrite } from '../../helpers'; import { basicAggs } from '../../../../../common/basic_aggs'; import { getBucketsPath } from '../../helpers/get_buckets_path'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { validateField } from '../../../../../common/fields_utils'; +import { getFieldsForTerms, validateField } from '../../../../../common/fields_utils'; export function splitByTerms(req, panel, series, esQueryConfig, seriesIndex) { return (next) => (doc) => { - if (series.split_mode === 'terms' && series.terms_field) { - const termsField = series.terms_field; + const termsIds = getFieldsForTerms(series.terms_field); + + if (series.split_mode === 'terms' && termsIds.length) { + const termsType = termsIds.length > 1 ? 'multi_terms' : 'terms'; const orderByTerms = series.terms_order_by; - validateField(termsField, seriesIndex); + termsIds.forEach((termsField) => { + validateField(termsField, seriesIndex); + }); const direction = series.terms_direction || 'desc'; const metric = series.metrics.find((item) => item.id === orderByTerms); - overwrite(doc, `aggs.${series.id}.terms.field`, termsField); - overwrite(doc, `aggs.${series.id}.terms.size`, series.terms_size); + + if (termsType === 'multi_terms') { + overwrite( + doc, + `aggs.${series.id}.${termsType}.terms`, + termsIds.map((item) => ({ + field: item, + })) + ); + } else { + overwrite(doc, `aggs.${series.id}.${termsType}.field`, termsIds[0]); + } + + overwrite(doc, `aggs.${series.id}.${termsType}.size`, series.terms_size); if (series.terms_include) { - overwrite(doc, `aggs.${series.id}.terms.include`, series.terms_include); + overwrite(doc, `aggs.${series.id}.${termsType}.include`, series.terms_include); } if (series.terms_exclude) { - overwrite(doc, `aggs.${series.id}.terms.exclude`, series.terms_exclude); + overwrite(doc, `aggs.${series.id}.${termsType}.exclude`, series.terms_exclude); } if (metric && metric.type !== 'count' && ~basicAggs.indexOf(metric.type)) { const sortAggKey = `${orderByTerms}-SORT`; @@ -37,12 +53,12 @@ export function splitByTerms(req, panel, series, esQueryConfig, seriesIndex) { orderByTerms, sortAggKey ); - overwrite(doc, `aggs.${series.id}.terms.order`, { [bucketPath]: direction }); + overwrite(doc, `aggs.${series.id}.${termsType}.order`, { [bucketPath]: direction }); overwrite(doc, `aggs.${series.id}.aggs`, { [sortAggKey]: fn(metric) }); } else if (['_key', '_count'].includes(orderByTerms)) { - overwrite(doc, `aggs.${series.id}.terms.order`, { [orderByTerms]: direction }); + overwrite(doc, `aggs.${series.id}.${termsType}.order`, { [orderByTerms]: direction }); } else { - overwrite(doc, `aggs.${series.id}.terms.order`, { _count: direction }); + overwrite(doc, `aggs.${series.id}.${termsType}.order`, { _count: direction }); } } return next(doc); diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/table/date_histogram.ts b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/table/date_histogram.ts index a458c870be7d..246c133e9360 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/table/date_histogram.ts +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/table/date_histogram.ts @@ -24,7 +24,8 @@ export const dateHistogram: TableRequestProcessorsFunction = const meta: TableSearchRequestMeta = { timeField, - index: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined, + dataViewId: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined, + indexPatternString: seriesIndex.indexPatternString, panelId: panel.id, }; diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/table/pivot.ts b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/table/pivot.ts index 692d4ea23bc5..4c28cfb442e1 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/table/pivot.ts +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/table/pivot.ts @@ -8,7 +8,7 @@ import { get, last } from 'lodash'; import { overwrite, getBucketsPath, bucketTransform } from '../../helpers'; - +import { getFieldsForTerms } from '../../../../../common/fields_utils'; import { basicAggs } from '../../../../../common/basic_aggs'; import type { TableRequestProcessorsFunction } from './types'; @@ -18,15 +18,29 @@ export const pivot: TableRequestProcessorsFunction = (next) => (doc) => { const { sort } = req.body.state; + const pivotIds = getFieldsForTerms(panel.pivot_id); + const termsType = pivotIds.length > 1 ? 'multi_terms' : 'terms'; + + if (pivotIds.length) { + if (termsType === 'multi_terms') { + overwrite( + doc, + `aggs.pivot.${termsType}.terms`, + pivotIds.map((item: string) => ({ + field: item, + })) + ); + } else { + overwrite(doc, `aggs.pivot.${termsType}.field`, pivotIds[0]); + } + + overwrite(doc, `aggs.pivot.${termsType}.size`, panel.pivot_rows); - if (panel.pivot_id) { - overwrite(doc, 'aggs.pivot.terms.field', panel.pivot_id); - overwrite(doc, 'aggs.pivot.terms.size', panel.pivot_rows); if (sort) { const series = panel.series.find((item) => item.id === sort.column); const metric = series && last(series.metrics); if (metric && metric.type === 'count') { - overwrite(doc, 'aggs.pivot.terms.order', { _count: sort.order }); + overwrite(doc, `aggs.pivot.${termsType}.order`, { _count: sort.order }); } else if (metric && series && basicAggs.includes(metric.type)) { const sortAggKey = `${metric.id}-SORT`; const fn = bucketTransform[metric.type]; @@ -34,10 +48,10 @@ export const pivot: TableRequestProcessorsFunction = metric.id, sortAggKey ); - overwrite(doc, `aggs.pivot.terms.order`, { [bucketPath]: sort.order }); + overwrite(doc, `aggs.pivot.${termsType}.order`, { [bucketPath]: sort.order }); overwrite(doc, `aggs.pivot.aggs`, { [sortAggKey]: fn(metric) }); } else { - overwrite(doc, 'aggs.pivot.terms.order', { + overwrite(doc, `aggs.pivot.${termsType}.order`, { _key: get(sort, 'order', 'asc'), }); } diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/types.ts b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/types.ts index 58124c825e91..2c6aa2a49576 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/types.ts +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/types.ts @@ -7,5 +7,6 @@ */ export interface BaseMeta { - index?: string; + dataViewId?: string; + indexPatternString?: string; } diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/format_label.ts b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/format_label.ts index 6d824c1c7f43..813cf1a5b9a6 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/format_label.ts +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/format_label.ts @@ -6,46 +6,67 @@ * Side Public License, v 1. */ -import { KBN_FIELD_TYPES } from '@kbn/field-types'; -import { BUCKET_TYPES, PANEL_TYPES } from '../../../../../common/enums'; +import { BUCKET_TYPES, PANEL_TYPES, TSVB_METRIC_TYPES } from '../../../../../common/enums'; +import { + createCachedFieldValueFormatter, + getFieldsForTerms, + MULTI_FIELD_VALUES_SEPARATOR, +} from '../../../../../common/fields_utils'; import type { Panel, PanelData, Series } from '../../../../../common/types'; import type { FieldFormatsRegistry } from '../../../../../../../field_formats/common'; import type { createFieldsFetcher } from '../../../search_strategies/lib/fields_fetcher'; import type { CachedIndexPatternFetcher } from '../../../search_strategies/lib/cached_index_pattern_fetcher'; +import type { BaseMeta } from '../../request_processors/types'; +import { SanitizedFieldType } from '../../../../../common/types'; export function formatLabel( resp: unknown, panel: Panel, series: Series, - meta: any, + meta: BaseMeta, extractFields: ReturnType, fieldFormatService: FieldFormatsRegistry, cachedIndexPatternFetcher: CachedIndexPatternFetcher ) { return (next: (results: PanelData[]) => unknown) => async (results: PanelData[]) => { const { terms_field: termsField, split_mode: splitMode } = series; + const termsIds = getFieldsForTerms(termsField); - const isKibanaIndexPattern = panel.use_kibana_indexes || panel.index_pattern === ''; - // no need to format labels for markdown as they also used there as variables keys const shouldFormatLabels = - isKibanaIndexPattern && - termsField && + // no need to format labels for series_agg + !series.metrics.some((m) => m.type === TSVB_METRIC_TYPES.SERIES_AGG) && + termsIds.length && splitMode === BUCKET_TYPES.TERMS && + // no need to format labels for markdown as they also used there as variables keys panel.type !== PANEL_TYPES.MARKDOWN; if (shouldFormatLabels) { - const { indexPattern } = await cachedIndexPatternFetcher({ id: meta.index }); - const getFieldFormatByName = (fieldName: string) => - fieldFormatService.deserialize(indexPattern?.fieldFormatMap?.[fieldName]); + const fetchedIndex = meta.dataViewId + ? await cachedIndexPatternFetcher({ id: meta.dataViewId }) + : undefined; + + let fields: SanitizedFieldType[] = []; + + if (!fetchedIndex?.indexPattern && meta.indexPatternString) { + fields = await extractFields(meta.indexPatternString); + } + + const formatField = createCachedFieldValueFormatter( + fetchedIndex?.indexPattern, + fields, + fieldFormatService + ); results .filter(({ seriesId }) => series.id === seriesId) .forEach((item) => { - const formattedLabel = getFieldFormatByName(termsField!).convert(item.label); - item.label = formattedLabel; - const termsFieldType = indexPattern?.fields.find(({ name }) => name === termsField)?.type; - if (termsFieldType === KBN_FIELD_TYPES.DATE) { - item.labelFormatted = formattedLabel; + const formatted = termsIds + .map((i, index) => formatField(i, [item.label].flat()[index])) + .join(MULTI_FIELD_VALUES_SEPARATOR); + + if (formatted) { + item.label = formatted; + item.labelFormatted = formatted; } }); } diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/series_agg.js b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/series_agg.js index 532f5fd07f59..305d6c6fc5b3 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/series_agg.js +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/series_agg.js @@ -33,7 +33,7 @@ export function seriesAgg(resp, panel, series, meta, extractFields) { return (fn && fn(acc)) || acc; }, targetSeries); - const fieldsForSeries = meta.index ? await extractFields({ id: meta.index }) : []; + const fieldsForSeries = meta.dataViewId ? await extractFields({ id: meta.dataViewId }) : []; results.push({ id: `${series.id}`, diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/table/series_agg.ts b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/table/series_agg.ts index b4bc082bab84..45829a930605 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/table/series_agg.ts +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/table/series_agg.ts @@ -36,7 +36,7 @@ export const seriesAgg: TableResponseProcessorsFunction = const fn = SeriesAgg[series.aggregate_function]; const data = fn(targetSeries); - const fieldsForSeries = meta.index ? await extractFields({ id: meta.index }) : []; + const fieldsForSeries = meta.dataViewId ? await extractFields({ id: meta.dataViewId }) : []; results.push({ id: `${series.id}`, diff --git a/src/plugins/vis_types/vislib/kibana.json b/src/plugins/vis_types/vislib/kibana.json index feb252f1bb0f..7c55aba21e7e 100644 --- a/src/plugins/vis_types/vislib/kibana.json +++ b/src/plugins/vis_types/vislib/kibana.json @@ -4,7 +4,7 @@ "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations"], - "requiredBundles": ["kibanaUtils", "visDefaultEditor", "visTypeXy", "visTypePie", "visTypeHeatmap", "fieldFormats", "kibanaReact"], + "requiredBundles": ["kibanaUtils", "visTypeXy", "visTypePie", "visTypeHeatmap", "visTypeGauge", "fieldFormats", "kibanaReact"], "owner": { "name": "Vis Editors", "githubTeam": "kibana-vis-editors" diff --git a/src/plugins/vis_types/vislib/public/gauge.ts b/src/plugins/vis_types/vislib/public/gauge.ts index 128c0758bfd0..5edc33edb84f 100644 --- a/src/plugins/vis_types/vislib/public/gauge.ts +++ b/src/plugins/vis_types/vislib/public/gauge.ts @@ -6,16 +6,13 @@ * Side Public License, v 1. */ -import { i18n } from '@kbn/i18n'; - -import { ColorMode, ColorSchemas, ColorSchemaParams, Labels, Style } from '../../../charts/public'; +import { ColorSchemaParams, Labels, Style } from '../../../charts/public'; import { RangeValues } from '../../../vis_default_editor/public'; -import { AggGroupNames } from '../../../data/public'; -import { VisTypeDefinition, VIS_EVENT_TO_TRIGGER } from '../../../visualizations/public'; +import { gaugeVisType } from '../../gauge/public'; +import { VisTypeDefinition } from '../../../visualizations/public'; -import { Alignment, GaugeType, VislibChartType } from './types'; +import { Alignment, GaugeType } from './types'; import { toExpressionAst } from './to_ast'; -import { GaugeOptions } from './editor/components'; export interface Gauge extends ColorSchemaParams { backStyle: 'Full'; @@ -46,104 +43,7 @@ export interface GaugeVisParams { gauge: Gauge; } -export const gaugeVisTypeDefinition: VisTypeDefinition = { - name: 'gauge', - title: i18n.translate('visTypeVislib.gauge.gaugeTitle', { defaultMessage: 'Gauge' }), - icon: 'visGauge', - description: i18n.translate('visTypeVislib.gauge.gaugeDescription', { - defaultMessage: 'Show the status of a metric.', - }), - getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], +export const gaugeVisTypeDefinition = { + ...gaugeVisType({}), toExpressionAst, - visConfig: { - defaults: { - type: VislibChartType.Gauge, - addTooltip: true, - addLegend: true, - isDisplayWarning: false, - gauge: { - alignment: Alignment.Automatic, - extendRange: true, - percentageMode: false, - gaugeType: GaugeType.Arc, - gaugeStyle: 'Full', - backStyle: 'Full', - orientation: 'vertical', - colorSchema: ColorSchemas.GreenToRed, - gaugeColorMode: ColorMode.Labels, - colorsRange: [ - { from: 0, to: 50 }, - { from: 50, to: 75 }, - { from: 75, to: 100 }, - ], - invertColors: false, - labels: { - show: true, - color: 'black', - }, - scale: { - show: true, - labels: false, - color: 'rgba(105,112,125,0.2)', - }, - type: 'meter', - style: { - bgWidth: 0.9, - width: 0.9, - mask: false, - bgMask: false, - maskBars: 50, - bgFill: 'rgba(105,112,125,0.2)', - bgColor: true, - subText: '', - fontSize: 60, - }, - }, - }, - }, - editorConfig: { - optionsTemplate: GaugeOptions, - schemas: [ - { - group: AggGroupNames.Metrics, - name: 'metric', - title: i18n.translate('visTypeVislib.gauge.metricTitle', { defaultMessage: 'Metric' }), - min: 1, - aggFilter: [ - '!std_dev', - '!geo_centroid', - '!percentiles', - '!percentile_ranks', - '!derivative', - '!serial_diff', - '!moving_avg', - '!cumulative_sum', - '!geo_bounds', - '!filtered_metric', - '!single_percentile', - ], - defaults: [{ schema: 'metric', type: 'count' }], - }, - { - group: AggGroupNames.Buckets, - name: 'group', - title: i18n.translate('visTypeVislib.gauge.groupTitle', { - defaultMessage: 'Split group', - }), - min: 0, - max: 1, - aggFilter: [ - '!geohash_grid', - '!geotile_grid', - '!filter', - '!sampler', - '!diversified_sampler', - '!rare_terms', - '!multi_terms', - '!significant_text', - ], - }, - ], - }, - requiresSearch: true, -}; +} as VisTypeDefinition; diff --git a/src/plugins/vis_types/vislib/public/goal.ts b/src/plugins/vis_types/vislib/public/goal.ts index 9dd5fdbc92b5..205b3a7a4280 100644 --- a/src/plugins/vis_types/vislib/public/goal.ts +++ b/src/plugins/vis_types/vislib/public/goal.ts @@ -6,108 +6,13 @@ * Side Public License, v 1. */ -import { i18n } from '@kbn/i18n'; - -import { AggGroupNames } from '../../../data/public'; -import { ColorMode, ColorSchemas } from '../../../charts/public'; import { VisTypeDefinition } from '../../../visualizations/public'; +import { goalVisType } from '../../gauge/public'; -import { GaugeOptions } from './editor'; import { toExpressionAst } from './to_ast'; -import { GaugeType } from './types'; import { GaugeVisParams } from './gauge'; -export const goalVisTypeDefinition: VisTypeDefinition = { - name: 'goal', - title: i18n.translate('visTypeVislib.goal.goalTitle', { defaultMessage: 'Goal' }), - icon: 'visGoal', - description: i18n.translate('visTypeVislib.goal.goalDescription', { - defaultMessage: 'Track how a metric progresses to a goal.', - }), +export const goalVisTypeDefinition = { + ...goalVisType({}), toExpressionAst, - visConfig: { - defaults: { - addTooltip: true, - addLegend: false, - isDisplayWarning: false, - type: 'gauge', - gauge: { - verticalSplit: false, - autoExtend: false, - percentageMode: true, - gaugeType: GaugeType.Arc, - gaugeStyle: 'Full', - backStyle: 'Full', - orientation: 'vertical', - useRanges: false, - colorSchema: ColorSchemas.GreenToRed, - gaugeColorMode: ColorMode.None, - colorsRange: [{ from: 0, to: 10000 }], - invertColors: false, - labels: { - show: true, - color: 'black', - }, - scale: { - show: false, - labels: false, - color: 'rgba(105,112,125,0.2)', - width: 2, - }, - type: 'meter', - style: { - bgFill: 'rgba(105,112,125,0.2)', - bgColor: false, - labelColor: false, - subText: '', - fontSize: 60, - }, - }, - }, - }, - editorConfig: { - optionsTemplate: GaugeOptions, - schemas: [ - { - group: AggGroupNames.Metrics, - name: 'metric', - title: i18n.translate('visTypeVislib.goal.metricTitle', { defaultMessage: 'Metric' }), - min: 1, - aggFilter: [ - '!std_dev', - '!geo_centroid', - '!percentiles', - '!percentile_ranks', - '!derivative', - '!serial_diff', - '!moving_avg', - '!cumulative_sum', - '!geo_bounds', - '!filtered_metric', - '!single_percentile', - ], - defaults: [{ schema: 'metric', type: 'count' }], - }, - { - group: AggGroupNames.Buckets, - name: 'group', - title: i18n.translate('visTypeVislib.goal.groupTitle', { - defaultMessage: 'Split group', - }), - min: 0, - max: 1, - aggFilter: [ - '!geohash_grid', - '!geotile_grid', - '!filter', - '!sampler', - '!diversified_sampler', - '!rare_terms', - '!multi_terms', - '!significant_text', - ], - }, - ], - }, - requiresSearch: true, -}; +} as VisTypeDefinition; diff --git a/src/plugins/vis_types/vislib/public/plugin.ts b/src/plugins/vis_types/vislib/public/plugin.ts index 8c54df99bb98..23013bc58238 100644 --- a/src/plugins/vis_types/vislib/public/plugin.ts +++ b/src/plugins/vis_types/vislib/public/plugin.ts @@ -14,13 +14,16 @@ import { ChartsPluginSetup } from '../../../charts/public'; import { DataPublicPluginStart } from '../../../data/public'; import { LEGACY_PIE_CHARTS_LIBRARY } from '../../pie/common/index'; import { LEGACY_HEATMAP_CHARTS_LIBRARY } from '../../heatmap/common/index'; +import { LEGACY_GAUGE_CHARTS_LIBRARY } from '../../gauge/common/index'; import { heatmapVisTypeDefinition } from './heatmap'; import { createVisTypeVislibVisFn } from './vis_type_vislib_vis_fn'; import { createPieVisFn } from './pie_fn'; -import { visLibVisTypeDefinitions, pieVisTypeDefinition } from './vis_type_vislib_vis_types'; +import { pieVisTypeDefinition } from './pie'; import { setFormatService, setDataActions, setTheme } from './services'; import { getVislibVisRenderer } from './vis_renderer'; +import { gaugeVisTypeDefinition } from './gauge'; +import { goalVisTypeDefinition } from './goal'; /** @internal */ export interface VisTypeVislibPluginSetupDependencies { @@ -48,7 +51,7 @@ export class VisTypeVislibPlugin { expressions, visualizations, charts }: VisTypeVislibPluginSetupDependencies ) { // register vislib XY axis charts - visLibVisTypeDefinitions.forEach(visualizations.createBaseVisualization); + expressions.registerRenderer(getVislibVisRenderer(core, charts)); expressions.registerFunction(createVisTypeVislibVisFn()); @@ -57,10 +60,16 @@ export class VisTypeVislibPlugin visualizations.createBaseVisualization(pieVisTypeDefinition); expressions.registerFunction(createPieVisFn()); } + if (core.uiSettings.get(LEGACY_HEATMAP_CHARTS_LIBRARY)) { // register vislib heatmap chart visualizations.createBaseVisualization(heatmapVisTypeDefinition); - expressions.registerFunction(createVisTypeVislibVisFn()); + } + + if (core.uiSettings.get(LEGACY_GAUGE_CHARTS_LIBRARY)) { + // register vislib gauge and goal charts + visualizations.createBaseVisualization(gaugeVisTypeDefinition); + visualizations.createBaseVisualization(goalVisTypeDefinition); } } 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 cc557ff274fa..3ec280951d5f 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 @@ -263,7 +263,6 @@ export class VisLegend extends PureComponent { type="button" onClick={this.toggleLegend} className={classNames('visLegend__toggle kbn-resetFocusState', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'visLegend__toggle--isOpen': open, })} aria-label={i18n.translate('visTypeVislib.vislib.legend.toggleLegendButtonAriaLabel', { diff --git a/src/plugins/vis_types/vislib/tsconfig.json b/src/plugins/vis_types/vislib/tsconfig.json index 6c0b13e36a61..ef4d0a97fd2a 100644 --- a/src/plugins/vis_types/vislib/tsconfig.json +++ b/src/plugins/vis_types/vislib/tsconfig.json @@ -18,7 +18,7 @@ { "path": "../../expressions/tsconfig.json" }, { "path": "../../visualizations/tsconfig.json" }, { "path": "../../kibana_utils/tsconfig.json" }, - { "path": "../../vis_default_editor/tsconfig.json" }, + { "path": "../../vis_types/gauge/tsconfig.json" }, { "path": "../../vis_types/xy/tsconfig.json" }, { "path": "../../vis_types/pie/tsconfig.json" }, { "path": "../../vis_types/heatmap/tsconfig.json" }, diff --git a/src/plugins/vis_types/xy/public/config/get_config.ts b/src/plugins/vis_types/xy/public/config/get_config.ts index d7cf22625e10..7aad30c5b743 100644 --- a/src/plugins/vis_types/xy/public/config/get_config.ts +++ b/src/plugins/vis_types/xy/public/config/get_config.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { ScaleContinuousType } from '@elastic/charts'; +import { Fit, ScaleContinuousType } from '@elastic/charts'; import { Datatable } from '../../../../expressions/public'; import { BUCKET_TYPES } from '../../../../data/public'; @@ -92,7 +92,7 @@ export function getConfig( return { // NOTE: downscale ratio to match current vislib implementation markSizeRatio: radiusRatio * 0.6, - fittingFunction, + fittingFunction: fittingFunction ?? Fit.Linear, fillOpacity, detailedTooltip, orderBucketsBySum, diff --git a/src/plugins/vis_types/xy/public/editor/components/options/point_series/elastic_charts_options.tsx b/src/plugins/vis_types/xy/public/editor/components/options/point_series/elastic_charts_options.tsx index 105cd6679904..1c93fe92b79a 100644 --- a/src/plugins/vis_types/xy/public/editor/components/options/point_series/elastic_charts_options.tsx +++ b/src/plugins/vis_types/xy/public/editor/components/options/point_series/elastic_charts_options.tsx @@ -78,7 +78,7 @@ export function ElasticChartsOptions(props: ValidationVisOptionsProps })} options={fittingFunctions} paramName="fittingFunction" - value={stateParams.fittingFunction} + value={stateParams.fittingFunction ?? fittingFunctions[2].value} setValue={(paramName, value) => { if (trackUiMetric) { trackUiMetric(METRIC_TYPE.CLICK, 'fitting_function_selected'); diff --git a/src/plugins/vis_types/xy/public/utils/render_all_series.tsx b/src/plugins/vis_types/xy/public/utils/render_all_series.tsx index e2672380c390..d9e4e9a47c23 100644 --- a/src/plugins/vis_types/xy/public/utils/render_all_series.tsx +++ b/src/plugins/vis_types/xy/public/utils/render_all_series.tsx @@ -196,6 +196,11 @@ export const renderAllSeries = ( area: { ...(type === ChartType.Line ? { opacity: 0 } : { opacity: fillOpacity }), }, + fit: { + area: { + ...(type === ChartType.Line ? { opacity: 0 } : { opacity: fillOpacity }), + }, + }, line: { strokeWidth, visible: drawLinesBetweenPoints, diff --git a/src/plugins/visualizations/common/utils/accessors.ts b/src/plugins/visualizations/common/utils/accessors.ts index 57a2d434dfd7..d290eabf9308 100644 --- a/src/plugins/visualizations/common/utils/accessors.ts +++ b/src/plugins/visualizations/common/utils/accessors.ts @@ -37,7 +37,7 @@ export const getAccessorByDimension = ( dimension: string | ExpressionValueVisDimension, columns: DatatableColumn[] ) => { - if (typeof dimension === 'string') { + if (!isVisDimension(dimension)) { return dimension; } @@ -48,3 +48,53 @@ export const getAccessorByDimension = ( return accessor.id; }; + +// we don't need validate ExpressionValueVisDimension type because +// it was already had validation inside `vis_dimenstion` expression function +export const validateAccessor = ( + accessor: string | ExpressionValueVisDimension | undefined, + columns: DatatableColumn[] +) => { + if (accessor && typeof accessor === 'string') { + findAccessorOrFail(accessor, columns); + } +}; + +export function getAccessor(dimension: string | ExpressionValueVisDimension) { + return typeof dimension === 'string' ? dimension : dimension.accessor; +} + +export function getFormatByAccessor( + dimension: string | ExpressionValueVisDimension, + columns: DatatableColumn[] +) { + return typeof dimension === 'string' + ? getColumnByAccessor(dimension, columns)?.meta.params + : dimension.format; +} + +export const getColumnByAccessor = ( + accessor: ExpressionValueVisDimension | string, + columns: Datatable['columns'] = [] +) => { + if (typeof accessor === 'string') { + return columns.find(({ id }) => accessor === id); + } + + const visDimensionAccessor = accessor.accessor; + if (typeof visDimensionAccessor === 'number') { + return columns[visDimensionAccessor]; + } + + return columns.find(({ id }) => visDimensionAccessor.id === id); +}; + +export function isVisDimension( + accessor: string | ExpressionValueVisDimension | undefined +): accessor is ExpressionValueVisDimension { + if (typeof accessor === 'string' || accessor === undefined) { + return false; + } + + return true; +} diff --git a/src/plugins/visualizations/common/utils/index.ts b/src/plugins/visualizations/common/utils/index.ts index 59833b3e54e4..35e01a9121ac 100644 --- a/src/plugins/visualizations/common/utils/index.ts +++ b/src/plugins/visualizations/common/utils/index.ts @@ -8,4 +8,12 @@ export { prepareLogTable } from './prepare_log_table'; export type { Dimension } from './prepare_log_table'; -export { findAccessorOrFail, getAccessorByDimension } from './accessors'; +export { + findAccessorOrFail, + getAccessorByDimension, + validateAccessor, + getColumnByAccessor, + isVisDimension, + getAccessor, + getFormatByAccessor, +} from './accessors'; diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index c7fd9c977bc2..79b04f132077 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -12,9 +12,10 @@ "embeddable", "inspector", "savedObjects", + "screenshotMode", "presentationUtil" ], - "optionalPlugins": [ "home", "share", "usageCollection", "spaces", "savedObjectsTaggingOss"], + "optionalPlugins": ["home", "share", "usageCollection", "spaces", "savedObjectsTaggingOss"], "requiredBundles": ["kibanaUtils", "discover", "kibanaReact", "home"], "extraPublicDirs": ["common/constants", "common/utils", "common/expression_functions"], "owner": { diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx index efc3bbf8314f..3d3c98ce4aae 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx @@ -39,7 +39,7 @@ import { ExpressionAstExpression, } from '../../../../plugins/expressions/public'; import { Vis, SerializedVis } from '../vis'; -import { getExpressions, getTheme, getUiActions } from '../services'; +import { getExecutionContext, getExpressions, getTheme, getUiActions } from '../services'; import { VIS_EVENT_TO_TRIGGER } from './events'; import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory'; import { getSavedVisualization } from '../utils/saved_visualize_utils'; @@ -398,20 +398,18 @@ export class VisualizeEmbeddable }; private async updateHandler() { - const parentContext = this.parent?.getInput().executionContext; + const parentContext = this.parent?.getInput().executionContext || getExecutionContext().get(); const child: KibanaExecutionContext = { type: 'visualization', name: this.vis.type.name, - id: this.vis.id ?? 'an_unsaved_vis', + id: this.vis.id ?? 'new', description: this.vis.title || this.input.title || this.vis.type.name, url: this.output.editUrl, }; - const context = parentContext - ? { - ...parentContext, - child, - } - : child; + const context = { + ...parentContext, + child, + }; const expressionParams: IExpressionLoaderParams = { searchContext: { diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 0fc142aeead6..69a7c61e6889 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -23,6 +23,7 @@ import { urlForwardingPluginMock } from '../../../plugins/url_forwarding/public/ import { navigationPluginMock } from '../../../plugins/navigation/public/mocks'; import { presentationUtilPluginMock } from '../../../plugins/presentation_util/public/mocks'; import { savedObjectTaggingOssPluginMock } from '../../saved_objects_tagging_oss/public/mocks'; +import { screenshotModePluginMock } from '../../screenshot_mode/public/mocks'; const createSetupContract = (): VisualizationsSetup => ({ createBaseVisualization: jest.fn(), @@ -68,6 +69,7 @@ const createInstance = async () => { navigation: navigationPluginMock.createStartContract(), presentationUtil: presentationUtilPluginMock.createStartContract(coreMock.createStart()), urlForwarding: urlForwardingPluginMock.createStartContract(), + screenshotMode: screenshotModePluginMock.createStartContract(), }); return { diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index c8c4d57543a0..88b9d35d5255 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -36,6 +36,7 @@ import { setDocLinks, setSpaces, setTheme, + setExecutionContext, } from './services'; import { createVisEmbeddableFromObject, @@ -86,6 +87,7 @@ import type { SharePluginSetup, SharePluginStart } from '../../share/public'; import type { UrlForwardingSetup, UrlForwardingStart } from '../../url_forwarding/public'; import type { PresentationUtilPluginStart } from '../../presentation_util/public'; import type { UsageCollectionStart } from '../../usage_collection/public'; +import type { ScreenshotModePluginStart } from '../../screenshot_mode/public'; import type { HomePublicPluginSetup } from '../../home/public'; import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public'; @@ -130,6 +132,7 @@ export interface VisualizationsStartDeps { share?: SharePluginStart; urlForwarding: UrlForwardingStart; usageCollection?: UsageCollectionStart; + screenshotMode: ScreenshotModePluginStart; } /** @@ -289,6 +292,11 @@ export class VisualizationsPlugin params.element.classList.add('visAppWrapper'); const { renderApp } = await import('./visualize_app'); + if (pluginsStart.screenshotMode.isScreenshotMode()) { + params.element.classList.add('visEditorScreenshotModeActive'); + // @ts-expect-error TS error, cannot find type declaration for scss + await import('./visualize_screenshot_mode.scss'); + } const unmount = renderApp(params, services); return () => { data.search.session.clear(); @@ -365,6 +373,7 @@ export class VisualizationsPlugin setTimeFilter(data.query.timefilter.timefilter); setAggs(data.search.aggs); setOverlays(core.overlays); + setExecutionContext(core.executionContext); setChrome(core.chrome); if (spaces) { diff --git a/src/plugins/visualizations/public/services.ts b/src/plugins/visualizations/public/services.ts index 37aea45fa3f5..8564c8225f1a 100644 --- a/src/plugins/visualizations/public/services.ts +++ b/src/plugins/visualizations/public/services.ts @@ -16,6 +16,7 @@ import type { SavedObjectsStart, DocLinksStart, ThemeServiceStart, + ExecutionContextSetup, } from '../../../core/public'; import type { TypesStart } from './vis_types'; import { createGetterSetter } from '../../../plugins/kibana_utils/public'; @@ -65,4 +66,7 @@ export const [getOverlays, setOverlays] = createGetterSetter('Over export const [getChrome, setChrome] = createGetterSetter('Chrome'); +export const [getExecutionContext, setExecutionContext] = + createGetterSetter('ExecutionContext'); + export const [getSpaces, setSpaces] = createGetterSetter('Spaces', false); diff --git a/src/plugins/visualizations/public/vis_types/types.ts b/src/plugins/visualizations/public/vis_types/types.ts index b89af7bd2cdb..834c781b3b82 100644 --- a/src/plugins/visualizations/public/vis_types/types.ts +++ b/src/plugins/visualizations/public/vis_types/types.ts @@ -98,7 +98,7 @@ export interface VisualizeEditorLayersContext { chartType?: string; axisPosition?: string; termsParams?: Record; - splitField?: string; + splitFields?: string[]; splitMode?: string; splitFilters?: SplitByFilters[]; palette?: PaletteOutput; @@ -107,6 +107,7 @@ export interface VisualizeEditorLayersContext { format?: string; label?: string; layerId?: string; + dropPartialBuckets?: boolean; } interface AxisExtents { diff --git a/src/plugins/visualizations/public/visualize_app/components/split_chart_warning.tsx b/src/plugins/visualizations/public/visualize_app/components/split_chart_warning.tsx index 942c6269f15f..679aa6aa2fbe 100644 --- a/src/plugins/visualizations/public/visualize_app/components/split_chart_warning.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/split_chart_warning.tsx @@ -6,56 +6,117 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCallOut, EuiLink } from '@elastic/eui'; import { useKibana } from '../../../../kibana_react/public'; import { VisualizeServices } from '../types'; +import { CHARTS_WITHOUT_SMALL_MULTIPLES } from '../utils/split_chart_warning_helpers'; +import type { CHARTS_WITHOUT_SMALL_MULTIPLES as CHART_WITHOUT_SMALL_MULTIPLES } from '../utils/split_chart_warning_helpers'; -export const NEW_HEATMAP_CHARTS_LIBRARY = 'visualization:visualize:legacyHeatmapChartsLibrary'; +interface Props { + chartType: CHART_WITHOUT_SMALL_MULTIPLES; + chartConfigToken: string; +} -export const SplitChartWarning = () => { +interface WarningMessageProps { + canEditAdvancedSettings: boolean | Readonly<{ [x: string]: boolean }>; + advancedSettingsLink: string; +} + +const SwitchToOldLibraryMessage: FC = ({ + canEditAdvancedSettings, + advancedSettingsLink, +}) => { + return ( + <> + {canEditAdvancedSettings && ( + + + + ), + }} + /> + )} + + ); +}; + +const ContactAdminMessage: FC = ({ canEditAdvancedSettings }) => { + return ( + <> + {!canEditAdvancedSettings && ( + + )} + + ); +}; + +const GaugeWarningFormatMessage: FC = (props) => { + return ( + + + + + ), + }} + /> + ); +}; + +const HeatmapWarningFormatMessage: FC = (props) => { + return ( + + + + + ), + }} + /> + ); +}; + +const warningMessages = { + [CHARTS_WITHOUT_SMALL_MULTIPLES.heatmap]: HeatmapWarningFormatMessage, + [CHARTS_WITHOUT_SMALL_MULTIPLES.gauge]: GaugeWarningFormatMessage, +}; + +export const SplitChartWarning: FC = ({ chartType, chartConfigToken }) => { const { services } = useKibana(); const canEditAdvancedSettings = services.application.capabilities.advancedSettings.save; const advancedSettingsLink = services.application.getUrlForApp('management', { - path: `/kibana/settings?query=${NEW_HEATMAP_CHARTS_LIBRARY}`, + path: `/kibana/settings?query=${chartConfigToken}`, }); + const WarningMessage = warningMessages[chartType]; return ( - {canEditAdvancedSettings && ( - - - - ), - }} - /> - )} - {!canEditAdvancedSettings && ( - - )} - - ), - }} + } iconType="alert" diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_editor.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_editor.tsx index 45241ec50108..c281b211768a 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_editor.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_editor.tsx @@ -11,7 +11,7 @@ import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { EventEmitter } from 'events'; -import { useKibana } from '../../../../kibana_react/public'; +import { useExecutionContext, useKibana } from '../../../../kibana_react/public'; import { useChromeVisibility, useSavedVisInstance, @@ -41,6 +41,14 @@ export const VisualizeEditor = ({ onAppLeave }: VisualizeAppProps) => { originatingApp, visualizationIdFromUrl ); + + const editorName = savedVisInstance?.vis.type.title.toLowerCase().replace(' ', '_') || ''; + useExecutionContext(services.executionContext, { + type: 'application', + page: `editor${editorName ? `:${editorName}` : ''}`, + id: visualizationIdFromUrl || 'new', + }); + const { appState, hasUnappliedChanges } = useVisualizeAppState( services, eventEmitter, diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx index 7d6594e05ae1..c76515072a1e 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx @@ -17,7 +17,7 @@ import { ExperimentalVisInfo } from './experimental_vis_info'; import { useKibana } from '../../../../kibana_react/public'; import { urlFor } from '../../../../visualizations/public'; import { getUISettings } from '../../services'; -import { SplitChartWarning, NEW_HEATMAP_CHARTS_LIBRARY } from './split_chart_warning'; +import { SplitChartWarning } from './split_chart_warning'; import { SavedVisInstance, VisualizeAppState, @@ -25,6 +25,11 @@ import { VisualizeAppStateContainer, VisualizeEditorVisInstance, } from '../types'; +import { + CHARTS_CONFIG_TOKENS, + CHARTS_WITHOUT_SMALL_MULTIPLES, + isSplitChart as isSplitChartFn, +} from '../utils/split_chart_warning_helpers'; interface VisualizeEditorCommonProps { visInstance?: VisualizeEditorVisInstance; @@ -110,8 +115,17 @@ export const VisualizeEditorCommon = ({ return null; }, [visInstance?.savedVis, services, visInstance?.vis?.type.title]); // Adds a notification for split chart on the new implementation as it is not supported yet - const isSplitChart = visInstance?.vis?.data?.aggs?.aggs.some((agg) => agg.schema === 'split'); - const hasHeatmapLegacyhartsEnabled = getUISettings().get(NEW_HEATMAP_CHARTS_LIBRARY); + const chartName = visInstance?.vis.type.name; + const isSplitChart = isSplitChartFn(chartName, visInstance?.vis?.data?.aggs); + + const chartsWithoutSmallMultiples: string[] = Object.values(CHARTS_WITHOUT_SMALL_MULTIPLES); + const chartNeedsWarning = chartName ? chartsWithoutSmallMultiples.includes(chartName) : false; + const chartToken = + chartName && chartNeedsWarning + ? CHARTS_CONFIG_TOKENS[chartName as CHARTS_WITHOUT_SMALL_MULTIPLES] + : undefined; + + const hasLegacyChartsEnabled = chartToken ? getUISettings().get(chartToken) : true; return (
@@ -134,9 +148,12 @@ export const VisualizeEditorCommon = ({ /> )} {visInstance?.vis?.type?.stage === 'experimental' && } - {!hasHeatmapLegacyhartsEnabled && - isSplitChart && - visInstance?.vis.type.name === 'heatmap' && } + {!hasLegacyChartsEnabled && isSplitChart && chartNeedsWarning && chartToken && chartName && ( + + )} {visInstance?.vis?.type?.getInfoMessage?.(visInstance.vis)} {getLegacyUrlConflictCallout()} {visInstance && ( diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx index cf219b1cda11..a180cf78feeb 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx @@ -21,7 +21,7 @@ import { findListItems } from '../../utils/saved_visualize_utils'; import { showNewVisModal } from '../../wizard'; import { getTypes } from '../../services'; import { SavedObjectsFindOptionsReference } from '../../../../../core/public'; -import { useKibana, TableListView } from '../../../../kibana_react/public'; +import { useKibana, TableListView, useExecutionContext } from '../../../../kibana_react/public'; import { VISUALIZE_ENABLE_LABS_SETTING } from '../../../../visualizations/public'; import { VisualizeServices } from '../types'; import { VisualizeConstants } from '../../../common/constants'; @@ -31,6 +31,7 @@ export const VisualizeListing = () => { const { services: { application, + executionContext, chrome, history, toastNotifications, @@ -49,6 +50,11 @@ export const VisualizeListing = () => { const closeNewVisModal = useRef(() => {}); const listingLimit = savedObjectsPublic.settings.getListingLimit(); + useExecutionContext(executionContext, { + type: 'application', + page: 'list', + }); + useEffect(() => { if (pathname === '/new') { // In case the user navigated to the page via the /visualize/new URL we start the dialog immediately diff --git a/src/plugins/visualizations/public/visualize_app/constants.ts b/src/plugins/visualizations/public/visualize_app/constants.ts new file mode 100644 index 000000000000..fd256cb5bbb8 --- /dev/null +++ b/src/plugins/visualizations/public/visualize_app/constants.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 NEW_HEATMAP_CHARTS_LIBRARY = 'visualization:visualize:legacyHeatmapChartsLibrary'; +export const NEW_GAUGE_CHARTS_LIBRARY = 'visualization:visualize:legacyGaugeChartsLibrary'; diff --git a/src/plugins/visualizations/public/visualize_app/utils/split_chart_warning_helpers.ts b/src/plugins/visualizations/public/visualize_app/utils/split_chart_warning_helpers.ts new file mode 100644 index 000000000000..d40f15aa0865 --- /dev/null +++ b/src/plugins/visualizations/public/visualize_app/utils/split_chart_warning_helpers.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. + */ + +import { $Values } from '@kbn/utility-types'; +import { AggConfigs } from '../../../../data/common'; +import { NEW_HEATMAP_CHARTS_LIBRARY, NEW_GAUGE_CHARTS_LIBRARY } from '../constants'; + +export const CHARTS_WITHOUT_SMALL_MULTIPLES = { + heatmap: 'heatmap', + gauge: 'gauge', +} as const; + +export type CHARTS_WITHOUT_SMALL_MULTIPLES = $Values; + +export const CHARTS_CONFIG_TOKENS = { + [CHARTS_WITHOUT_SMALL_MULTIPLES.heatmap]: NEW_HEATMAP_CHARTS_LIBRARY, + [CHARTS_WITHOUT_SMALL_MULTIPLES.gauge]: NEW_GAUGE_CHARTS_LIBRARY, +} as const; + +export const isSplitChart = (chartType: string | undefined, aggs?: AggConfigs) => { + const defaultIsSplitChart = () => aggs?.aggs.some((agg) => agg.schema === 'split'); + + const knownCheckers = { + [CHARTS_WITHOUT_SMALL_MULTIPLES.heatmap]: defaultIsSplitChart, + [CHARTS_WITHOUT_SMALL_MULTIPLES.gauge]: () => aggs?.aggs.some((agg) => agg.schema === 'group'), + }; + + return (knownCheckers[chartType as CHARTS_WITHOUT_SMALL_MULTIPLES] ?? defaultIsSplitChart)(); +}; diff --git a/src/plugins/visualizations/public/visualize_screenshot_mode.scss b/src/plugins/visualizations/public/visualize_screenshot_mode.scss new file mode 100644 index 000000000000..b0a8bb35835b --- /dev/null +++ b/src/plugins/visualizations/public/visualize_screenshot_mode.scss @@ -0,0 +1,60 @@ +/* hide unusable controls */ +/* TODO: This is the legacy way of hiding chrome elements. Rather use chrome.setIsVisible */ +kbn-top-nav, +filter-bar, +.kbnTopNavMenu__wrapper, +::-webkit-scrollbar, +.euiNavDrawer { + display: none !important; +} + +/* hide unusable controls +* !important is required to override resizable panel inline display */ +.visEditorScreenshotModeActive .visEditor__content .visEditor--default > :not(.visEditor__visualization__wrapper) { + display: none !important; +} + +/** THIS IS FOR TSVB UNTIL REFACTOR **/ +.visEditorScreenshotModeActive .tvbEditorVisualization { + position: static !important; +} +.visEditorScreenshotModeActive .visualize .tvbVisTimeSeries__legendToggle { + /* all non-content rows in interface */ + display: none; +} + +.visEditorScreenshotModeActive .tvbEditor--hideForReporting { + /* all non-content rows in interface */ + display: none; +} +/** END TSVB BAD BAD HACKS **/ + +/* remove left padding from visualizations so that map lines up with .leaflet-container and +* setting the position to be fixed and to take up the entire screen, because some zoom levels/viewports +* are triggering the media breakpoints that cause the .visEditor__canvas to take up more room than the viewport */ + +.visEditorScreenshotModeActive .visEditor .visEditor__canvas { + padding-left: 0; + position: fixed; + width: 100%; + height: 100%; + top: 0; + left: 0; +} + +/** + * Visualization tweaks + */ + +/* hide unusable controls */ +.visEditorScreenshotModeActive .visualize .visLegend__toggle, +.visEditorScreenshotModeActive .visualize .kbnAggTable__controls, +.visEditorScreenshotModeActive .visualize .leaflet-container .leaflet-top.leaflet-left, +.visEditorScreenshotModeActive .visualize paginate-controls /* page numbers */ { + display: none; +} + +/* Ensure the min-height of the small breakpoint isn't used */ +.visEditorScreenshotModeActive .vis-editor visualization { + min-height: 0 !important; +} diff --git a/src/plugins/visualizations/tsconfig.json b/src/plugins/visualizations/tsconfig.json index 57d21d8719ed..2bc25cfb3c34 100644 --- a/src/plugins/visualizations/tsconfig.json +++ b/src/plugins/visualizations/tsconfig.json @@ -30,6 +30,7 @@ { "path": "../home/tsconfig.json" }, { "path": "../share/tsconfig.json" }, { "path": "../presentation_util/tsconfig.json" }, + { "path": "../screenshot_mode/tsconfig.json" }, { "path": "../../../x-pack/plugins/spaces/tsconfig.json" } ] } diff --git a/test/api_integration/apis/custom_integration/integrations.ts b/test/api_integration/apis/custom_integration/integrations.ts index 4d3915f5f229..e71cc045da32 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(35); + expect(resp.body.length).to.be(38); // Test for sample data card expect(resp.body.findIndex((c: { id: string }) => c.id === 'sample_data_all')).to.be.above( diff --git a/test/api_integration/apis/saved_objects/import.ts b/test/api_integration/apis/saved_objects/import.ts index c899f082ec4d..205325ae745b 100644 --- a/test/api_integration/apis/saved_objects/import.ts +++ b/test/api_integration/apis/saved_objects/import.ts @@ -16,7 +16,6 @@ const createConflictError = ( object: Omit ): SavedObjectsImportFailure => ({ ...object, - title: object.meta.title, error: { type: 'conflict' }, }); @@ -123,7 +122,6 @@ export default function ({ getService }: FtrProviderContext) { { id: '1', type: 'wigwags', - title: 'my title', meta: { title: 'my title' }, error: { type: 'unsupported_type' }, }, @@ -221,7 +219,6 @@ export default function ({ getService }: FtrProviderContext) { { type: 'visualization', id: '1', - title: 'My visualization', meta: { title: 'My visualization', icon: 'visualizeApp' }, error: { type: 'missing_references', diff --git a/test/common/services/es_archiver.ts b/test/common/services/es_archiver.ts index 2ea4b6ce3a43..3212e8092868 100644 --- a/test/common/services/es_archiver.ts +++ b/test/common/services/es_archiver.ts @@ -8,11 +8,12 @@ import { EsArchiver } from '@kbn/es-archiver'; import { FtrProviderContext } from '../ftr_provider_context'; -import * as KibanaServer from './kibana_server'; +import * as KibanaServer from '../../common/services/kibana_server'; export function EsArchiverProvider({ getService }: FtrProviderContext): EsArchiver { const config = getService('config'); const client = getService('es'); + const log = getService('log'); const kibanaServer = getService('kibanaServer'); const retry = getService('retry'); diff --git a/test/common/services/security/system_indices_user.ts b/test/common/services/security/system_indices_user.ts index 2546fbeafffa..091621207a67 100644 --- a/test/common/services/security/system_indices_user.ts +++ b/test/common/services/security/system_indices_user.ts @@ -6,25 +6,18 @@ * Side Public License, v 1. */ -import { systemIndicesSuperuser, createEsClientForFtrConfig } from '@kbn/test'; +import { Client } from '@elastic/elasticsearch'; +import { ToolingLog } from '@kbn/dev-utils'; +import { + systemIndicesSuperuser, + createEsClientForFtrConfig, + createRemoteEsClientForFtrConfig, +} from '@kbn/test'; import { FtrProviderContext } from '../../ftr_provider_context'; const SYSTEM_INDICES_SUPERUSER_ROLE = 'system_indices_superuser'; -export async function createSystemIndicesUser(ctx: FtrProviderContext) { - const log = ctx.getService('log'); - const config = ctx.getService('config'); - - const enabled = !config - .get('esTestCluster.serverArgs') - .some((arg: string) => arg === 'xpack.security.enabled=false'); - - if (!enabled) { - return; - } - - const es = createEsClientForFtrConfig(config); - +async function ensureSystemIndicesUser(es: Client, log: ToolingLog) { // There are cases where the test config file doesn't have security disabled // but tests are still executed on ES without security. Checking this case // by trying to fetch the users list. @@ -67,3 +60,24 @@ export async function createSystemIndicesUser(ctx: FtrProviderContext) { await es.close(); } + +export async function createSystemIndicesUser(ctx: FtrProviderContext) { + const log = ctx.getService('log'); + const config = ctx.getService('config'); + + const enabled = !config + .get('esTestCluster.serverArgs') + .some((arg: string) => arg === 'xpack.security.enabled=false'); + + if (!enabled) { + return; + } + + const localEs = createEsClientForFtrConfig(config); + await ensureSystemIndicesUser(localEs, log); + + if (config.get('esTestCluster.ccs')) { + const remoteEs = createRemoteEsClientForFtrConfig(config); + await ensureSystemIndicesUser(remoteEs, log); + } +} diff --git a/test/common/services/security/test_user.ts b/test/common/services/security/test_user.ts index 1161e7b493f4..bc5dbf68698b 100644 --- a/test/common/services/security/test_user.ts +++ b/test/common/services/security/test_user.ts @@ -40,29 +40,33 @@ export class TestUser extends FtrService { super(ctx); } - async restoreDefaults(shouldRefreshBrowser: boolean = true) { - if (this.enabled) { - await this.setRoles(this.config.get('security.defaultRoles'), shouldRefreshBrowser); + async restoreDefaults(options?: { skipBrowserRefresh?: boolean }) { + if (!this.enabled) { + return; } + + await this.setRoles(this.config.get('security.defaultRoles'), options); } - async setRoles(roles: string[], shouldRefreshBrowser: boolean = true) { - if (this.enabled) { - this.log.debug(`set roles = ${roles}`); - await this.user.create(TEST_USER_NAME, { - password: TEST_USER_PASSWORD, - roles, - full_name: 'test user', - }); - - if (this.browser && this.testSubjects && shouldRefreshBrowser) { - if (await this.testSubjects.exists('kibanaChrome', { allowHidden: true })) { - await this.browser.refresh(); - // accept alert if it pops up - const alert = await this.browser.getAlert(); - await alert?.accept(); - await this.testSubjects.find('kibanaChrome', this.config.get('timeouts.find') * 10); - } + async setRoles(roles: string[], options?: { skipBrowserRefresh?: boolean }) { + if (!this.enabled) { + return; + } + + this.log.debug(`set roles = ${roles}`); + await this.user.create(TEST_USER_NAME, { + password: TEST_USER_PASSWORD, + roles, + full_name: 'test user', + }); + + if (this.browser && this.testSubjects && !options?.skipBrowserRefresh) { + if (await this.testSubjects.exists('kibanaChrome', { allowHidden: true })) { + await this.browser.refresh(); + // accept alert if it pops up + const alert = await this.browser.getAlert(); + await alert?.accept(); + await this.testSubjects.find('kibanaChrome', this.config.get('timeouts.find') * 10); } } } @@ -86,6 +90,28 @@ export async function createTestUserService(ctx: FtrProviderContext, role: Role, await role.create(name, definition); } + // when configured to setup remote roles, load the remote es service and set them up directly via es + const remoteEsRoles: undefined | Record = config.get('security.remoteEsRoles'); + if (remoteEsRoles) { + let remoteEs; + try { + remoteEs = ctx.getService('remoteEs' as 'es'); + } catch (error) { + throw new Error( + 'unable to load `remoteEs` cluster, which should provide an ES client configured to talk to the remote cluster. Include that service from another FTR config or fix the error it is throwing on creation: ' + + error.message + ); + } + + for (const [name, body] of Object.entries(remoteEsRoles)) { + log.info(`creating ${name} role on remote cluster`); + await remoteEs.security.putRole({ + name, + ...body, + }); + } + } + // delete the test_user if present (will it error if the user doesn't exist?) try { await user.delete(TEST_USER_NAME); diff --git a/test/examples/embeddables/dashboard.ts b/test/examples/embeddables/dashboard.ts index 23f221c40d4d..6be6d833a8dd 100644 --- a/test/examples/embeddables/dashboard.ts +++ b/test/examples/embeddables/dashboard.ts @@ -93,6 +93,7 @@ export const testDashboardInput = { // eslint-disable-next-line import/no-default-export export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); const pieChart = getService('pieChart'); const dashboardExpect = getService('dashboardExpect'); @@ -103,8 +104,9 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide describe('dashboard container', () => { before(async () => { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); - await esArchiver.loadIfNeeded( - 'test/functional/fixtures/es_archiver/dashboard/current/kibana' + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' ); await PageObjects.common.navigateToApp('dashboardEmbeddableExamples'); await testSubjects.click('dashboardEmbeddableByValue'); diff --git a/test/functional/apps/console/_autocomplete.ts b/test/functional/apps/console/_autocomplete.ts index 1ba4bcaa76b3..cd17244b1f49 100644 --- a/test/functional/apps/console/_autocomplete.ts +++ b/test/functional/apps/console/_autocomplete.ts @@ -13,24 +13,25 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const PageObjects = getPageObjects(['common', 'console']); - // Failing: See https://github.com/elastic/kibana/issues/126421 - describe.skip('console autocomplete feature', function describeIndexTests() { + describe('console autocomplete feature', function describeIndexTests() { this.tags('includeFirefox'); before(async () => { log.debug('navigateTo console'); await PageObjects.common.navigateToApp('console'); // Ensure that the text area can be interacted with await PageObjects.console.dismissTutorial(); + await PageObjects.console.clearTextArea(); }); it('should provide basic auto-complete functionality', async () => { await PageObjects.console.enterRequest(); + await PageObjects.console.enterText(`{\n\t"query": {`); + await PageObjects.console.pressEnter(); await PageObjects.console.promptAutocomplete(); expect(PageObjects.console.isAutocompleteVisible()).to.be.eql(true); }); - // FLAKY: https://github.com/elastic/kibana/issues/126414 - describe.skip('with a missing comma in query', () => { + describe('with a missing comma in query', () => { const LINE_NUMBER = 4; beforeEach(async () => { await PageObjects.console.clearTextArea(); diff --git a/test/functional/apps/dashboard/copy_panel_to.ts b/test/functional/apps/dashboard/copy_panel_to.ts index 8877e5e47bd9..9a61b289ee1f 100644 --- a/test/functional/apps/dashboard/copy_panel_to.ts +++ b/test/functional/apps/dashboard/copy_panel_to.ts @@ -14,7 +14,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardPanelActions = getService('dashboardPanelActions'); const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); - const esArchiver = getService('esArchiver'); const find = getService('find'); const PageObjects = getPageObjects([ @@ -40,7 +39,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('dashboard panel copy to', function viewEditModeTests() { before(async function () { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -61,6 +63,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async function () { await PageObjects.dashboard.gotoDashboardLandingPage(); + await kibanaServer.savedObjects.cleanStandardList(); }); it('does not show the new dashboard option when on a new dashboard', async () => { diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.ts b/test/functional/apps/dashboard/create_and_add_embeddables.ts index e18e66f3a499..d8bf67b6d287 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.ts +++ b/test/functional/apps/dashboard/create_and_add_embeddables.ts @@ -16,22 +16,40 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); const browser = getService('browser'); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardAddPanel = getService('dashboardAddPanel'); describe('create and add embeddables', () => { before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); + }); + + it('ensure toolbar popover closes on add', async () => { await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.preserveCrossAppState(); - await PageObjects.dashboard.loadSavedDashboard('few panels'); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.dashboard.switchToEditMode(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('LOG_STREAM_EMBEDDABLE'); + await dashboardAddPanel.expectEditorMenuClosed(); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); }); describe('add new visualization link', () => { + before(async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + }); + it('adds new visualization via the top nav link', async () => { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); await PageObjects.dashboard.switchToEditMode(); diff --git a/test/functional/apps/dashboard/dashboard_back_button.ts b/test/functional/apps/dashboard/dashboard_back_button.ts index 3b03ea525b90..d532444befda 100644 --- a/test/functional/apps/dashboard/dashboard_back_button.ts +++ b/test/functional/apps/dashboard/dashboard_back_button.ts @@ -10,7 +10,6 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['dashboard', 'header', 'common', 'visualize', 'timePicker']); const browser = getService('browser'); @@ -18,8 +17,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('dashboard back button', () => { before(async () => { - await esArchiver.loadIfNeeded( - 'test/functional/fixtures/es_archiver/dashboard/current/kibana' + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' ); await security.testUser.setRoles(['kibana_admin', 'animals', 'test_logstash_reader']); await kibanaServer.uiSettings.replace({ @@ -31,6 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await security.testUser.restoreDefaults(); + await kibanaServer.savedObjects.cleanStandardList(); }); it('after navigation from listing page to dashboard back button works', async () => { diff --git a/test/functional/apps/dashboard/dashboard_controls_integration.ts b/test/functional/apps/dashboard/dashboard_controls_integration.ts index 5ede80bf8eb8..a5feb4ca5e4e 100644 --- a/test/functional/apps/dashboard/dashboard_controls_integration.ts +++ b/test/functional/apps/dashboard/dashboard_controls_integration.ts @@ -16,7 +16,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const queryBar = getService('queryBar'); const pieChart = getService('pieChart'); const filterBar = getService('filterBar'); - const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); const dashboardAddPanel = getService('dashboardAddPanel'); @@ -29,8 +28,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ]); describe('Dashboard controls integration', () => { + const clearAllControls = async () => { + const controlIds = await dashboardControls.getAllControlIds(); + for (const controlId of controlIds) { + await dashboardControls.removeExistingControl(controlId); + } + }; + before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', @@ -44,12 +53,48 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await timePicker.setDefaultDataRange(); }); - it('shows the empty control callout on a new dashboard', async () => { - await testSubjects.existOrFail('controls-empty'); + after(async () => { + await security.testUser.restoreDefaults(); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + describe('Controls callout visibility', async () => { + describe('does not show the empty control callout on an empty dashboard', async () => { + it('in view mode', async () => { + await dashboard.saveDashboard('Test Controls Callout'); + await dashboard.clickCancelOutOfEditMode(); + await testSubjects.missingOrFail('controls-empty'); + }); + + it('in edit mode', async () => { + await dashboard.switchToEditMode(); + await testSubjects.missingOrFail('controls-empty'); + }); + }); + + it('show the empty control callout on a dashboard with panels', async () => { + await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); + await testSubjects.existOrFail('controls-empty'); + }); + + it('adding control hides the empty control callout', async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + await testSubjects.missingOrFail('controls-empty'); + }); + + after(async () => { + await dashboard.clickCancelOutOfEditMode(); + await dashboard.gotoDashboardLandingPage(); + }); }); describe('Options List Control creation and editing experience', async () => { it('can add a new options list control from a blank state', async () => { + await dashboard.clickNewDashboard(); + await timePicker.setDefaultDataRange(); await dashboardControls.createOptionsListControl({ fieldName: 'machine.os.raw' }); expect(await dashboardControls.getControlsCount()).to.be(1); }); @@ -84,9 +129,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.controlEditorSave(); // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view - await testSubjects.click('addFilter'); - await testSubjects.missingOrFail('filterIndexPatternsSelect'); - await filterBar.ensureFieldEditorModalIsClosed(); + await retry.try(async () => { + await testSubjects.click('addFilter'); + const indexPatternSelectExists = await testSubjects.exists('filterIndexPatternsSelect'); + await filterBar.ensureFieldEditorModalIsClosed(); + expect(indexPatternSelectExists).to.be(false); + }); }); it('deletes an existing control', async () => { @@ -97,18 +145,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { - const controlIds = await dashboardControls.getAllControlIds(); - for (const controlId of controlIds) { - await dashboardControls.removeExistingControl(controlId); - } + await clearAllControls(); }); }); - describe('Interact with options list on dashboard', async () => { + describe('Interactions between options list and dashboard', async () => { let controlId: string; before(async () => { await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); - await dashboardControls.createOptionsListControl({ dataViewTitle: 'animals-*', fieldName: 'sound.keyword', @@ -253,7 +297,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - describe('Options List validation', async () => { + describe('Options List dashboard validation', async () => { before(async () => { await dashboardControls.optionsListOpenPopover(controlId); await dashboardControls.optionsListPopoverSelectOption('meow'); @@ -330,6 +374,102 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await pieChart.getPieSliceCount()).to.be(1); }); }); + + after(async () => { + await filterBar.removeAllFilters(); + await clearAllControls(); + }); + }); + + describe('Control group hierarchical chaining', async () => { + let controlIds: string[]; + + const ensureAvailableOptionsEql = async (controlId: string, expectation: string[]) => { + await dashboardControls.optionsListOpenPopover(controlId); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql( + expectation + ); + }); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }; + + before(async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'animal.keyword', + title: 'Animal', + }); + + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'name.keyword', + title: 'Animal Name', + }); + + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + title: 'Animal Sound', + }); + + controlIds = await dashboardControls.getAllControlIds(); + }); + + it('Shows all available options in first Options List control', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(2); + }); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + }); + + it('Selecting an option in the first Options List will filter the second and third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverSelectOption('cat'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester']); + await ensureAvailableOptionsEql(controlIds[2], ['hiss', 'meow', 'growl', 'grr']); + }); + + it('Selecting an option in the second Options List will filter the third control', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[1]); + await dashboardControls.optionsListPopoverSelectOption('sylvester'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[1]); + + await ensureAvailableOptionsEql(controlIds[2], ['meow', 'hiss']); + }); + + it('Can select an option in the third Options List', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[2]); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); + }); + + it('Selecting a conflicting option in the first control will validate the second and third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverClearSelections(); + await dashboardControls.optionsListPopoverSelectOption('dog'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await ensureAvailableOptionsEql(controlIds[1], [ + 'Fluffy', + 'Fee Fee', + 'Rover', + 'Ignored selection', + 'sylvester', + ]); + await ensureAvailableOptionsEql(controlIds[2], [ + 'ruff', + 'bark', + 'grrr', + 'bow ow ow', + 'grr', + 'Ignored selection', + 'meow', + ]); + }); }); }); } diff --git a/test/functional/apps/dashboard/dashboard_error_handling.ts b/test/functional/apps/dashboard/dashboard_error_handling.ts index d120fb518f10..58304359458c 100644 --- a/test/functional/apps/dashboard/dashboard_error_handling.ts +++ b/test/functional/apps/dashboard/dashboard_error_handling.ts @@ -10,7 +10,6 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['dashboard', 'header', 'common']); const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); @@ -22,8 +21,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { */ describe('dashboard error handling', () => { before(async () => { - await esArchiver.loadIfNeeded( - 'test/functional/fixtures/es_archiver/dashboard/current/kibana' + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + // The kbn_archiver above was created from an es_archiver which intentionally had + // 2 missing index patterns. But that would fail to load with kbn_archiver. + // So we unload those 2 index patterns here. + await kibanaServer.importExport.unload( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana_unload' ); await kibanaServer.importExport.load( 'test/functional/fixtures/kbn_archiver/dashboard_error_cases.json' @@ -31,6 +37,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('dashboard'); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + it('correctly loads default index pattern on first load with an error embeddable', async () => { await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.loadSavedDashboard('Dashboard with Missing Lens Panel'); diff --git a/test/functional/apps/dashboard/dashboard_filter_bar.ts b/test/functional/apps/dashboard/dashboard_filter_bar.ts index 62eac2454ae9..a56143fa8b05 100644 --- a/test/functional/apps/dashboard/dashboard_filter_bar.ts +++ b/test/functional/apps/dashboard/dashboard_filter_bar.ts @@ -17,7 +17,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); const pieChart = getService('pieChart'); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const browser = getService('browser'); const security = getService('security'); @@ -32,7 +31,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('dashboard filter bar', () => { before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + // The kbn_archiver above was created from an es_archiver which intentionally had + // 2 missing index patterns. But that would fail to load with kbn_archiver. + // So we unload those 2 index patterns here. + await kibanaServer.importExport.unload( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana_unload' + ); await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', @@ -42,6 +50,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await security.testUser.restoreDefaults(); + await kibanaServer.savedObjects.cleanStandardList(); }); describe('Add a filter bar', function () { diff --git a/test/functional/apps/dashboard/dashboard_filtering.ts b/test/functional/apps/dashboard/dashboard_filtering.ts index 6c8a37883134..9522c47f907f 100644 --- a/test/functional/apps/dashboard/dashboard_filtering.ts +++ b/test/functional/apps/dashboard/dashboard_filtering.ts @@ -22,7 +22,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const renderable = getService('renderable'); const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const security = getService('security'); const dashboardPanelActions = getService('dashboardPanelActions'); @@ -51,7 +50,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }; before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + // The kbn_archiver above was created from an es_archiver which intentionally had + // 2 missing index patterns. But that would fail to load with kbn_archiver. + // So we unload those 2 index patterns here. + await kibanaServer.importExport.unload( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana_unload' + ); await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', @@ -63,6 +71,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await security.testUser.restoreDefaults(); + await kibanaServer.savedObjects.cleanStandardList(); }); describe('adding a filter that excludes all data', () => { diff --git a/test/functional/apps/dashboard/dashboard_grid.ts b/test/functional/apps/dashboard/dashboard_grid.ts index fecec34fd91e..25e901fd25d8 100644 --- a/test/functional/apps/dashboard/dashboard_grid.ts +++ b/test/functional/apps/dashboard/dashboard_grid.ts @@ -12,14 +12,16 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardPanelActions = getService('dashboardPanelActions'); const PageObjects = getPageObjects(['common', 'dashboard']); describe('dashboard grid', function () { before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -29,6 +31,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.switchToEditMode(); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + describe('move panel', () => { // Specific test after https://github.com/elastic/kibana/issues/14764 fix it('Can move panel from bottom to top row', async () => { diff --git a/test/functional/apps/dashboard/dashboard_options.ts b/test/functional/apps/dashboard/dashboard_options.ts index 8080c6cb4cc7..282674d0cec9 100644 --- a/test/functional/apps/dashboard/dashboard_options.ts +++ b/test/functional/apps/dashboard/dashboard_options.ts @@ -12,7 +12,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['common', 'dashboard']); @@ -20,7 +19,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { let originalTitles: string[] = []; before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -31,6 +33,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { originalTitles = await PageObjects.dashboard.getPanelTitles(); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + it('should be able to hide all panel titles', async () => { await PageObjects.dashboard.checkHideTitle(); await retry.try(async () => { diff --git a/test/functional/apps/dashboard/dashboard_query_bar.ts b/test/functional/apps/dashboard/dashboard_query_bar.ts index 07fa3355c6fb..5092cadaf9d2 100644 --- a/test/functional/apps/dashboard/dashboard_query_bar.ts +++ b/test/functional/apps/dashboard/dashboard_query_bar.ts @@ -20,7 +20,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('dashboard query bar', () => { before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -29,6 +32,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.loadSavedDashboard('dashboard with filter'); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + it('causes panels to reload when refresh is clicked', async () => { await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data'); diff --git a/test/functional/apps/dashboard/dashboard_save.ts b/test/functional/apps/dashboard/dashboard_save.ts index dce59744db30..4ab8633a5619 100644 --- a/test/functional/apps/dashboard/dashboard_save.ts +++ b/test/functional/apps/dashboard/dashboard_save.ts @@ -58,6 +58,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // wait till it finishes reloading or it might reload the url after simulating the // dashboard landing page click. await PageObjects.header.waitUntilLoadingHasFinished(); + + // after saving a new dashboard, the app state must be removed + await await PageObjects.dashboard.expectAppStateRemovedFromURL(); + await PageObjects.dashboard.gotoDashboardLandingPage(); await listingTable.searchAndExpectItemsCount('dashboard', dashboardName, 2); diff --git a/test/functional/apps/dashboard/dashboard_saved_query.ts b/test/functional/apps/dashboard/dashboard_saved_query.ts index 015a00a713bd..658afb9c641b 100644 --- a/test/functional/apps/dashboard/dashboard_saved_query.ts +++ b/test/functional/apps/dashboard/dashboard_saved_query.ts @@ -11,7 +11,6 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['common', 'dashboard', 'timePicker']); const browser = getService('browser'); @@ -21,13 +20,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('dashboard saved queries', function describeIndexTests() { before(async function () { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); await PageObjects.common.navigateToApp('dashboard'); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + describe('saved query management component functionality', function () { before(async () => { await PageObjects.dashboard.gotoDashboardLandingPage(); diff --git a/test/functional/apps/dashboard/dashboard_snapshots.ts b/test/functional/apps/dashboard/dashboard_snapshots.ts index 9279bbd5806e..9cb52c5dd551 100644 --- a/test/functional/apps/dashboard/dashboard_snapshots.ts +++ b/test/functional/apps/dashboard/dashboard_snapshots.ts @@ -18,14 +18,16 @@ export default function ({ const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'common', 'timePicker']); const screenshot = getService('screenshots'); const browser = getService('browser'); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardPanelActions = getService('dashboardPanelActions'); const dashboardAddPanel = getService('dashboardAddPanel'); describe('dashboard snapshots', function describeIndexTests() { before(async function () { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -38,6 +40,7 @@ export default function ({ after(async function () { await browser.setWindowSize(1300, 900); + await kibanaServer.savedObjects.cleanStandardList(); }); it('compare TSVB snapshot', async () => { diff --git a/test/functional/apps/dashboard/dashboard_state.ts b/test/functional/apps/dashboard/dashboard_state.ts index 0cc0fa480648..d3643753c8e8 100644 --- a/test/functional/apps/dashboard/dashboard_state.ts +++ b/test/functional/apps/dashboard/dashboard_state.ts @@ -195,12 +195,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('for query parameter with soft refresh', async function () { await changeQuery(false, 'hi:goodbye'); + await PageObjects.dashboard.expectAppStateRemovedFromURL(); }); it('for query parameter with hard refresh', async function () { await changeQuery(true, 'hi:hello'); await queryBar.clearQuery(); await queryBar.clickQuerySubmitButton(); + await PageObjects.dashboard.expectAppStateRemovedFromURL(); }); it('for panel size parameters', async function () { diff --git a/test/functional/apps/dashboard/dashboard_unsaved_listing.ts b/test/functional/apps/dashboard/dashboard_unsaved_listing.ts index 2b2d96e8d723..d9451eda5ff6 100644 --- a/test/functional/apps/dashboard/dashboard_unsaved_listing.ts +++ b/test/functional/apps/dashboard/dashboard_unsaved_listing.ts @@ -12,7 +12,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardAddPanel = getService('dashboardAddPanel'); const dashboardPanelActions = getService('dashboardPanelActions'); @@ -36,7 +35,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }; before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -44,6 +46,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.preserveCrossAppState(); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + it('lists unsaved changes to existing dashboards', async () => { await PageObjects.dashboard.loadSavedDashboard(dashboardTitle); await PageObjects.dashboard.switchToEditMode(); diff --git a/test/functional/apps/dashboard/dashboard_unsaved_state.ts b/test/functional/apps/dashboard/dashboard_unsaved_state.ts index c2da82a96cd0..5afe3b993743 100644 --- a/test/functional/apps/dashboard/dashboard_unsaved_state.ts +++ b/test/functional/apps/dashboard/dashboard_unsaved_state.ts @@ -15,7 +15,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const queryBar = getService('queryBar'); const filterBar = getService('filterBar'); - const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); const dashboardAddPanel = getService('dashboardAddPanel'); @@ -27,7 +26,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // FLAKY https://github.com/elastic/kibana/issues/112812 describe.skip('dashboard unsaved state', () => { before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -39,6 +41,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { originalPanelCount = await PageObjects.dashboard.getPanelCount(); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + describe('view mode state', () => { before(async () => { await queryBar.setQuery(testQuery); diff --git a/test/functional/apps/dashboard/data_shared_attributes.ts b/test/functional/apps/dashboard/data_shared_attributes.ts index 4b993287ffe4..a94cf1b6063a 100644 --- a/test/functional/apps/dashboard/data_shared_attributes.ts +++ b/test/functional/apps/dashboard/data_shared_attributes.ts @@ -12,7 +12,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardPanelActions = getService('dashboardPanelActions'); const security = getService('security'); @@ -22,7 +21,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { let originalPanelTitles: string[]; before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', @@ -35,6 +37,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await security.testUser.restoreDefaults(); + await kibanaServer.savedObjects.cleanStandardList(); }); it('should have time picker with data-shared-timefilter-duration', async () => { diff --git a/test/functional/apps/dashboard/edit_embeddable_redirects.ts b/test/functional/apps/dashboard/edit_embeddable_redirects.ts index 02f178a5153a..763488cc21ab 100644 --- a/test/functional/apps/dashboard/edit_embeddable_redirects.ts +++ b/test/functional/apps/dashboard/edit_embeddable_redirects.ts @@ -12,14 +12,16 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardPanelActions = getService('dashboardPanelActions'); const dashboardAddPanel = getService('dashboardAddPanel'); describe('edit embeddable redirects', () => { before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -29,6 +31,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.switchToEditMode(); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + it('redirects via save and return button after edit', async () => { await dashboardPanelActions.openContextMenu(); await dashboardPanelActions.clickEdit(); diff --git a/test/functional/apps/dashboard/edit_visualizations.js b/test/functional/apps/dashboard/edit_visualizations.js index 507d4b8308d4..d4de54586b73 100644 --- a/test/functional/apps/dashboard/edit_visualizations.js +++ b/test/functional/apps/dashboard/edit_visualizations.js @@ -10,7 +10,6 @@ import expect from '@kbn/expect'; export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'common', 'visEditor']); - const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); const appsMenu = getService('appsMenu'); const kibanaServer = getService('kibanaServer'); @@ -44,13 +43,20 @@ export default function ({ getService, getPageObjects }) { describe('edit visualizations from dashboard', () => { before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); await PageObjects.common.navigateToApp('dashboard'); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + it('save button returns to dashboard after editing visualization with changes saved', async () => { const title = 'test save'; await PageObjects.dashboard.gotoDashboardLandingPage(); diff --git a/test/functional/apps/dashboard/embed_mode.ts b/test/functional/apps/dashboard/embed_mode.ts index 943a6b3bdb46..7e53bff7387c 100644 --- a/test/functional/apps/dashboard/embed_mode.ts +++ b/test/functional/apps/dashboard/embed_mode.ts @@ -13,7 +13,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['dashboard', 'common']); const browser = getService('browser'); @@ -28,7 +27,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ]; before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -81,6 +83,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // Then get rid of the timestamp so the rest of the tests work with state and app switching. useTimeStamp = false; await browser.get(newUrl.toString(), useTimeStamp); + await kibanaServer.savedObjects.cleanStandardList(); }); }); } diff --git a/test/functional/apps/dashboard/embeddable_data_grid.ts b/test/functional/apps/dashboard/embeddable_data_grid.ts index 5dea22f5006c..060c46765666 100644 --- a/test/functional/apps/dashboard/embeddable_data_grid.ts +++ b/test/functional/apps/dashboard/embeddable_data_grid.ts @@ -23,8 +23,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); - await esArchiver.loadIfNeeded( - 'test/functional/fixtures/es_archiver/dashboard/current/kibana' + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', @@ -39,6 +40,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); }); + after(async function () { + await kibanaServer.savedObjects.cleanStandardList(); + }); + it('should expand the detail row when the toggle arrow is clicked', async function () { await retry.try(async function () { await dataGrid.clickRowToggle({ isAnchorRow: false, rowIndex: 0 }); diff --git a/test/functional/apps/dashboard/embeddable_library.ts b/test/functional/apps/dashboard/embeddable_library.ts index fd1aa0d91def..2abf75f6385a 100644 --- a/test/functional/apps/dashboard/embeddable_library.ts +++ b/test/functional/apps/dashboard/embeddable_library.ts @@ -12,7 +12,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); - const esArchiver = getService('esArchiver'); const find = getService('find'); const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); @@ -21,7 +20,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('embeddable library', () => { before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); diff --git a/test/functional/apps/dashboard/embeddable_rendering.ts b/test/functional/apps/dashboard/embeddable_rendering.ts index 8fa2337eef0c..840826be4653 100644 --- a/test/functional/apps/dashboard/embeddable_rendering.ts +++ b/test/functional/apps/dashboard/embeddable_rendering.ts @@ -21,7 +21,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const find = getService('find'); const browser = getService('browser'); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const pieChart = getService('pieChart'); const elasticChart = getService('elasticChart'); @@ -103,7 +102,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('dashboard embeddable rendering', function describeIndexTests() { before(async () => { await security.testUser.setRoles(['kibana_admin', 'animals', 'test_logstash_reader']); - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -122,12 +124,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const newUrl = currentUrl.replace(/\?.*$/, ''); await browser.get(newUrl, false); await security.testUser.restoreDefaults(); + await kibanaServer.savedObjects.cleanStandardList(); }); it('adding visualizations', async () => { await elasticChart.setNewChartUiDebugFlag(true); visNames = await dashboardAddPanel.addEveryVisualization('"Rendering Test"'); + expect(visNames.length).to.be.equal(24); await dashboardExpect.visualizationsArePresent(visNames); // This one is rendered via svg which lets us do better testing of what is being rendered. diff --git a/test/functional/apps/dashboard/empty_dashboard.ts b/test/functional/apps/dashboard/empty_dashboard.ts index 46a8545b7959..a7524eaa94b8 100644 --- a/test/functional/apps/dashboard/empty_dashboard.ts +++ b/test/functional/apps/dashboard/empty_dashboard.ts @@ -12,7 +12,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardAddPanel = getService('dashboardAddPanel'); const dashboardVisualizations = getService('dashboardVisualizations'); @@ -21,7 +20,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('empty dashboard', () => { before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -33,6 +35,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await dashboardAddPanel.closeAddPanel(); await PageObjects.dashboard.gotoDashboardLandingPage(); + await kibanaServer.savedObjects.cleanStandardList(); }); it('should display empty widget', async () => { diff --git a/test/functional/apps/dashboard/full_screen_mode.ts b/test/functional/apps/dashboard/full_screen_mode.ts index fcfd0fc49dd2..74fa2168a146 100644 --- a/test/functional/apps/dashboard/full_screen_mode.ts +++ b/test/functional/apps/dashboard/full_screen_mode.ts @@ -13,7 +13,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const browser = getService('browser'); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardPanelActions = getService('dashboardPanelActions'); const PageObjects = getPageObjects(['dashboard', 'common']); @@ -21,7 +20,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('full screen mode', () => { before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -30,6 +32,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.loadSavedDashboard('few panels'); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + it('option not available in edit mode', async () => { await PageObjects.dashboard.switchToEditMode(); const exists = await PageObjects.dashboard.fullScreenModeMenuItemExists(); diff --git a/test/functional/apps/dashboard/legacy_urls.ts b/test/functional/apps/dashboard/legacy_urls.ts index b449c0f6728a..1e4138e63d39 100644 --- a/test/functional/apps/dashboard/legacy_urls.ts +++ b/test/functional/apps/dashboard/legacy_urls.ts @@ -23,7 +23,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const dashboardAddPanel = getService('dashboardAddPanel'); const listingTable = getService('listingTable'); - const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); const security = getService('security'); let kibanaLegacyBaseUrl: string; @@ -33,7 +33,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('legacy urls', function describeIndexTests() { before(async function () { await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.clickNewDashboard(); await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); diff --git a/test/functional/apps/dashboard/panel_expand_toggle.ts b/test/functional/apps/dashboard/panel_expand_toggle.ts index 00500450595e..272ec3824e23 100644 --- a/test/functional/apps/dashboard/panel_expand_toggle.ts +++ b/test/functional/apps/dashboard/panel_expand_toggle.ts @@ -12,14 +12,16 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardPanelActions = getService('dashboardPanelActions'); const PageObjects = getPageObjects(['dashboard', 'visualize', 'header', 'common']); describe('expanding a panel', () => { before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -28,6 +30,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.loadSavedDashboard('few panels'); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + it('hides other panels', async () => { await dashboardPanelActions.openContextMenu(); await dashboardPanelActions.clickExpandPanelToggle(); diff --git a/test/functional/apps/dashboard/saved_search_embeddable.ts b/test/functional/apps/dashboard/saved_search_embeddable.ts index b08dc43210d2..02050eec3022 100644 --- a/test/functional/apps/dashboard/saved_search_embeddable.ts +++ b/test/functional/apps/dashboard/saved_search_embeddable.ts @@ -22,8 +22,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); - await esArchiver.loadIfNeeded( - 'test/functional/fixtures/es_archiver/dashboard/current/kibana' + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', @@ -38,6 +39,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + it('highlighting on filtering works', async function () { await dashboardAddPanel.addSavedSearch('Rendering-Test:-saved-search'); await filterBar.addFilter('agent', 'is', 'Mozilla'); diff --git a/test/functional/apps/dashboard/share.ts b/test/functional/apps/dashboard/share.ts index 77a858b22ec7..7fe8048ab7c0 100644 --- a/test/functional/apps/dashboard/share.ts +++ b/test/functional/apps/dashboard/share.ts @@ -10,13 +10,15 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['dashboard', 'common', 'share']); describe('share dashboard', () => { before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -25,6 +27,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.loadSavedDashboard('few panels'); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + it('has "panels" state when sharing a snapshot', async () => { await PageObjects.share.clickShareTopNavButton(); const sharedUrl = await PageObjects.share.getSharedUrl(); diff --git a/test/functional/apps/dashboard/url_field_formatter.ts b/test/functional/apps/dashboard/url_field_formatter.ts index 16cdb6276821..be480066c036 100644 --- a/test/functional/apps/dashboard/url_field_formatter.ts +++ b/test/functional/apps/dashboard/url_field_formatter.ts @@ -18,7 +18,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'timePicker', 'visChart', ]); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); const browser = getService('browser'); @@ -38,7 +37,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Changing field formatter to Url', () => { before(async function () { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); @@ -52,6 +54,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await settings.controlChangeSave(); }); + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + it('applied on dashboard', async () => { await common.navigateToApp('dashboard'); await dashboard.loadSavedDashboard('dashboard with table'); diff --git a/test/functional/apps/dashboard/view_edit.ts b/test/functional/apps/dashboard/view_edit.ts index f0ee5aad7a7c..a73924a8ae75 100644 --- a/test/functional/apps/dashboard/view_edit.ts +++ b/test/functional/apps/dashboard/view_edit.ts @@ -12,7 +12,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const queryBar = getService('queryBar'); - const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardAddPanel = getService('dashboardAddPanel'); const PageObjects = getPageObjects(['dashboard', 'header', 'common', 'visualize', 'timePicker']); @@ -22,7 +21,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('dashboard view edit mode', function viewEditModeTests() { before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', @@ -33,6 +35,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await security.testUser.restoreDefaults(); + await kibanaServer.savedObjects.cleanStandardList(); }); it('create new dashboard opens in edit mode', async function () { diff --git a/test/functional/apps/discover/_huge_fields.ts b/test/functional/apps/discover/_huge_fields.ts index 24b10e1df049..3cca75234675 100644 --- a/test/functional/apps/discover/_huge_fields.ts +++ b/test/functional/apps/discover/_huge_fields.ts @@ -18,7 +18,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('test large number of fields in sidebar', function () { before(async function () { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/huge_fields'); - await security.testUser.setRoles(['kibana_admin', 'test_testhuge_reader'], false); + await security.testUser.setRoles(['kibana_admin', 'test_testhuge_reader'], { + skipBrowserRefresh: true, + }); await kibanaServer.uiSettings.update({ 'timepicker:timeDefaults': `{ "from": "2016-10-05T00:00:00", "to": "2016-10-06T00:00:00"}`, }); diff --git a/test/functional/apps/discover/_runtime_fields_editor.ts b/test/functional/apps/discover/_runtime_fields_editor.ts index 8406c7552385..7c884f8b759d 100644 --- a/test/functional/apps/discover/_runtime_fields_editor.ts +++ b/test/functional/apps/discover/_runtime_fields_editor.ts @@ -32,7 +32,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); }; - describe('discover integration with runtime fields editor', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/123372 + describe.skip('discover integration with runtime fields editor', function describeIndexTests() { before(async function () { await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); diff --git a/test/functional/apps/management/_create_index_pattern_wizard.js b/test/functional/apps/management/_create_index_pattern_wizard.ts similarity index 93% rename from test/functional/apps/management/_create_index_pattern_wizard.js rename to test/functional/apps/management/_create_index_pattern_wizard.ts index b2f24e530cb1..cf732e178aa7 100644 --- a/test/functional/apps/management/_create_index_pattern_wizard.js +++ b/test/functional/apps/management/_create_index_pattern_wizard.ts @@ -6,7 +6,9 @@ * Side Public License, v 1. */ -export default function ({ getService, getPageObjects }) { +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); const es = getService('es'); @@ -38,7 +40,7 @@ export default function ({ getService, getPageObjects }) { body: { actions: [{ add: { index: 'blogs', alias: 'alias1' } }] }, }); - await PageObjects.settings.createIndexPattern('alias1', false); + await PageObjects.settings.createIndexPattern('alias1', null); }); it('can delete an index pattern', async () => { diff --git a/test/functional/apps/management/_exclude_index_pattern.js b/test/functional/apps/management/_exclude_index_pattern.ts similarity index 89% rename from test/functional/apps/management/_exclude_index_pattern.js rename to test/functional/apps/management/_exclude_index_pattern.ts index b71222c1ec44..8c20acdc21f9 100644 --- a/test/functional/apps/management/_exclude_index_pattern.js +++ b/test/functional/apps/management/_exclude_index_pattern.ts @@ -7,8 +7,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['settings']); const es = getService('es'); diff --git a/test/functional/apps/management/_handle_alias.js b/test/functional/apps/management/_handle_alias.ts similarity index 95% rename from test/functional/apps/management/_handle_alias.js rename to test/functional/apps/management/_handle_alias.ts index 891e59d84a04..04496bf9ed75 100644 --- a/test/functional/apps/management/_handle_alias.js +++ b/test/functional/apps/management/_handle_alias.ts @@ -7,8 +7,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const es = getService('es'); const retry = getService('retry'); diff --git a/test/functional/apps/management/_handle_version_conflict.js b/test/functional/apps/management/_handle_version_conflict.ts similarity index 96% rename from test/functional/apps/management/_handle_version_conflict.js rename to test/functional/apps/management/_handle_version_conflict.ts index a04c5d34b2d3..2f65f966c559 100644 --- a/test/functional/apps/management/_handle_version_conflict.js +++ b/test/functional/apps/management/_handle_version_conflict.ts @@ -16,8 +16,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); const browser = getService('browser'); @@ -93,7 +94,6 @@ export default function ({ getService, getPageObjects }) { expect(response.body.result).to.be('updated'); await PageObjects.settings.controlChangeSave(); await retry.try(async function () { - //await PageObjects.common.sleep(2000); const message = await PageObjects.common.closeToast(); expect(message).to.contain('Unable'); }); diff --git a/test/functional/apps/management/_index_pattern_create_delete.js b/test/functional/apps/management/_index_pattern_create_delete.ts similarity index 91% rename from test/functional/apps/management/_index_pattern_create_delete.js rename to test/functional/apps/management/_index_pattern_create_delete.ts index 4c9f5a5210ac..6b2036499a1e 100644 --- a/test/functional/apps/management/_index_pattern_create_delete.js +++ b/test/functional/apps/management/_index_pattern_create_delete.ts @@ -7,8 +7,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const browser = getService('browser'); @@ -35,8 +36,7 @@ export default function ({ getService, getPageObjects }) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/124663 - describe.skip('validation', function () { + describe('validation', function () { it('can display errors', async function () { await PageObjects.settings.clickAddNewIndexPatternButton(); await PageObjects.settings.setIndexPatternField('log-fake*'); @@ -46,7 +46,7 @@ export default function ({ getService, getPageObjects }) { it('can resolve errors and submit', async function () { await PageObjects.settings.setIndexPatternField('log*'); - await (await PageObjects.settings.getSaveIndexPatternButton()).click(); + await (await PageObjects.settings.getSaveDataViewButtonActive()).click(); await PageObjects.settings.removeIndexPattern(); }); }); @@ -72,10 +72,12 @@ export default function ({ getService, getPageObjects }) { }); describe('index pattern creation', function indexPatternCreation() { - let indexPatternId; + let indexPatternId: string; before(function () { - return PageObjects.settings.createIndexPattern().then((id) => (indexPatternId = id)); + return PageObjects.settings + .createIndexPattern('logstash-*') + .then((id) => (indexPatternId = id)); }); it('should have index pattern in page header', async function () { diff --git a/test/functional/apps/management/_index_pattern_filter.js b/test/functional/apps/management/_index_pattern_filter.ts similarity index 90% rename from test/functional/apps/management/_index_pattern_filter.js rename to test/functional/apps/management/_index_pattern_filter.ts index 3e9d316b59c6..afa64c474d39 100644 --- a/test/functional/apps/management/_index_pattern_filter.js +++ b/test/functional/apps/management/_index_pattern_filter.ts @@ -7,8 +7,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const retry = getService('retry'); const PageObjects = getPageObjects(['settings']); @@ -23,7 +24,7 @@ export default function ({ getService, getPageObjects }) { }); beforeEach(async function () { - await PageObjects.settings.createIndexPattern(); + await PageObjects.settings.createIndexPattern('logstash-*'); }); afterEach(async function () { diff --git a/test/functional/apps/management/_index_pattern_popularity.js b/test/functional/apps/management/_index_pattern_popularity.ts similarity index 92% rename from test/functional/apps/management/_index_pattern_popularity.js rename to test/functional/apps/management/_index_pattern_popularity.ts index 1a71e4c5fbc6..bff6cdce0f7a 100644 --- a/test/functional/apps/management/_index_pattern_popularity.js +++ b/test/functional/apps/management/_index_pattern_popularity.ts @@ -7,8 +7,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); const log = getService('log'); @@ -23,7 +24,7 @@ export default function ({ getService, getPageObjects }) { }); beforeEach(async () => { - await PageObjects.settings.createIndexPattern(); + await PageObjects.settings.createIndexPattern('logstash-*'); // increase Popularity of geo.coordinates log.debug('Starting openControlsByName (' + fieldName + ')'); await PageObjects.settings.openControlsByName(fieldName); diff --git a/test/functional/apps/management/_index_pattern_results_sort.js b/test/functional/apps/management/_index_pattern_results_sort.ts similarity index 90% rename from test/functional/apps/management/_index_pattern_results_sort.js rename to test/functional/apps/management/_index_pattern_results_sort.ts index cedf5ee355b3..305a72889e95 100644 --- a/test/functional/apps/management/_index_pattern_results_sort.js +++ b/test/functional/apps/management/_index_pattern_results_sort.ts @@ -7,8 +7,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const retry = getService('retry'); const PageObjects = getPageObjects(['settings', 'common']); @@ -18,7 +19,7 @@ export default function ({ getService, getPageObjects }) { // delete .kibana index and then wait for Kibana to re-create it await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); - await PageObjects.settings.createIndexPattern(); + await PageObjects.settings.createIndexPattern('logstash-*'); }); after(async function () { @@ -30,7 +31,7 @@ export default function ({ getService, getPageObjects }) { heading: 'Name', first: '@message', last: 'xss.raw', - selector: async function () { + async selector() { const tableRow = await PageObjects.settings.getTableRow(0, 0); return await tableRow.getVisibleText(); }, @@ -39,7 +40,7 @@ export default function ({ getService, getPageObjects }) { heading: 'Type', first: '', last: 'text', - selector: async function () { + async selector() { const tableRow = await PageObjects.settings.getTableRow(0, 1); return await tableRow.getVisibleText(); }, @@ -49,7 +50,6 @@ export default function ({ getService, getPageObjects }) { columns.forEach(function (col) { describe('sort by heading - ' + col.heading, function indexPatternCreation() { it('should sort ascending', async function () { - console.log('col.heading', col.heading); if (col.heading !== 'Name') { await PageObjects.settings.sortBy(col.heading); } diff --git a/test/functional/apps/management/_kibana_settings.js b/test/functional/apps/management/_kibana_settings.ts similarity index 96% rename from test/functional/apps/management/_kibana_settings.js rename to test/functional/apps/management/_kibana_settings.ts index cfe4e88cda21..d459643849fb 100644 --- a/test/functional/apps/management/_kibana_settings.js +++ b/test/functional/apps/management/_kibana_settings.ts @@ -7,8 +7,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const browser = getService('browser'); const PageObjects = getPageObjects(['settings', 'common', 'dashboard', 'timePicker', 'header']); diff --git a/test/functional/apps/management/_mgmt_import_saved_objects.js b/test/functional/apps/management/_mgmt_import_saved_objects.ts similarity index 80% rename from test/functional/apps/management/_mgmt_import_saved_objects.js rename to test/functional/apps/management/_mgmt_import_saved_objects.ts index 95b0bbb7ed03..04a1bb593832 100644 --- a/test/functional/apps/management/_mgmt_import_saved_objects.js +++ b/test/functional/apps/management/_mgmt_import_saved_objects.ts @@ -8,13 +8,14 @@ import expect from '@kbn/expect'; import path from 'path'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['common', 'settings', 'header', 'savedObjects']); - //in 6.4.0 bug the Saved Search conflict would be resolved and get imported but the visualization - //that referenced the saved search was not imported.( https://github.com/elastic/kibana/issues/22238) + // in 6.4.0 bug the Saved Search conflict would be resolved and get imported but the visualization + // that referenced the saved search was not imported.( https://github.com/elastic/kibana/issues/22238) describe('mgmt saved objects', function describeIndexTests() { before(async () => { @@ -41,7 +42,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.savedObjects.waitTableIsLoaded(); await PageObjects.savedObjects.searchForObject('mysaved'); - //instead of asserting on count- am asserting on the titles- which is more accurate than count. + // instead of asserting on count- am asserting on the titles- which is more accurate than count. const objects = await PageObjects.savedObjects.getRowTitles(); expect(objects.includes('mysavedsearch')).to.be(true); expect(objects.includes('mysavedviz')).to.be(true); diff --git a/test/functional/apps/management/_runtime_fields.js b/test/functional/apps/management/_runtime_fields.ts similarity index 91% rename from test/functional/apps/management/_runtime_fields.js rename to test/functional/apps/management/_runtime_fields.ts index 3a70df81b55d..8ec9fb92c58e 100644 --- a/test/functional/apps/management/_runtime_fields.js +++ b/test/functional/apps/management/_runtime_fields.ts @@ -7,8 +7,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const log = getService('log'); const browser = getService('browser'); @@ -36,7 +37,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); - const startingCount = parseInt(await PageObjects.settings.getFieldsTabCount()); + const startingCount = parseInt(await PageObjects.settings.getFieldsTabCount(), 10); await log.debug('add runtime field'); await PageObjects.settings.addRuntimeField( fieldName, @@ -51,7 +52,9 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.clickSaveField(); await retry.try(async function () { - expect(parseInt(await PageObjects.settings.getFieldsTabCount())).to.be(startingCount + 1); + expect(parseInt(await PageObjects.settings.getFieldsTabCount(), 10)).to.be( + startingCount + 1 + ); }); }); diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.ts similarity index 96% rename from test/functional/apps/management/_scripted_fields.js rename to test/functional/apps/management/_scripted_fields.ts index 72f45e1fedb4..c8c605ec7ed1 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.ts @@ -23,8 +23,9 @@ // it will automatically insert a a closing square brace ], etc. import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const log = getService('log'); const browser = getService('browser'); @@ -77,7 +78,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); - const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); + const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount(), 10); await PageObjects.settings.clickScriptedFieldsTab(); await log.debug('add scripted field'); const script = `1`; @@ -90,7 +91,7 @@ export default function ({ getService, getPageObjects }) { script ); await retry.try(async function () { - expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount())).to.be( + expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount(), 10)).to.be( startingCount + 1 ); }); @@ -111,7 +112,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); - const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); + const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount(), 10); await PageObjects.settings.clickScriptedFieldsTab(); await log.debug('add scripted field'); const script = `if (doc['machine.ram'].size() == 0) return -1; @@ -126,7 +127,7 @@ export default function ({ getService, getPageObjects }) { script ); await retry.try(async function () { - expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount())).to.be( + expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount(), 10)).to.be( startingCount + 1 ); }); @@ -150,7 +151,7 @@ export default function ({ getService, getPageObjects }) { }); }); - //add a test to sort numeric scripted field + // add a test to sort numeric scripted field it('should sort scripted field value in Discover', async function () { await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName}`); // after the first click on the scripted field, it becomes secondary sort after time. @@ -201,7 +202,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); - const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); + const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount(), 10); await PageObjects.settings.clickScriptedFieldsTab(); await log.debug('add scripted field'); await PageObjects.settings.addScriptedField( @@ -213,7 +214,7 @@ export default function ({ getService, getPageObjects }) { "if (doc['response.raw'].value == '200') { return 'good'} else { return 'bad'}" ); await retry.try(async function () { - expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount())).to.be( + expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount(), 10)).to.be( startingCount + 1 ); }); @@ -237,7 +238,7 @@ export default function ({ getService, getPageObjects }) { }); }); - //add a test to sort string scripted field + // add a test to sort string scripted field it('should sort scripted field value in Discover', async function () { await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); // after the first click on the scripted field, it becomes secondary sort after time. @@ -287,7 +288,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); - const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); + const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount(), 10); await PageObjects.settings.clickScriptedFieldsTab(); await log.debug('add scripted field'); await PageObjects.settings.addScriptedField( @@ -299,7 +300,7 @@ export default function ({ getService, getPageObjects }) { "doc['response.raw'].value == '200'" ); await retry.try(async function () { - expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount())).to.be( + expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount(), 10)).to.be( startingCount + 1 ); }); @@ -335,8 +336,8 @@ export default function ({ getService, getPageObjects }) { await filterBar.removeAllFilters(); }); - //add a test to sort boolean - //existing bug: https://github.com/elastic/kibana/issues/75519 hence the issue is skipped. + // add a test to sort boolean + // existing bug: https://github.com/elastic/kibana/issues/75519 hence the issue is skipped. it.skip('should sort scripted field value in Discover', async function () { await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); // after the first click on the scripted field, it becomes secondary sort after time. @@ -374,7 +375,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); - const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); + const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount(), 10); await PageObjects.settings.clickScriptedFieldsTab(); await log.debug('add scripted field'); await PageObjects.settings.addScriptedField( @@ -386,7 +387,7 @@ export default function ({ getService, getPageObjects }) { "doc['utc_time'].value.toEpochMilli() + (1000) * 60 * 60" ); await retry.try(async function () { - expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount())).to.be( + expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount(), 10)).to.be( startingCount + 1 ); }); @@ -410,8 +411,8 @@ export default function ({ getService, getPageObjects }) { }); }); - //add a test to sort date scripted field - //https://github.com/elastic/kibana/issues/75711 + // add a test to sort date scripted field + // https://github.com/elastic/kibana/issues/75711 it.skip('should sort scripted field value in Discover', async function () { await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); // after the first click on the scripted field, it becomes secondary sort after time. diff --git a/test/functional/apps/management/_scripted_fields_filter.js b/test/functional/apps/management/_scripted_fields_filter.ts similarity index 95% rename from test/functional/apps/management/_scripted_fields_filter.js rename to test/functional/apps/management/_scripted_fields_filter.ts index 117b8747c5a0..4f6d1a41d052 100644 --- a/test/functional/apps/management/_scripted_fields_filter.js +++ b/test/functional/apps/management/_scripted_fields_filter.ts @@ -7,8 +7,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const retry = getService('retry'); const log = getService('log'); @@ -67,6 +68,7 @@ export default function ({ getService, getPageObjects }) { expect(lang).to.be('painless'); } }); + await PageObjects.settings.clearScriptedFieldLanguageFilter('painless'); await PageObjects.settings.setScriptedFieldLanguageFilter('expression'); diff --git a/test/functional/apps/management/_scripted_fields_preview.js b/test/functional/apps/management/_scripted_fields_preview.ts similarity index 90% rename from test/functional/apps/management/_scripted_fields_preview.js rename to test/functional/apps/management/_scripted_fields_preview.ts index b6c941fe21d0..380b4659c0f3 100644 --- a/test/functional/apps/management/_scripted_fields_preview.js +++ b/test/functional/apps/management/_scripted_fields_preview.ts @@ -7,13 +7,14 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const PageObjects = getPageObjects(['settings']); const SCRIPTED_FIELD_NAME = 'myScriptedField'; - const scriptResultToJson = (scriptResult) => { + const scriptResultToJson = (scriptResult: string) => { try { return JSON.parse(scriptResult); } catch (e) { @@ -26,7 +27,7 @@ export default function ({ getService, getPageObjects }) { await browser.setWindowSize(1200, 800); await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaIndexPatterns(); - await PageObjects.settings.createIndexPattern(); + await PageObjects.settings.createIndexPattern('logstash-*'); await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaIndexPatterns(); @@ -67,7 +68,7 @@ export default function ({ getService, getPageObjects }) { it('should display additional fields', async function () { const scriptResults = await PageObjects.settings.executeScriptedField( `doc['bytes'].value * 2`, - ['bytes'] + 'bytes' ); const [{ _id, bytes }] = scriptResultToJson(scriptResults); expect(_id).to.be.a('string'); diff --git a/test/functional/apps/management/_test_huge_fields.js b/test/functional/apps/management/_test_huge_fields.ts similarity index 90% rename from test/functional/apps/management/_test_huge_fields.js rename to test/functional/apps/management/_test_huge_fields.ts index 7b7568394092..abc338cb8abc 100644 --- a/test/functional/apps/management/_test_huge_fields.js +++ b/test/functional/apps/management/_test_huge_fields.ts @@ -7,8 +7,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const security = getService('security'); const PageObjects = getPageObjects(['common', 'home', 'settings']); @@ -19,7 +20,7 @@ export default function ({ getService, getPageObjects }) { const EXPECTED_FIELD_COUNT = '10006'; before(async function () { - await security.testUser.setRoles(['kibana_admin', 'test_testhuge_reader'], false); + await security.testUser.setRoles(['kibana_admin', 'test_testhuge_reader']); await esArchiver.emptyKibanaIndex(); await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/large_fields'); await PageObjects.settings.navigateTo(); diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index 4080ca2a0ba7..ec3852b309d3 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -34,7 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { beforeEach(async () => { await security.testUser.setRoles( ['kibana_admin', 'test_logstash_reader', 'kibana_sample_admin'], - false + { skipBrowserRefresh: true } ); await visualize.navigateToNewVisualization(); await visualize.clickVisualBuilder(); diff --git a/test/functional/fixtures/es_archiver/dashboard/current/kibana/data.json b/test/functional/fixtures/es_archiver/dashboard/current/kibana/data.json deleted file mode 100644 index eef30ceb606e..000000000000 --- a/test/functional/fixtures/es_archiver/dashboard/current/kibana/data.json +++ /dev/null @@ -1,3392 +0,0 @@ -{ - "type": "doc", - "value": { - "id": "search:a16d1990-3dca-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "search": "7.9.3" - }, - "references": [ - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "search": { - "columns": [ - "animal", - "isDog", - "name", - "sound", - "weightLbs" - ], - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"lucene\",\"query\":\"weightLbs:>40\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "sort": [ - [ - "weightLbs", - "desc" - ] - ], - "title": "animal weights", - "version": 1 - }, - "type": "search", - "updated_at": "2018-04-11T20:55:26.317Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "config:6.3.0", - "index": ".kibana", - "source": { - "config": { - "buildNum": 8467, - "defaultIndex": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c" - }, - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "config": "7.13.0" - }, - "references": [ - ], - "type": "config", - "updated_at": "2018-04-11T20:43:55.434Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:61c58ad0-3dd3-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":0,\"y\":0,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"w\":24,\"h\":15,\"x\":24,\"y\":0,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"}]", - "refreshInterval": { - "display": "Off", - "pause": false, - "value": 0 - }, - "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", - "timeRestore": true, - "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", - "title": "dashboard with filter", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - }, - { - "id": "50643b60-3dd3-11e8-b2b9-5d5dc1715159", - "name": "1:panel_1", - "type": "visualization" - }, - { - "id": "a16d1990-3dca-11e8-8660-4d65aa086b3c", - "name": "2:panel_2", - "type": "search" - } - ], - "type": "dashboard", - "updated_at": "2018-04-11T21:57:52.253Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:2ae34a60-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":0,\"y\":0,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":24,\"y\":0,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"}]", - "timeRestore": false, - "title": "couple panels", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - { - "id": "145ced90-3dcb-11e8-8660-4d65aa086b3c", - "name": "1:panel_1", - "type": "visualization" - }, - { - "id": "e2023110-3dcb-11e8-8660-4d65aa086b3c", - "name": "2:panel_2", - "type": "visualization" - } - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:03:29.670Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:76d03330-3dd3-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "and_descriptions_has_underscores", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "dashboard_with_underscores", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T21:58:27.555Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:9b780cd0-3dd3-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "* hi & $%!!@# 漢字 ^--=++[]{};'~`~<>?,./:\";'\\|\\\\ special chars", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:00:07.322Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:6c0b16e0-3dd3-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "dashboard-name-has-dashes", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T21:58:09.486Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:19523860-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "im empty too", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:03:00.198Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:14616b50-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "im empty", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:02:51.909Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:33bb8ad0-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"}]", - "timeRestore": false, - "title": "few panels", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - { - "id": "145ced90-3dcb-11e8-8660-4d65aa086b3c", - "name": "1:panel_1", - "type": "visualization" - }, - { - "id": "e2023110-3dcb-11e8-8660-4d65aa086b3c", - "name": "2:panel_2", - "type": "visualization" - }, - { - "id": "4b5d6ef0-3dcb-11e8-8660-4d65aa086b3c", - "name": "3:panel_3", - "type": "visualization" - } - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:03:44.509Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:60659030-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "zz 2", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:04:59.443Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:65227c00-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "zz 3", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:05:07.392Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:6803a2f0-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "zz 4", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:05:12.223Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:6b18f940-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "zz 5", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:05:17.396Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:6e12ff60-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "zz 6", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:05:22.390Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:4f0fd980-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "zz", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:04:30.360Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:3de0bda0-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "1", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:04:01.530Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:46c8b580-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "2", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:04:16.472Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:708fe640-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "zz 7", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:05:26.564Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:7b8d50a0-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "Hi i have a lot of words in my dashboard name! It's pretty long i wonder what it'll look like", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:05:45.002Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:7e42d3b0-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "bye", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:05:49.547Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:846988b0-3dd4-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[]", - "timeRestore": false, - "title": "last", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - ], - "type": "dashboard", - "updated_at": "2018-04-11T22:05:59.867Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:cbd3bc30-3e5a-11e8-9fc3-39e49624228e", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"weightLbs:<50\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":true,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"name.keyword\",\"value\":\"Fee Fee\",\"params\":{\"query\":\"Fee Fee\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"name.keyword\":{\"query\":\"Fee Fee\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":true,\"useMargins\":true,\"hidePanelTitles\":true}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"}]", - "timeRestore": false, - "title": "bug", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - }, - { - "id": "771b4f10-3e59-11e8-9fc3-39e49624228e", - "name": "1:panel_1", - "type": "visualization" - }, - { - "id": "befdb6b0-3e59-11e8-9fc3-39e49624228e", - "name": "2:panel_2", - "type": "visualization" - }, - { - "id": "4c0c3f90-3e5a-11e8-9fc3-39e49624228e", - "name": "3:panel_3", - "type": "visualization" - } - ], - "type": "dashboard", - "updated_at": "2018-04-12T14:07:12.243Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:5bac3a80-3e5b-11e8-9fc3-39e49624228e", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "dashboard with scripted filter, negated filter and query", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"weightLbs:<50\"},\"filter\":[{\"$state\":{\"store\":\"appState\"},\"meta\":{\"alias\":null,\"disabled\":false,\"key\":\"name.keyword\",\"negate\":true,\"params\":{\"query\":\"Fee Fee\",\"type\":\"phrase\"},\"type\":\"phrase\",\"value\":\"Fee Fee\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"name.keyword\":{\"query\":\"Fee Fee\",\"type\":\"phrase\"}}}},{\"$state\":{\"store\":\"appState\"},\"meta\":{\"alias\":\"is dog\",\"disabled\":false,\"field\":\"isDog\",\"key\":\"isDog\",\"negate\":false,\"params\":{\"value\":true},\"type\":\"phrase\",\"value\":\"true\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[1].meta.index\"},\"script\":{\"script\":{\"inline\":\"boolean compare(Supplier s, def v) {return s.get() == v;}compare(() -> { return doc['animal.keyword'].value == 'dog' }, params.value);\",\"lang\":\"painless\",\"params\":{\"value\":true}}}}],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":true,\"hidePanelTitles\":false,\"useMargins\":true}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"4\"},\"panelIndex\":\"4\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_4\"}]", - "refreshInterval": { - "display": "Off", - "pause": false, - "section": 0, - "value": 0 - }, - "timeFrom": "Wed Apr 12 2017 10:06:21 GMT-0400", - "timeRestore": true, - "timeTo": "Thu Apr 12 2018 10:06:21 GMT-0400", - "title": "filters", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - }, - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[1].meta.index", - "type": "index-pattern" - }, - { - "id": "771b4f10-3e59-11e8-9fc3-39e49624228e", - "name": "1:panel_1", - "type": "visualization" - }, - { - "id": "4c0c3f90-3e5a-11e8-9fc3-39e49624228e", - "name": "3:panel_3", - "type": "visualization" - }, - { - "id": "50643b60-3dd3-11e8-b2b9-5d5dc1715159", - "name": "4:panel_4", - "type": "visualization" - } - ], - "type": "dashboard", - "updated_at": "2018-04-12T14:11:13.576Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "index-pattern:f908c8e0-3e6d-11e8-bbb9-e15942d5d48c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "index-pattern": { - "fields": "[{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"activity level\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"barking level\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"breed\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"breed.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"size\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"size.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"trainability\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", - "title": "dogbreeds" - }, - "migrationVersion": { - "index-pattern": "7.11.0" - }, - "references": [ - ], - "type": "index-pattern", - "updated_at": "2018-04-12T16:24:29.357Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:5e085850-3e6e-11e8-bbb9-e15942d5d48c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "f908c8e0-3e6d-11e8-bbb9-e15942d5d48c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-12T16:27:17.973Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "non timebased line chart - dog data", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"non timebased line chart - dog data\",\"type\":\"line\",\"params\":{\"type\":\"line\",\"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,\"filter\":true},\"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\":\"Max trainability\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"line\",\"mode\":\"normal\",\"data\":{\"label\":\"Max trainability\",\"id\":\"1\"},\"valueAxis\":\"ValueAxis-1\",\"drawLinesBetweenPoints\":true,\"showCircles\":true},{\"show\":true,\"mode\":\"normal\",\"type\":\"line\",\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"data\":{\"id\":\"3\",\"label\":\"Max barking level\"},\"valueAxis\":\"ValueAxis-1\"},{\"show\":true,\"mode\":\"normal\",\"type\":\"line\",\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"data\":{\"id\":\"4\",\"label\":\"Max activity level\"},\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false,\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"isVislibVis\":true,\"detailedTooltip\":true,\"fittingFunction\":\"zero\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"max\",\"schema\":\"metric\",\"params\":{\"field\":\"trainability\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"breed.keyword\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}},{\"id\":\"3\",\"enabled\":true,\"type\":\"max\",\"schema\":\"metric\",\"params\":{\"field\":\"barking level\"}},{\"id\":\"4\",\"enabled\":true,\"type\":\"max\",\"schema\":\"metric\",\"params\":{\"field\":\"activity level\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:a5d56330-3e6e-11e8-bbb9-e15942d5d48c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "I have two visualizations that are created off a non time based index", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":0,\"y\":0,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":24,\"y\":0,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"}]", - "timeRestore": false, - "title": "Non time based", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - { - "id": "5e085850-3e6e-11e8-bbb9-e15942d5d48c", - "name": "1:panel_1", - "type": "visualization" - }, - { - "id": "8bc8d6c0-3e6e-11e8-bbb9-e15942d5d48c", - "name": "2:panel_2", - "type": "visualization" - } - ], - "type": "dashboard", - "updated_at": "2018-04-12T16:29:18.435Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:d2525040-3dcd-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "I have one of every visualization type since the last time I was created!", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":30,\"w\":24,\"h\":15,\"i\":\"5\"},\"panelIndex\":\"5\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_5\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":30,\"w\":24,\"h\":15,\"i\":\"6\"},\"panelIndex\":\"6\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_6\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":45,\"w\":24,\"h\":15,\"i\":\"7\"},\"panelIndex\":\"7\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_7\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":45,\"w\":24,\"h\":15,\"i\":\"8\"},\"panelIndex\":\"8\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_8\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":60,\"w\":24,\"h\":15,\"i\":\"9\"},\"panelIndex\":\"9\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_9\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":60,\"w\":24,\"h\":15,\"i\":\"10\"},\"panelIndex\":\"10\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_10\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":75,\"w\":24,\"h\":15,\"i\":\"11\"},\"panelIndex\":\"11\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_11\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":75,\"w\":24,\"h\":15,\"i\":\"12\"},\"panelIndex\":\"12\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_12\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":90,\"w\":24,\"h\":15,\"i\":\"13\"},\"panelIndex\":\"13\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_13\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":105,\"w\":24,\"h\":15,\"i\":\"15\"},\"panelIndex\":\"15\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_15\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":105,\"w\":24,\"h\":15,\"i\":\"16\"},\"panelIndex\":\"16\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_16\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":120,\"w\":24,\"h\":15,\"i\":\"17\"},\"panelIndex\":\"17\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_17\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":120,\"w\":24,\"h\":15,\"i\":\"18\"},\"panelIndex\":\"18\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_18\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":135,\"w\":24,\"h\":15,\"i\":\"19\"},\"panelIndex\":\"19\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_19\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":135,\"w\":24,\"h\":15,\"i\":\"20\"},\"panelIndex\":\"20\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_20\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":150,\"w\":24,\"h\":15,\"i\":\"21\"},\"panelIndex\":\"21\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_21\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":150,\"w\":24,\"h\":15,\"i\":\"22\"},\"panelIndex\":\"22\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_22\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":165,\"w\":24,\"h\":15,\"i\":\"23\"},\"panelIndex\":\"23\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_23\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":24,\"y\":165,\"w\":24,\"h\":15,\"i\":\"24\"},\"panelIndex\":\"24\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_24\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":0,\"y\":180,\"w\":24,\"h\":15,\"i\":\"25\"},\"panelIndex\":\"25\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_25\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":24,\"y\":180,\"w\":24,\"h\":15,\"i\":\"26\"},\"panelIndex\":\"26\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_26\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":195,\"w\":24,\"h\":15,\"i\":\"27\"},\"panelIndex\":\"27\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_27\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":195,\"w\":24,\"h\":15,\"i\":\"28\"},\"panelIndex\":\"28\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_28\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":210,\"w\":24,\"h\":15,\"i\":\"29\"},\"panelIndex\":\"29\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_29\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":24,\"y\":210,\"i\":\"30\"},\"panelIndex\":\"30\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_30\"}]", - "refreshInterval": { - "display": "Off", - "pause": false, - "value": 0 - }, - "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", - "timeRestore": true, - "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", - "title": "dashboard with everything", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - { - "id": "e6140540-3dca-11e8-8660-4d65aa086b3c", - "name": "1:panel_1", - "type": "visualization" - }, - { - "id": "3525b840-3dcb-11e8-8660-4d65aa086b3c", - "name": "2:panel_2", - "type": "visualization" - }, - { - "id": "4b5d6ef0-3dcb-11e8-8660-4d65aa086b3c", - "name": "3:panel_3", - "type": "visualization" - }, - { - "id": "ffa2e0c0-3dcb-11e8-8660-4d65aa086b3c", - "name": "5:panel_5", - "type": "visualization" - }, - { - "id": "e2023110-3dcb-11e8-8660-4d65aa086b3c", - "name": "6:panel_6", - "type": "visualization" - }, - { - "id": "145ced90-3dcb-11e8-8660-4d65aa086b3c", - "name": "7:panel_7", - "type": "visualization" - }, - { - "id": "2d1b1620-3dcd-11e8-8660-4d65aa086b3c", - "name": "8:panel_8", - "type": "visualization" - }, - { - "id": "42535e30-3dcd-11e8-8660-4d65aa086b3c", - "name": "9:panel_9", - "type": "visualization" - }, - { - "id": "42535e30-3dcd-11e8-8660-4d65aa086b3c", - "name": "10:panel_10", - "type": "visualization" - }, - { - "id": "4c0f47e0-3dcd-11e8-8660-4d65aa086b3c", - "name": "11:panel_11", - "type": "visualization" - }, - { - "id": "11ae2bd0-3dcc-11e8-8660-4d65aa086b3c", - "name": "12:panel_12", - "type": "visualization" - }, - { - "id": "3fe22200-3dcb-11e8-8660-4d65aa086b3c", - "name": "13:panel_13", - "type": "visualization" - }, - { - "id": "78803be0-3dcd-11e8-8660-4d65aa086b3c", - "name": "15:panel_15", - "type": "visualization" - }, - { - "id": "b92ae920-3dcc-11e8-8660-4d65aa086b3c", - "name": "16:panel_16", - "type": "visualization" - }, - { - "id": "e4d8b430-3dcc-11e8-8660-4d65aa086b3c", - "name": "17:panel_17", - "type": "visualization" - }, - { - "id": "f81134a0-3dcc-11e8-8660-4d65aa086b3c", - "name": "18:panel_18", - "type": "visualization" - }, - { - "id": "cc43fab0-3dcc-11e8-8660-4d65aa086b3c", - "name": "19:panel_19", - "type": "visualization" - }, - { - "id": "02a2e4e0-3dcd-11e8-8660-4d65aa086b3c", - "name": "20:panel_20", - "type": "visualization" - }, - { - "id": "df815d20-3dcc-11e8-8660-4d65aa086b3c", - "name": "21:panel_21", - "type": "visualization" - }, - { - "id": "c40f4d40-3dcc-11e8-8660-4d65aa086b3c", - "name": "22:panel_22", - "type": "visualization" - }, - { - "id": "7fda8ee0-3dcd-11e8-8660-4d65aa086b3c", - "name": "23:panel_23", - "type": "visualization" - }, - { - "id": "a16d1990-3dca-11e8-8660-4d65aa086b3c", - "name": "24:panel_24", - "type": "search" - }, - { - "id": "be5accf0-3dca-11e8-8660-4d65aa086b3c", - "name": "25:panel_25", - "type": "search" - }, - { - "id": "ca5ada40-3dca-11e8-8660-4d65aa086b3c", - "name": "26:panel_26", - "type": "search" - }, - { - "id": "771b4f10-3e59-11e8-9fc3-39e49624228e", - "name": "27:panel_27", - "type": "visualization" - }, - { - "id": "5e085850-3e6e-11e8-bbb9-e15942d5d48c", - "name": "28:panel_28", - "type": "visualization" - }, - { - "id": "8bc8d6c0-3e6e-11e8-bbb9-e15942d5d48c", - "name": "29:panel_29", - "type": "visualization" - }, - { - "id": "befdb6b0-3e59-11e8-9fc3-39e49624228e", - "name": "30:panel_30", - "type": "visualization" - } - ], - "type": "dashboard", - "updated_at": "2018-04-16T16:05:02.915Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:d2525040-3dcd-11e8-8660-4d65aa086b3b", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "I have one of every visualization type since the last time I was created!", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"}]", - "refreshInterval": { - "display": "Off", - "pause": false, - "value": 0 - }, - "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", - "timeRestore": true, - "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", - "title": "dashboard with table", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - { - "id": "4b5d6ef0-3dcb-11e8-8660-4d65aa086b3c", - "name": "3:panel_3", - "type": "visualization" - } - ], - "type": "dashboard", - "updated_at": "2018-04-16T16:05:02.915Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:29bd0240-4197-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - }, - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-16T16:56:53.092Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"geo.src\",\"value\":\"CN\",\"params\":{\"query\":\"CN\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"geo.src\":{\"query\":\"CN\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"query\":\"bytes >= 10000\",\"language\":\"kuery\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Kuery: pie bytes with kuery and filter", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Kuery: pie bytes with kuery and filter\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"bytes\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":10,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "index-pattern:0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "index-pattern": { - "fieldFormatMap": "{\"machine.ram\":{\"id\":\"number\",\"params\":{\"pattern\":\"0,0.[000] b\"}}}", - "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", - "timeFieldName": "@timestamp", - "title": "logstash-*" - }, - "migrationVersion": { - "index-pattern": "7.11.0" - }, - "references": [ - ], - "type": "index-pattern", - "updated_at": "2018-04-16T16:57:12.263Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "search:55d37a30-4197-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "search": "7.9.3" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - }, - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - } - ], - "search": { - "columns": [ - "agent", - "bytes", - "clientip" - ], - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"query\":\"clientip : 73.14.212.83\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"range\",\"key\":\"bytes\",\"value\":\"100 to 1,000\",\"params\":{\"gte\":100,\"lt\":1000},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"range\":{\"bytes\":{\"gte\":100,\"lt\":1000}},\"$state\":{\"store\":\"appState\"}}],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "sort": [ - [ - "@timestamp", - "desc" - ] - ], - "title": "Bytes and kuery in saved search with filter", - "version": 1 - }, - "type": "search", - "updated_at": "2018-04-16T16:58:07.059Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:b60de070-4197-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "Bytes bytes and more bytes", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":15,\"w\":17,\"h\":8,\"i\":\"4\"},\"panelIndex\":\"4\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_4\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":30,\"w\":18,\"h\":13,\"i\":\"5\"},\"panelIndex\":\"5\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_5\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":37,\"w\":24,\"h\":12,\"i\":\"6\"},\"panelIndex\":\"6\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_6\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":18,\"y\":30,\"w\":9,\"h\":7,\"i\":\"7\"},\"panelIndex\":\"7\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_7\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":28,\"y\":23,\"w\":15,\"h\":13,\"i\":\"8\"},\"panelIndex\":\"8\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_8\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":43,\"w\":24,\"h\":15,\"i\":\"9\"},\"panelIndex\":\"9\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_9\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":49,\"w\":18,\"h\":12,\"i\":\"10\"},\"panelIndex\":\"10\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_10\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":58,\"w\":24,\"h\":15,\"i\":\"11\"},\"panelIndex\":\"11\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_11\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":61,\"w\":5,\"h\":4,\"i\":\"12\"},\"panelIndex\":\"12\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_12\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":73,\"w\":17,\"h\":6,\"i\":\"13\"},\"panelIndex\":\"13\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_13\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":65,\"w\":24,\"h\":15,\"i\":\"14\"},\"panelIndex\":\"14\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_14\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":79,\"w\":24,\"h\":6,\"i\":\"15\"},\"panelIndex\":\"15\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_15\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":80,\"w\":24,\"h\":15,\"i\":\"16\"},\"panelIndex\":\"16\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_16\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":85,\"w\":13,\"h\":11,\"i\":\"17\"},\"panelIndex\":\"17\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_17\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":24,\"y\":95,\"w\":23,\"h\":11,\"i\":\"18\"},\"panelIndex\":\"18\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_18\"}]", - "refreshInterval": { - "display": "Off", - "pause": false, - "value": 0 - }, - "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", - "timeRestore": true, - "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", - "title": "All about those bytes", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - { - "id": "7ff2c4c0-4191-11e8-bb13-d53698fb349a", - "name": "1:panel_1", - "type": "visualization" - }, - { - "id": "03d2afd0-4192-11e8-bb13-d53698fb349a", - "name": "2:panel_2", - "type": "visualization" - }, - { - "id": "63983430-4192-11e8-bb13-d53698fb349a", - "name": "3:panel_3", - "type": "visualization" - }, - { - "id": "0ca8c600-4195-11e8-bb13-d53698fb349a", - "name": "4:panel_4", - "type": "visualization" - }, - { - "id": "c10c6b00-4191-11e8-bb13-d53698fb349a", - "name": "5:panel_5", - "type": "visualization" - }, - { - "id": "760a9060-4190-11e8-bb13-d53698fb349a", - "name": "6:panel_6", - "type": "visualization" - }, - { - "id": "1dcdfe30-4192-11e8-bb13-d53698fb349a", - "name": "7:panel_7", - "type": "visualization" - }, - { - "id": "584c0300-4191-11e8-bb13-d53698fb349a", - "name": "8:panel_8", - "type": "visualization" - }, - { - "id": "b3e70d00-4190-11e8-bb13-d53698fb349a", - "name": "9:panel_9", - "type": "visualization" - }, - { - "id": "df72ad40-4194-11e8-bb13-d53698fb349a", - "name": "10:panel_10", - "type": "visualization" - }, - { - "id": "9bebe980-4192-11e8-bb13-d53698fb349a", - "name": "11:panel_11", - "type": "visualization" - }, - { - "id": "9fb4c670-4194-11e8-bb13-d53698fb349a", - "name": "12:panel_12", - "type": "visualization" - }, - { - "id": "35417e50-4194-11e8-bb13-d53698fb349a", - "name": "13:panel_13", - "type": "visualization" - }, - { - "id": "039e4770-4194-11e8-bb13-d53698fb349a", - "name": "14:panel_14", - "type": "visualization" - }, - { - "id": "76c7f020-4194-11e8-bb13-d53698fb349a", - "name": "15:panel_15", - "type": "visualization" - }, - { - "id": "8090dcb0-4195-11e8-bb13-d53698fb349a", - "name": "16:panel_16", - "type": "visualization" - }, - { - "id": "29bd0240-4197-11e8-bb13-d53698fb349a", - "name": "17:panel_17", - "type": "visualization" - }, - { - "id": "55d37a30-4197-11e8-bb13-d53698fb349a", - "name": "18:panel_18", - "type": "search" - } - ], - "type": "dashboard", - "updated_at": "2018-04-16T17:00:48.503Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:78803be0-3dcd-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:32.127Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Rendering Test: tag cloud", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: tag cloud\",\"type\":\"tagcloud\",\"params\":{\"scale\":\"linear\",\"orientation\":\"single\",\"minFontSize\":18,\"maxFontSize\":72,\"showLabel\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"geo.src\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:3fe22200-3dcb-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:32.130Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Rendering Test: pie", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: pie\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"bytes\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:11ae2bd0-3dcc-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:32.133Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Rendering Test: metric", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: metric\",\"type\":\"metric\",\"params\":{\"addTooltip\":true,\"addLegend\":false,\"type\":\"metric\",\"metric\":{\"percentageMode\":false,\"useRanges\":false,\"colorSchema\":\"Green to Red\",\"metricColorMode\":\"None\",\"colorsRange\":[{\"from\":0,\"to\":10000}],\"labels\":{\"show\":true},\"invertColors\":false,\"style\":{\"bgFill\":\"#000\",\"bgColor\":false,\"labelColor\":false,\"subText\":\"\",\"fontSize\":60}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"cardinality\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:145ced90-3dcb-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:32.134Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Rendering Test: heatmap", - "uiStateJSON": "{\"vis\":{\"defaultColors\":{\"0 - 15\":\"rgb(247,252,245)\",\"15 - 30\":\"rgb(199,233,192)\",\"30 - 45\":\"rgb(116,196,118)\",\"45 - 60\":\"rgb(35,139,69)\"}}}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: heatmap\",\"type\":\"heatmap\",\"params\":{\"type\":\"heatmap\",\"addTooltip\":true,\"addLegend\":true,\"enableHover\":false,\"legendPosition\":\"right\",\"times\":[],\"colorsNumber\":4,\"colorSchema\":\"Greens\",\"setColorRange\":false,\"colorsRange\":[],\"invertColors\":false,\"percentageMode\":false,\"valueAxes\":[{\"show\":false,\"id\":\"ValueAxis-1\",\"type\":\"value\",\"scale\":{\"type\":\"linear\",\"defaultYExtents\":false},\"labels\":{\"show\":false,\"rotate\":0,\"overwriteColor\":false,\"color\":\"#555\"}}]},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"bytes\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"geo.src\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:e2023110-3dcb-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:32.135Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Rendering Test: guage", - "uiStateJSON": "{\"vis\":{\"colors\":{\"0 - 50000\":\"#EF843C\",\"75000 - 10000000\":\"#3F6833\"},\"defaultColors\":{\"0 - 5000000\":\"rgb(0,104,55)\",\"50000000 - 74998990099\":\"rgb(165,0,38)\"}}}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: guage\",\"type\":\"gauge\",\"params\":{\"addLegend\":true,\"addTooltip\":true,\"gauge\":{\"backStyle\":\"Full\",\"colorSchema\":\"Green to Red\",\"colorsRange\":[{\"from\":0,\"to\":5000000},{\"from\":50000000,\"to\":74998990099}],\"extendRange\":true,\"gaugeColorMode\":\"Labels\",\"gaugeStyle\":\"Full\",\"gaugeType\":\"Arc\",\"invertColors\":false,\"labels\":{\"color\":\"black\",\"show\":true},\"orientation\":\"vertical\",\"percentageMode\":false,\"scale\":{\"color\":\"#333\",\"labels\":false,\"show\":true},\"style\":{\"bgColor\":false,\"bgFill\":\"#eee\",\"bgMask\":false,\"bgWidth\":0.9,\"fontSize\":60,\"labelColor\":true,\"mask\":false,\"maskBars\":50,\"subText\":\"\",\"width\":0.9},\"type\":\"meter\",\"alignment\":\"horizontal\"},\"isDisplayWarning\":false,\"type\":\"gauge\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"avg\",\"schema\":\"metric\",\"params\":{\"field\":\"machine.ram\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:b92ae920-3dcc-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:31.110Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Rendering Test: timelion", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: timelion\",\"type\":\"timelion\",\"params\":{\"expression\":\".es(*, metric=avg:bytes, split=ip:5)\",\"interval\":\"auto\"},\"aggs\":[]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:e4d8b430-3dcc-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:31.106Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Rendering Test: tsvb-guage", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: tsvb-guage\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"gauge\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_color_rules\":[{\"id\":\"e0be22e0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_width\":10,\"gauge_inner_width\":10,\"gauge_style\":\"half\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:4c0f47e0-3dcd-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:31.111Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Rendering Test: markdown", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: markdown\",\"type\":\"markdown\",\"params\":{\"fontSize\":20,\"openLinksInNewTab\":false,\"markdown\":\"I'm a markdown!\"},\"aggs\":[]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:2d1b1620-3dcd-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "control_0_index_pattern", - "type": "index-pattern" - }, - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "control_1_index_pattern", - "type": "index-pattern" - }, - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "control_2_index_pattern", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:31.123Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Rendering Test: input control", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: input control\",\"type\":\"input_control_vis\",\"params\":{\"controls\":[{\"id\":\"1523481142694\",\"fieldName\":\"bytes\",\"parent\":\"\",\"label\":\"Bytes Input List\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":5,\"order\":\"desc\"},\"indexPatternRefName\":\"control_0_index_pattern\"},{\"id\":\"1523481163654\",\"fieldName\":\"bytes\",\"parent\":\"\",\"label\":\"Bytes range\",\"type\":\"range\",\"options\":{\"decimalPlaces\":0,\"step\":1},\"indexPatternRefName\":\"control_1_index_pattern\"},{\"id\":\"1523481176519\",\"fieldName\":\"sound.keyword\",\"parent\":\"\",\"label\":\"Animal sounds\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":5,\"order\":\"desc\"},\"indexPatternRefName\":\"control_2_index_pattern\"}],\"updateFiltersOnChange\":false,\"useTimeFilter\":false,\"pinFilters\":false},\"aggs\":[]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:8bc8d6c0-3e6e-11e8-bbb9-e15942d5d48c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "f908c8e0-3e6d-11e8-bbb9-e15942d5d48c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - }, - { - "id": "f908c8e0-3e6d-11e8-bbb9-e15942d5d48c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:31.173Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"size.keyword\",\"value\":\"extra large\",\"params\":{\"query\":\"extra large\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"size.keyword\":{\"query\":\"extra large\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Rendering Test: non timebased line chart - dog data - with filter", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"aggs\":[{\"enabled\":true,\"id\":\"1\",\"params\":{\"field\":\"trainability\"},\"schema\":\"metric\",\"type\":\"max\"},{\"enabled\":true,\"id\":\"2\",\"params\":{\"field\":\"breed.keyword\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"order\":\"desc\",\"orderBy\":\"1\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"size\":5},\"schema\":\"segment\",\"type\":\"terms\"},{\"enabled\":true,\"id\":\"3\",\"params\":{\"field\":\"barking level\"},\"schema\":\"metric\",\"type\":\"max\"},{\"enabled\":true,\"id\":\"4\",\"params\":{\"field\":\"activity level\"},\"schema\":\"metric\",\"type\":\"max\"}],\"params\":{\"addLegend\":true,\"addTimeMarker\":false,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"labels\":{\"show\":true,\"truncate\":100,\"filter\":true},\"position\":\"bottom\",\"scale\":{\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{},\"type\":\"category\"}],\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"legendPosition\":\"right\",\"seriesParams\":[{\"data\":{\"id\":\"1\",\"label\":\"Max trainability\"},\"drawLinesBetweenPoints\":true,\"mode\":\"normal\",\"show\":\"true\",\"showCircles\":true,\"type\":\"line\",\"valueAxis\":\"ValueAxis-1\"},{\"data\":{\"id\":\"3\",\"label\":\"Max barking level\"},\"drawLinesBetweenPoints\":true,\"mode\":\"normal\",\"show\":true,\"showCircles\":true,\"type\":\"line\",\"valueAxis\":\"ValueAxis-1\"},{\"data\":{\"id\":\"4\",\"label\":\"Max activity level\"},\"drawLinesBetweenPoints\":true,\"mode\":\"normal\",\"show\":true,\"showCircles\":true,\"type\":\"line\",\"valueAxis\":\"ValueAxis-1\"}],\"times\":[],\"type\":\"line\",\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"labels\":{\"filter\":false,\"rotate\":0,\"show\":true,\"truncate\":100},\"name\":\"LeftAxis-1\",\"position\":\"left\",\"scale\":{\"mode\":\"normal\",\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"Max trainability\"},\"type\":\"value\"}],\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"isVislibVis\":true,\"detailedTooltip\":true,\"fittingFunction\":\"zero\"},\"title\":\"Rendering Test: non timebased line chart - dog data - with filter\",\"type\":\"line\"}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:42535e30-3dcd-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "control_0_index_pattern", - "type": "index-pattern" - }, - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "control_1_index_pattern", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:31.124Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Rendering Test: input control parent", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: input control parent\",\"type\":\"input_control_vis\",\"params\":{\"controls\":[{\"id\":\"1523481216736\",\"fieldName\":\"animal.keyword\",\"parent\":\"\",\"label\":\"Animal type\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":5,\"order\":\"desc\"},\"indexPatternRefName\":\"control_0_index_pattern\"},{\"id\":\"1523481176519\",\"fieldName\":\"sound.keyword\",\"parent\":\"1523481216736\",\"label\":\"Animal sounds\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":5,\"order\":\"desc\"},\"indexPatternRefName\":\"control_1_index_pattern\"}],\"updateFiltersOnChange\":false,\"useTimeFilter\":false,\"pinFilters\":false},\"aggs\":[]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:7fda8ee0-3dcd-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:30.344Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Rendering Test: vega", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: vega\",\"type\":\"vega\",\"params\":{\"spec\":\"{\\n/*\\n\\nWelcome to Vega visualizations. Here you can design your own dataviz from scratch using a declarative language called Vega, or its simpler form Vega-Lite. In Vega, you have the full control of what data is loaded, even from multiple sources, how that data is transformed, and what visual elements are used to show it. Use help icon to view Vega examples, tutorials, and other docs. Use the wrench icon to reformat this text, or to remove comments.\\n\\nThis example graph shows the document count in all indexes in the current time range. You might need to adjust the time filter in the upper right corner.\\n*/\\n\\n $schema: https://vega.github.io/schema/vega-lite/v2.json\\n title: Event counts from all indexes\\n\\n // Define the data source\\n data: {\\n url: {\\n/*\\nAn object instead of a string for the \\\"url\\\" param is treated as an Elasticsearch query. Anything inside this object is not part of the Vega language, but only understood by Kibana and Elasticsearch server. This query counts the number of documents per time interval, assuming you have a @timestamp field in your data.\\n\\nKibana has a special handling for the fields surrounded by \\\"%\\\". They are processed before the the query is sent to Elasticsearch. This way the query becomes context aware, and can use the time range and the dashboard filters.\\n*/\\n\\n // Apply dashboard context filters when set\\n %context%: true\\n // Filter the time picker (upper right corner) with this field\\n %timefield%: @timestamp\\n\\n/*\\nSee .search() documentation for : https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html#api-search\\n*/\\n\\n // Which index to search\\n index: _all\\n // Aggregate data by the time field into time buckets, counting the number of documents in each bucket.\\n body: {\\n aggs: {\\n time_buckets: {\\n date_histogram: {\\n // Use date histogram aggregation on @timestamp field\\n field: @timestamp\\n // The interval value will depend on the daterange picker (true), or use an integer to set an approximate bucket count\\n interval: {%autointerval%: true}\\n // Make sure we get an entire range, even if it has no data\\n extended_bounds: {\\n // Use the current time range's start and end\\n min: {%timefilter%: \\\"min\\\"}\\n max: {%timefilter%: \\\"max\\\"}\\n }\\n // Use this for linear (e.g. line, area) graphs. Without it, empty buckets will not show up\\n min_doc_count: 0\\n }\\n }\\n }\\n // Speed up the response by only including aggregation results\\n size: 0\\n }\\n }\\n/*\\nElasticsearch will return results in this format:\\n\\naggregations: {\\n time_buckets: {\\n buckets: [\\n {\\n key_as_string: 2015-11-30T22:00:00.000Z\\n key: 1448920800000\\n doc_count: 0\\n },\\n {\\n key_as_string: 2015-11-30T23:00:00.000Z\\n key: 1448924400000\\n doc_count: 0\\n }\\n ...\\n ]\\n }\\n}\\n\\nFor our graph, we only need the list of bucket values. Use the format.property to discard everything else.\\n*/\\n format: {property: \\\"aggregations.time_buckets.buckets\\\"}\\n }\\n\\n // \\\"mark\\\" is the graphics element used to show our data. Other mark values are: area, bar, circle, line, point, rect, rule, square, text, and tick. See https://vega.github.io/vega-lite/docs/mark.html\\n mark: line\\n\\n // \\\"encoding\\\" tells the \\\"mark\\\" what data to use and in what way. See https://vega.github.io/vega-lite/docs/encoding.html\\n encoding: {\\n x: {\\n // The \\\"key\\\" value is the timestamp in milliseconds. Use it for X axis.\\n field: key\\n type: temporal\\n axis: {title: false} // Customize X axis format\\n }\\n y: {\\n // The \\\"doc_count\\\" is the count per bucket. Use it for Y axis.\\n field: doc_count\\n type: quantitative\\n axis: {title: \\\"Document count\\\"}\\n }\\n }\\n}\\n\"},\"aggs\":[]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:02a2e4e0-3dcd-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:30.351Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Rendering Test: tsvb-table", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: tsvb-table\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"table\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_color_rules\":[{\"id\":\"e0be22e0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_width\":10,\"gauge_inner_width\":10,\"gauge_style\":\"half\",\"markdown\":\"\\nHi Avg last bytes: {{ average_of_bytes.last.raw }}\",\"pivot_id\":\"bytes\",\"pivot_label\":\"Hello\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:f81134a0-3dcc-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:30.355Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Rendering Test: tsvb-markdown", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: tsvb-markdown\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"markdown\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_color_rules\":[{\"id\":\"e0be22e0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_width\":10,\"gauge_inner_width\":10,\"gauge_style\":\"half\",\"markdown\":\"\\nHi Avg last bytes: {{ average_of_bytes.last.raw }}\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:df815d20-3dcc-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:30.349Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Rendering Test: tsvb-topn", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: tsvb-topn\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"top_n\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:cc43fab0-3dcc-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:30.353Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Rendering Test: tsvb-metric", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: tsvb-metric\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"metric\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"sum_of_squares\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:c40f4d40-3dcc-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:30.347Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Rendering Test: tsvb-ts", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: tsvb-ts\",\"type\":\"metrics\",\"params\":{\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"timeseries\",\"series\":[{\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"count\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"use_kibana_indexes\":false},\"aggs\":[]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:ffa2e0c0-3dcb-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:33.153Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Rendering Test: goal", - "uiStateJSON": "{\"vis\":{\"defaultColors\":{\"0 - 100\":\"rgb(0,104,55)\"}}}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: goal\",\"type\":\"goal\",\"params\":{\"addTooltip\":true,\"addLegend\":false,\"isDisplayWarning\":false,\"type\":\"gauge\",\"gauge\":{\"verticalSplit\":false,\"autoExtend\":false,\"percentageMode\":true,\"gaugeType\":\"Arc\",\"gaugeStyle\":\"Full\",\"backStyle\":\"Full\",\"orientation\":\"vertical\",\"useRanges\":false,\"colorSchema\":\"Green to Red\",\"gaugeColorMode\":\"None\",\"colorsRange\":[{\"from\":0,\"to\":4000}],\"invertColors\":false,\"labels\":{\"show\":true,\"color\":\"black\"},\"scale\":{\"show\":false,\"labels\":false,\"color\":\"#333\",\"width\":2},\"type\":\"meter\",\"style\":{\"bgFill\":\"#000\",\"bgColor\":false,\"labelColor\":false,\"subText\":\"\",\"fontSize\":60}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"geo.src\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":2,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:4b5d6ef0-3dcb-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:33.162Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Rendering Test: datatable", - "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: datatable\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMeticsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\",\"showToolbar\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"clientip\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:3525b840-3dcb-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:33.163Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Rendering Test: bar", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: bar\",\"type\":\"horizontal_bar\",\"params\":{\"type\":\"histogram\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":200},\"title\":{}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":75,\"filter\":true,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":true,\"type\":\"histogram\",\"mode\":\"normal\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"valueAxis\":\"ValueAxis-1\",\"drawLinesBetweenPoints\":true,\"showCircles\":true}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false,\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"isVislibVis\":true,\"detailedTooltip\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1,\"extended_bounds\":{}}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"geo.src\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":3,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:e6140540-3dca-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - }, - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:33.165Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[{\"meta\":{\"negate\":true,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"geo.src\",\"value\":\"CN\",\"params\":{\"query\":\"CN\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"geo.src\":{\"query\":\"CN\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Rendering Test: area with not filter", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: area with not filter\",\"type\":\"area\",\"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,\"filter\":true},\"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\":\"Count\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false,\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"isVislibVis\":true,\"detailedTooltip\":true,\"fittingFunction\":\"zero\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1,\"extended_bounds\":{}}},{\"id\":\"3\",\"enabled\":true,\"type\":\"filters\",\"schema\":\"group\",\"params\":{\"filters\":[{\"input\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"label\":\"\"},{\"input\":{\"query\":\"bytes:>10\",\"language\":\"lucene\"}}]}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:4c0c3f90-3e5a-11e8-9fc3-39e49624228e", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - }, - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:33.166Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[{\"meta\":{\"field\":\"isDog\",\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"isDog\",\"value\":\"true\",\"params\":{\"value\":true},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"script\":{\"script\":{\"inline\":\"boolean compare(Supplier s, def v) {return s.get() == v;}compare(() -> { return doc['animal.keyword'].value == 'dog' }, params.value);\",\"lang\":\"painless\",\"params\":{\"value\":true}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"query\":\"weightLbs:>40\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Rendering Test: scripted filter and query", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: scripted filter and query\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"sound.keyword\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:50643b60-3dd3-11e8-b2b9-5d5dc1715159", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:34.195Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Rendering Test: animal sounds pie", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: animal sounds pie\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"sound.keyword\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:771b4f10-3e59-11e8-9fc3-39e49624228e", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "a16d1990-3dca-11e8-8660-4d65aa086b3c", - "name": "search_0", - "type": "search" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:34.200Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" - }, - "savedSearchRefName": "search_0", - "title": "Rendering Test: animal weights linked to search", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: animal weights linked to search\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"name.keyword\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:76c7f020-4194-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:34.583Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Filter Bytes Test: tsvb top n with bytes filter", - "uiStateJSON": "{}", - "version": 1, - "visState":"{\"title\":\"Filter Bytes Test: tsvb top n with bytes filter\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"top_n\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"filters\",\"metrics\":[{\"id\":\"482d6560-4194-11e8-a461-7d278185cba4\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"terms_field\":\"clientip\",\"filter\":{\"query\":\"Filter Bytes Test:>1000\",\"language\":\"lucene\"},\"override_index_pattern\":0,\"series_index_pattern\":\"logstash-*\",\"series_time_field\":\"utc_time\",\"series_interval\":\"1m\",\"value_template\":\"\",\"split_filters\":[{\"filter\":{\"query\":\"Filter Bytes Test:>100\",\"language\":\"lucene\"},\"label\":\"\",\"color\":\"#68BC00\",\"id\":\"39a107e0-4194-11e8-a461-7d278185cba4\"}],\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"4fd5b150-4194-11e8-a461-7d278185cba4\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"4fd5b151-4194-11e8-a461-7d278185cba4\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"Filter Bytes Test:>3000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"06893260-4194-11e8-a461-7d278185cba4\"}],\"bar_color_rules\":[{\"id\":\"36a0e740-4194-11e8-a461-7d278185cba4\"}],\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:0ca8c600-4195-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "control_0_index_pattern", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:35.229Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Filter Bytes Test: input control with filter", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Filter Bytes Test: input control with filter\",\"type\":\"input_control_vis\",\"params\":{\"controls\":[{\"id\":\"1523896850250\",\"fieldName\":\"bytes\",\"parent\":\"\",\"label\":\"Byte Options\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":10,\"order\":\"desc\"},\"indexPatternRefName\":\"control_0_index_pattern\"}],\"updateFiltersOnChange\":false,\"useTimeFilter\":false,\"pinFilters\":false},\"aggs\":[]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:039e4770-4194-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:35.220Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Filter Bytes Test: tsvb time series with bytes filter split by clientip", - "uiStateJSON": "{}", - "version": 1, - "visState":"{\"title\":\"Filter Bytes Test: tsvb time series with bytes filter split by clientip\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"timeseries\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"terms\",\"metrics\":[{\"value\":\"\",\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"sum\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"terms_field\":\"clientip\",\"filter\":{\"query\":\"Filter Bytes Test:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"use_kibana_indexes\":false,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:760a9060-4190-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - }, - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:35.235Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"geo.src\",\"value\":\"US\",\"params\":{\"query\":\"US\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"geo.src\":{\"query\":\"US\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Filter Bytes Test: max bytes in US - area chart with filter", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Filter Bytes Test: max bytes in US - area chart with filter\",\"type\":\"area\",\"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,\"filter\":true},\"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\":\"Max bytes\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Max bytes\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false,\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"isVislibVis\":true,\"detailedTooltip\":true,\"fittingFunction\":\"zero\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"max\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1,\"extended_bounds\":{}}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:b3e70d00-4190-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:35.236Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Filter Bytes Test: standard deviation heatmap with other bucket", - "uiStateJSON": "{\"vis\":{\"defaultColors\":{\"-4,000 - 1,000\":\"rgb(247,252,245)\",\"1,000 - 6,000\":\"rgb(199,233,192)\",\"6,000 - 11,000\":\"rgb(116,196,118)\",\"11,000 - 16,000\":\"rgb(35,139,69)\"}}}", - "version": 1, - "visState": "{\"title\":\"Filter Bytes Test: standard deviation heatmap with other bucket\",\"type\":\"heatmap\",\"params\":{\"type\":\"heatmap\",\"addTooltip\":true,\"addLegend\":true,\"enableHover\":false,\"legendPosition\":\"right\",\"times\":[],\"colorsNumber\":4,\"colorSchema\":\"Greens\",\"setColorRange\":false,\"colorsRange\":[],\"invertColors\":false,\"percentageMode\":false,\"valueAxes\":[{\"show\":false,\"id\":\"ValueAxis-1\",\"type\":\"value\",\"scale\":{\"type\":\"linear\",\"defaultYExtents\":false},\"labels\":{\"show\":false,\"rotate\":0,\"overwriteColor\":false,\"color\":\"#555\"}}]},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"std_dev\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"geo.src\",\"otherBucket\":true,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":10,\"order\":\"desc\",\"orderBy\":\"_term\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:c10c6b00-4191-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:36.267Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Filter Bytes Test: max bytes guage percent mode", - "uiStateJSON": "{\"vis\":{\"defaultColors\":{\"0 - 1\":\"rgb(0,104,55)\",\"1 - 15\":\"rgb(255,255,190)\",\"15 - 100\":\"rgb(165,0,38)\"}}}", - "version": 1, - "visState": "{\"title\":\"Filter Bytes Test: max bytes guage percent mode\",\"type\":\"gauge\",\"params\":{\"type\":\"gauge\",\"addTooltip\":true,\"addLegend\":true,\"isDisplayWarning\":false,\"gauge\":{\"extendRange\":true,\"percentageMode\":true,\"gaugeType\":\"Arc\",\"gaugeStyle\":\"Full\",\"backStyle\":\"Full\",\"orientation\":\"vertical\",\"colorSchema\":\"Green to Red\",\"gaugeColorMode\":\"Labels\",\"colorsRange\":[{\"from\":0,\"to\":500},{\"from\":500,\"to\":7500},{\"from\":7500,\"to\":50000}],\"invertColors\":false,\"labels\":{\"show\":true,\"color\":\"black\"},\"scale\":{\"show\":true,\"labels\":false,\"color\":\"#333\"},\"type\":\"meter\",\"style\":{\"bgWidth\":0.9,\"width\":0.9,\"mask\":false,\"bgMask\":false,\"maskBars\":50,\"bgFill\":\"#eee\",\"bgColor\":false,\"subText\":\"Im subtext\",\"fontSize\":60,\"labelColor\":true},\"alignment\":\"horizontal\"}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"max\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:03d2afd0-4192-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:36.269Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Filter Bytes Test: Goal unique count", - "uiStateJSON": "{\"vis\":{\"defaultColors\":{\"0 - 10000\":\"rgb(0,104,55)\"}}}", - "version": 1, - "visState": "{\"title\":\"Filter Bytes Test: Goal unique count\",\"type\":\"goal\",\"params\":{\"addTooltip\":true,\"addLegend\":false,\"isDisplayWarning\":false,\"type\":\"gauge\",\"gauge\":{\"verticalSplit\":false,\"autoExtend\":false,\"percentageMode\":false,\"gaugeType\":\"Arc\",\"gaugeStyle\":\"Full\",\"backStyle\":\"Full\",\"orientation\":\"vertical\",\"useRanges\":false,\"colorSchema\":\"Green to Red\",\"gaugeColorMode\":\"None\",\"colorsRange\":[{\"from\":0,\"to\":10000}],\"invertColors\":false,\"labels\":{\"show\":true,\"color\":\"black\"},\"scale\":{\"show\":false,\"labels\":false,\"color\":\"#333\",\"width\":2},\"type\":\"meter\",\"style\":{\"bgFill\":\"#000\",\"bgColor\":false,\"labelColor\":false,\"subText\":\"\",\"fontSize\":60}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"cardinality\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:7ff2c4c0-4191-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:36.270Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Filter Bytes Test: Data table top hit with significant terms geo.src", - "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", - "version": 1, - "visState": "{\"title\":\"Filter Bytes Test: Data table top hit with significant terms geo.src\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMeticsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\",\"showToolbar\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"top_hits\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\",\"aggregate\":\"average\",\"size\":1,\"sortField\":\"@timestamp\",\"sortOrder\":\"desc\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"significant_terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"geo.src\",\"size\":10}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:df72ad40-4194-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - }, - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:36.276Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[{\"meta\":{\"negate\":true,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"bytes\",\"value\":\"0\",\"params\":{\"query\":0,\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"bytes\":{\"query\":0,\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Filter Bytes Test: tag cloud with not 0 bytes filter", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Filter Bytes Test: tag cloud with not 0 bytes filter\",\"type\":\"tagcloud\",\"params\":{\"scale\":\"linear\",\"orientation\":\"single\",\"minFontSize\":18,\"maxFontSize\":72,\"showLabel\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"bytes\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "search:be5accf0-3dca-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "search": "7.9.3" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "search": { - "columns": [ - "agent", - "bytes", - "clientip" - ], - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "sort": [ - [ - "@timestamp", - "desc" - ] - ], - "title": "Rendering Test: saved search", - "version": 1 - }, - "type": "search", - "updated_at": "2018-04-17T15:09:39.805Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "search:ca5ada40-3dca-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "search": "7.9.3" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - }, - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - } - ], - "search": { - "columns": [ - "agent", - "bytes", - "clientip" - ], - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[{\"meta\":{\"negate\":false,\"type\":\"phrase\",\"key\":\"bytes\",\"value\":\"1,607\",\"params\":{\"query\":1607,\"type\":\"phrase\"},\"disabled\":false,\"alias\":null,\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"bytes\":{\"query\":1607,\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "sort": [ - [ - "@timestamp", - "desc" - ] - ], - "title": "Filter Bytes Test: search with filter", - "version": 1 - }, - "type": "search", - "updated_at": "2018-04-17T15:09:55.976Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:9bebe980-4192-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T15:59:42.648Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Filter Bytes Test: timelion split 5 on bytes", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Filter Bytes Test: timelion split 5 on bytes\",\"type\":\"timelion\",\"params\":{\"expression\":\".es(*, split=bytes:5)\",\"interval\":\"auto\"},\"aggs\":[]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:1dcdfe30-4192-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:59:56.976Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"bytes:>100\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Filter Bytes Test: min bytes metric with query", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Filter Bytes Test: min bytes metric with query\",\"type\":\"metric\",\"params\":{\"addTooltip\":true,\"addLegend\":false,\"type\":\"metric\",\"metric\":{\"percentageMode\":false,\"useRanges\":false,\"colorSchema\":\"Green to Red\",\"metricColorMode\":\"None\",\"colorsRange\":[{\"from\":0,\"to\":10000}],\"labels\":{\"show\":true},\"invertColors\":false,\"style\":{\"bgFill\":\"#000\",\"bgColor\":false,\"labelColor\":false,\"subText\":\"\",\"fontSize\":60}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"min\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:35417e50-4194-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T16:06:03.785Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Filter Bytes Test: tsvb metric with custom interval and bytes filter", - "uiStateJSON": "{}", - "version": 1, - "visState":"{\"title\":\"Filter Bytes Test: tsvb metric with custom interval and bytes filter\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"metric\",\"series\":[{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"value\":\"\",\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"sum\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"terms_field\":\"clientip\",\"filter\":{\"query\":\"Filter Bytes Test:>1000\",\"language\":\"lucene\"},\"override_index_pattern\":1,\"series_index_pattern\":\"logstash-*\",\"series_time_field\":\"utc_time\",\"series_interval\":\"1d\",\"value_template\":\"{{value}} custom template\",\"split_color_mode\":\"gradient\",\"series_drop_last_bucket\":1}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"06893260-4194-11e8-a461-7d278185cba4\"}],\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":0,\"isModelInvalid\":false,\"bar_color_rules\":[{\"id\":\"71f4e260-4186-11ec-8262-619fbabeae59\"}]}}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:9fb4c670-4194-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T16:32:59.086Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Filter Bytes Test: tsvb markdown", - "uiStateJSON": "{}", - "version": 1, - "visState":"{\"title\":\"Filter Bytes Test: tsvb markdown\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"markdown\",\"series\":[{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"filters\",\"metrics\":[{\"id\":\"482d6560-4194-11e8-a461-7d278185cba4\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"terms_field\":\"clientip\",\"filter\":{\"query\":\"Filter Bytes Test:>1000\",\"language\":\"lucene\"},\"override_index_pattern\":0,\"series_index_pattern\":\"logstash-*\",\"series_time_field\":\"utc_time\",\"series_interval\":\"1m\",\"value_template\":\"\",\"split_filters\":[{\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"label\":\"\",\"color\":\"#68BC00\",\"id\":\"39a107e0-4194-11e8-a461-7d278185cba4\"}],\"label\":\"\",\"var_name\":\"\",\"split_color_mode\":\"gradient\",\"series_drop_last_bucket\":1}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"06893260-4194-11e8-a461-7d278185cba4\"}],\"bar_color_rules\":[{\"id\":\"36a0e740-4194-11e8-a461-7d278185cba4\"}],\"markdown\":\"{{bytes_1000.last.formatted}}\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:befdb6b0-3e59-11e8-9fc3-39e49624228e", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - }, - { - "id": "a16d1990-3dca-11e8-8660-4d65aa086b3c", - "name": "search_0", - "type": "search" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T17:16:27.743Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal.keyword\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal.keyword\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"language\":\"lucene\",\"query\":\"\"}}" - }, - "savedSearchRefName": "search_0", - "title": "Filter Test: animals: linked to search with filter", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Filter Test: animals: linked to search with filter\",\"type\":\"pie\",\"params\":{\"addLegend\":true,\"addTooltip\":true,\"isDonut\":true,\"labels\":{\"last_level\":true,\"show\":false,\"truncate\":100,\"values\":true},\"legendPosition\":\"right\",\"type\":\"pie\",\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"name.keyword\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:584c0300-4191-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T18:36:30.315Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"bytes:>9000\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Filter Bytes Test: split by geo with query", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Filter Bytes Test: split by geo with query\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"sum\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"geo.src\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "config:7.0.0-alpha1", - "index": ".kibana", - "source": { - "config": { - "buildNum": null, - "dateFormat:tz": "UTC", - "defaultIndex": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "notifications:lifetime:banner": 3600000, - "notifications:lifetime:error": 3600000, - "notifications:lifetime:info": 3600000, - "notifications:lifetime:warning": 3600000 - }, - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "config": "7.13.0" - }, - "references": [ - ], - "type": "config", - "updated_at": "2018-04-17T19:25:03.632Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:8090dcb0-4195-11e8-bb13-d53698fb349a", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - ], - "type": "visualization", - "updated_at": "2018-04-17T19:28:21.967Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - }, - "title": "Filter Bytes Test: vega", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Filter Bytes Test: vega\",\"type\":\"vega\",\"params\":{\"spec\":\"{ \\nconfig: { kibana: { renderer: \\\"svg\\\" }},\\n/*\\n\\nWelcome to Vega visualizations. Here you can design your own dataviz from scratch using a declarative language called Vega, or its simpler form Vega-Lite. In Vega, you have the full control of what data is loaded, even from multiple sources, how that data is transformed, and what visual elements are used to show it. Use help icon to view Vega examples, tutorials, and other docs. Use the wrench icon to reformat this text, or to remove comments.\\n\\nThis example graph shows the document count in all indexes in the current time range. You might need to adjust the time filter in the upper right corner.\\n*/\\n\\n $schema: https://vega.github.io/schema/vega-lite/v2.json\\n title: Event counts from all indexes\\n\\n // Define the data source\\n data: {\\n url: {\\n/*\\nAn object instead of a string for the \\\"url\\\" param is treated as an Elasticsearch query. Anything inside this object is not part of the Vega language, but only understood by Kibana and Elasticsearch server. This query counts the number of documents per time interval, assuming you have a @timestamp field in your data.\\n\\nKibana has a special handling for the fields surrounded by \\\"%\\\". They are processed before the the query is sent to Elasticsearch. This way the query becomes context aware, and can use the time range and the dashboard filters.\\n*/\\n\\n // Apply dashboard context filters when set\\n %context%: true\\n // Filter the time picker (upper right corner) with this field\\n %timefield%: @timestamp\\n\\n/*\\nSee .search() documentation for : https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html#api-search\\n*/\\n\\n // Which index to search\\n index: _all\\n // Aggregate data by the time field into time buckets, counting the number of documents in each bucket.\\n body: {\\n aggs: {\\n time_buckets: {\\n date_histogram: {\\n // Use date histogram aggregation on @timestamp field\\n field: @timestamp\\n // The interval value will depend on the daterange picker (true), or use an integer to set an approximate bucket count\\n interval: {%autointerval%: true}\\n // Make sure we get an entire range, even if it has no data\\n extended_bounds: {\\n // Use the current time range's start and end\\n min: {%timefilter%: \\\"min\\\"}\\n max: {%timefilter%: \\\"max\\\"}\\n }\\n // Use this for linear (e.g. line, area) graphs. Without it, empty buckets will not show up\\n min_doc_count: 0\\n }\\n }\\n }\\n // Speed up the response by only including aggregation results\\n size: 0\\n }\\n }\\n/*\\nElasticsearch will return results in this format:\\n\\naggregations: {\\n time_buckets: {\\n buckets: [\\n {\\n key_as_string: 2015-11-30T22:00:00.000Z\\n key: 1448920800000\\n doc_count: 0\\n },\\n {\\n key_as_string: 2015-11-30T23:00:00.000Z\\n key: 1448924400000\\n doc_count: 0\\n }\\n ...\\n ]\\n }\\n}\\n\\nFor our graph, we only need the list of bucket values. Use the format.property to discard everything else.\\n*/\\n format: {property: \\\"aggregations.time_buckets.buckets\\\"}\\n }\\n\\n // \\\"mark\\\" is the graphics element used to show our data. Other mark values are: area, bar, circle, line, point, rect, rule, square, text, and tick. See https://vega.github.io/vega-lite/docs/mark.html\\n mark: line\\n\\n // \\\"encoding\\\" tells the \\\"mark\\\" what data to use and in what way. See https://vega.github.io/vega-lite/docs/encoding.html\\n encoding: {\\n x: {\\n // The \\\"key\\\" value is the timestamp in milliseconds. Use it for X axis.\\n field: key\\n type: temporal\\n axis: {title: false} // Customize X axis format\\n }\\n y: {\\n // The \\\"doc_count\\\" is the count per bucket. Use it for Y axis.\\n field: doc_count\\n type: quantitative\\n axis: {title: \\\"Document count\\\"}\\n }\\n }\\n}\\n\"},\"aggs\":[]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "config:6.2.4", - "index": ".kibana", - "source": { - "config": { - "buildNum": 16627, - "defaultIndex": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "xPackMonitoring:showBanner": false - }, - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "config": "7.13.0" - }, - "references": [ - ], - "type": "config", - "updated_at": "2018-05-09T20:50:57.021Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:edb65990-53ca-11e8-b481-c9426d020fcd", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-05-09T20:52:47.144Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "table created in 6_2", - "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", - "version": 1, - "visState": "{\"title\":\"table created in 6_2\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMeticsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\",\"showToolbar\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"weightLbs\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"animal.keyword\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:0644f890-53cb-11e8-b481-c9426d020fcd", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-05-09T20:53:28.345Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"weightLbs:>10\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Weight in lbs pie created in 6.2", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"title\":\"Weight in lbs pie created in 6.2\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"weightLbs\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:1b2f47b0-53cb-11e8-b481-c9426d020fcd", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"weightLbs:>15\"},\"filter\":[{\"meta\":{\"field\":\"isDog\",\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"isDog\",\"value\":\"true\",\"params\":{\"value\":true},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"script\":{\"script\":{\"inline\":\"boolean compare(Supplier s, def v) {return s.get() == v;}compare(() -> { return doc['animal.keyword'].value == 'dog' }, params.value);\",\"lang\":\"painless\",\"params\":{\"value\":true}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" - }, - "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":12,\"x\":24,\"y\":0,\"i\":\"4\"},\"panelIndex\":\"4\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_4\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":12,\"x\":0,\"y\":0,\"i\":\"5\"},\"panelIndex\":\"5\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_5\"}]", - "refreshInterval": { - "display": "Off", - "pause": false, - "value": 0 - }, - "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", - "timeRestore": true, - "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", - "title": "Animal Weights (created in 6.2)", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - }, - { - "id": "edb65990-53ca-11e8-b481-c9426d020fcd", - "name": "4:panel_4", - "type": "visualization" - }, - { - "id": "0644f890-53cb-11e8-b481-c9426d020fcd", - "name": "5:panel_5", - "type": "visualization" - } - ], - "type": "dashboard", - "updated_at": "2018-05-09T20:54:03.435Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "index-pattern:a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "index-pattern": { - "fieldFormatMap": "{\"weightLbs\":{\"id\":\"number\",\"params\":{\"pattern\":\"0,0.0\"}},\"is_dog\":{\"id\":\"boolean\"},\"isDog\":{\"id\":\"boolean\"}}", - "fields": "[{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"animal\",\"type\":\"string\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"animal.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"name\",\"type\":\"string\",\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"name.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"sound\",\"type\":\"string\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sound.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"weightLbs\",\"type\":\"number\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"isDog\",\"type\":\"boolean\",\"count\":0,\"scripted\":true,\"script\":\"return doc['animal.keyword'].value == 'dog'\",\"lang\":\"painless\",\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false}]", - "timeFieldName": "@timestamp", - "title": "animals-*" - }, - "migrationVersion": { - "index-pattern": "7.11.0" - }, - "references": [ - ], - "type": "index-pattern", - "updated_at": "2018-05-09T20:55:44.314Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "search:6351c590-53cb-11e8-b481-c9426d020fcd", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "search": "7.9.3" - }, - "references": [ - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - }, - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - } - ], - "search": { - "columns": [ - "animal", - "sound", - "weightLbs" - ], - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"lucene\",\"query\":\"weightLbs:>10\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"sound.keyword\",\"value\":\"growl\",\"params\":{\"query\":\"growl\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"sound.keyword\":{\"query\":\"growl\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "sort": [ - [ - "@timestamp", - "desc" - ] - ], - "title": "Search created in 6.2", - "version": 1 - }, - "type": "search", - "updated_at": "2018-05-09T20:56:04.457Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "visualization:47b5cf60-9e93-11ea-853e-adc0effaf76d", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "1b1789d0-9e93-11ea-853e-adc0effaf76d", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2020-05-25T15:16:27.743Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "vis with missing index pattern", - "uiStateJSON": "{}", - "version": 1, - "visState": "{\"type\":\"pie\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}],\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"title\":\"vis with missing index pattern\"}" - } - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:502e63a0-9e93-11ea-853e-adc0effaf76d", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}" - }, - "optionsJSON": "{\"hidePanelTitles\":false,\"useMargins\":true}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"6cfbe6cc-1872-4cb4-9455-a02eeb75127e\"},\"panelIndex\":\"6cfbe6cc-1872-4cb4-9455-a02eeb75127e\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_6cfbe6cc-1872-4cb4-9455-a02eeb75127e\"}]", - "timeRestore": false, - "title": "dashboard with missing index pattern", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - { - "id": "47b5cf60-9e93-11ea-853e-adc0effaf76d", - "name": "6cfbe6cc-1872-4cb4-9455-a02eeb75127e:panel_6cfbe6cc-1872-4cb4-9455-a02eeb75127e", - "type": "visualization" - } - ], - "type": "dashboard", - "updated_at": "2020-05-25T15:16:27.743Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "dashboard:6eb8a840-a32e-11ea-88c2-d56dd2b14bd7", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "dashboard": { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\n \"query\": {\n \"language\": \"kuery\",\n \"query\": \"\"\n },\n \"filter\": [\n {\n \"meta\": {\n \"alias\": null,\n \"negate\": false,\n \"disabled\": true,\n \"type\": \"phrase\",\n \"key\": \"name\",\n \"params\": {\n \"query\": \"moo\"\n },\n \"indexRefName\": \"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"\n },\n \"query\": {\n \"match_phrase\": {\n \"name\": \"moo\"\n }\n },\n \"$state\": {\n \"store\": \"appState\"\n }\n },\n {\n \"meta\": {\n \"alias\": null,\n \"negate\": false,\n \"disabled\": true,\n \"type\": \"phrase\",\n \"key\": \"baad-field\",\n \"params\": {\n \"query\": \"moo\"\n },\n \"indexRefName\": \"kibanaSavedObjectMeta.searchSourceJSON.filter[1].meta.index\"\n },\n \"query\": {\n \"match_phrase\": {\n \"baad-field\": \"moo\"\n }\n },\n \"$state\": {\n \"store\": \"appState\"\n }\n },\n {\n \"meta\": {\n \"alias\": null,\n \"negate\": false,\n \"disabled\": false,\n \"type\": \"phrase\",\n \"key\": \"@timestamp\",\n \"params\": {\n \"query\": \"123\"\n },\n \"indexRefName\": \"kibanaSavedObjectMeta.searchSourceJSON.filter[2].meta.index\"\n },\n \"query\": {\n \"match_phrase\": {\n \"@timestamp\": \"123\"\n }\n },\n \"$state\": {\n \"store\": \"appState\"\n }\n },\n {\n \"meta\": {\n \"alias\": null,\n \"negate\": false,\n \"disabled\": false,\n \"type\": \"exists\",\n \"key\": \"extension\",\n \"value\": \"exists\",\n \"indexRefName\": \"kibanaSavedObjectMeta.searchSourceJSON.filter[3].meta.index\"\n },\n \"exists\": {\n \"field\": \"extension\"\n },\n \"$state\": {\n \"store\": \"appState\"\n }\n },\n {\n \"meta\": {\n \"alias\": null,\n \"negate\": false,\n \"disabled\": false,\n \"type\": \"phrase\",\n \"key\": \"banana\",\n \"params\": {\n \"query\": \"yellow\"\n }\n },\n \"query\": {\n \"match_phrase\": {\n \"banana\": \"yellow\"\n }\n },\n \"$state\": {\n \"store\": \"appState\"\n }\n }\n ]\n}" - }, - "optionsJSON": "{\n \"hidePanelTitles\": false,\n \"useMargins\": true\n}", - "panelsJSON": "[{\"version\":\"8.0.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"94a3dc1d-508a-4d42-a480-65b158925ba0\"},\"panelIndex\":\"94a3dc1d-508a-4d42-a480-65b158925ba0\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_94a3dc1d-508a-4d42-a480-65b158925ba0\"}]", - "refreshInterval": { - "pause": true, - "value": 0 - }, - "timeFrom": "now-10y", - "timeRestore": true, - "timeTo": "now", - "title": "dashboard with bad filters", - "version": 1 - }, - "migrationVersion": { - "dashboard": "7.14.0" - }, - "references": [ - { - "id": "a0f483a0-3dc9-11e8-8660-bad-index", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - }, - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[1].meta.index", - "type": "index-pattern" - }, - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[2].meta.index", - "type": "index-pattern" - }, - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[3].meta.index", - "type": "index-pattern" - }, - { - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[4].meta.index", - "type": "index-pattern" - }, - { - "id": "50643b60-3dd3-11e8-b2b9-5d5dc1715159", - "name": "94a3dc1d-508a-4d42-a480-65b158925ba0:panel_94a3dc1d-508a-4d42-a480-65b158925ba0", - "type": "visualization" - } - ], - "type": "dashboard", - "updated_at": "2020-06-04T09:26:04.272Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "config:8.0.0", - "index": ".kibana", - "source": { - "config": { - "accessibility:disableAnimations": true, - "buildNum": null, - "dateFormat:tz": "UTC", - "defaultIndex": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c" - }, - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "config": "7.13.0" - }, - "references": [ - ], - "type": "config", - "updated_at": "2020-06-04T09:22:54.572Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "ui-metric:DashboardPanelVersionInUrl:8.0.0", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "references": [ - ], - "type": "ui-metric", - "ui-metric": { - "count": 15 - }, - "updated_at": "2020-06-04T09:28:06.848Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "ui-metric:kibana-user_agent:Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "references": [ - ], - "type": "ui-metric", - "ui-metric": { - "count": 1 - }, - "updated_at": "2020-06-04T09:28:06.848Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "application_usage_daily:dashboards:2020-05-31", - "index": ".kibana", - "source": { - "application_usage_daily": { - "appId": "dashboards", - "minutesOnScreen": 13.956333333333333, - "numberOfClicks": 134, - "timestamp": "2020-05-31T00:00:00.000Z" - }, - "coreMigrationVersion": "7.14.0", - "references": [ - ], - "type": "application_usage_daily", - "updated_at": "2021-06-10T22:39:09.215Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "application_usage_daily:home:2020-05-31", - "index": ".kibana", - "source": { - "application_usage_daily": { - "appId": "home", - "minutesOnScreen": 0.5708666666666666, - "numberOfClicks": 1, - "timestamp": "2020-05-31T00:00:00.000Z" - }, - "coreMigrationVersion": "7.14.0", - "references": [ - ], - "type": "application_usage_daily", - "updated_at": "2021-06-10T22:39:09.215Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "application_usage_daily:management:2020-05-31", - "index": ".kibana", - "source": { - "application_usage_daily": { - "appId": "management", - "minutesOnScreen": 5.842616666666666, - "numberOfClicks": 107, - "timestamp": "2020-05-31T00:00:00.000Z" - }, - "coreMigrationVersion": "7.14.0", - "references": [ - ], - "type": "application_usage_daily", - "updated_at": "2021-06-10T22:39:09.215Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "application_usage_daily:management:2020-06-04", - "index": ".kibana", - "source": { - "application_usage_daily": { - "appId": "management", - "minutesOnScreen": 2.5120666666666667, - "numberOfClicks": 38, - "timestamp": "2020-06-04T00:00:00.000Z" - }, - "coreMigrationVersion": "7.14.0", - "references": [ - ], - "type": "application_usage_daily", - "updated_at": "2021-06-10T22:39:09.215Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "application_usage_daily:dashboards:2020-06-04", - "index": ".kibana", - "source": { - "application_usage_daily": { - "appId": "dashboards", - "minutesOnScreen": 9.065083333333334, - "numberOfClicks": 21, - "timestamp": "2020-06-04T00:00:00.000Z" - }, - "coreMigrationVersion": "7.14.0", - "references": [ - ], - "type": "application_usage_daily", - "updated_at": "2021-06-10T22:39:09.215Z" - }, - "type": "_doc" - } -} diff --git a/test/functional/fixtures/es_archiver/dashboard/current/kibana/mappings.json b/test/functional/fixtures/es_archiver/dashboard/current/kibana/mappings.json deleted file mode 100644 index 161d733e868a..000000000000 --- a/test/functional/fixtures/es_archiver/dashboard/current/kibana/mappings.json +++ /dev/null @@ -1,476 +0,0 @@ -{ - "type": "index", - "value": { - "aliases": { - ".kibana_$KIBANA_PACKAGE_VERSION": {}, - ".kibana": {} - }, - "index": ".kibana_$KIBANA_PACKAGE_VERSION_001", - "mappings": { - "_meta": { - "migrationMappingPropertyHashes": { - "application_usage_daily": "43b8830d5d0df85a6823d290885fc9fd", - "application_usage_totals": "3d1b76c39bfb2cc8296b024d73854724", - "application_usage_transactional": "3d1b76c39bfb2cc8296b024d73854724", - "config": "c63748b75f39d0c54de12d12c1ccbc20", - "core-usage-stats": "3d1b76c39bfb2cc8296b024d73854724", - "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", - "dashboard": "40554caf09725935e2c02e02563a2d07", - "index-pattern": "45915a1ad866812242df474eb0479052", - "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", - "legacy-url-alias": "6155300fd11a00e23d5cbaa39f0fce0a", - "migrationVersion": "4a1746014a75ade3a714e1db5763276f", - "namespace": "2f4316de49999235636386fe51dc06c1", - "namespaces": "2f4316de49999235636386fe51dc06c1", - "originId": "2f4316de49999235636386fe51dc06c1", - "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", - "references": "7997cf5a56cc02bdc9c93361bde732b0", - "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", - "search": "db2c00e39b36f40930a3b9fc71c823e1", - "search-telemetry": "3d1b76c39bfb2cc8296b024d73854724", - "telemetry": "36a616f7026dfa617d6655df850fe16d", - "type": "2f4316de49999235636386fe51dc06c1", - "ui-counter": "0d409297dc5ebe1e3a1da691c6ee32e3", - "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", - "updated_at": "00da57df13e94e9d98437d13ace4bfe0", - "url": "c7f66a0df8b1b52f17c28c4adb111105", - "usage-counters": "8cc260bdceffec4ffc3ad165c97dc1b4", - "visualization": "f819cf6636b75c9e76ba733a0c6ef355" - } - }, - "dynamic": "strict", - "properties": { - "application_usage_daily": { - "dynamic": "false", - "properties": { - "timestamp": { - "type": "date" - } - } - }, - "application_usage_totals": { - "dynamic": "false", - "type": "object" - }, - "application_usage_transactional": { - "dynamic": "false", - "type": "object" - }, - "config": { - "dynamic": "false", - "properties": { - "buildNum": { - "type": "keyword" - } - } - }, - "core-usage-stats": { - "dynamic": "false", - "type": "object" - }, - "coreMigrationVersion": { - "type": "keyword" - }, - "dashboard": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "doc_values": false, - "index": false, - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "index": false, - "type": "text" - } - } - }, - "optionsJSON": { - "index": false, - "type": "text" - }, - "panelsJSON": { - "index": false, - "type": "text" - }, - "refreshInterval": { - "properties": { - "display": { - "doc_values": false, - "index": false, - "type": "keyword" - }, - "pause": { - "doc_values": false, - "index": false, - "type": "boolean" - }, - "section": { - "doc_values": false, - "index": false, - "type": "integer" - }, - "value": { - "doc_values": false, - "index": false, - "type": "integer" - } - } - }, - "timeFrom": { - "doc_values": false, - "index": false, - "type": "keyword" - }, - "timeRestore": { - "doc_values": false, - "index": false, - "type": "boolean" - }, - "timeTo": { - "doc_values": false, - "index": false, - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "index-pattern": { - "dynamic": "false", - "properties": { - "title": { - "type": "text" - }, - "type": { - "type": "keyword" - } - } - }, - "kql-telemetry": { - "properties": { - "optInCount": { - "type": "long" - }, - "optOutCount": { - "type": "long" - } - } - }, - "legacy-url-alias": { - "dynamic": "false", - "properties": { - "disabled": { - "type": "boolean" - }, - "sourceId": { - "type": "keyword" - }, - "targetType": { - "type": "keyword" - } - } - }, - "migrationVersion": { - "dynamic": "true", - "properties": { - "config": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "dashboard": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "index-pattern": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "search": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "visualization": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "namespace": { - "type": "keyword" - }, - "namespaces": { - "type": "keyword" - }, - "originId": { - "type": "keyword" - }, - "query": { - "properties": { - "description": { - "type": "text" - }, - "filters": { - "enabled": false, - "type": "object" - }, - "query": { - "properties": { - "language": { - "type": "keyword" - }, - "query": { - "index": false, - "type": "keyword" - } - } - }, - "timefilter": { - "enabled": false, - "type": "object" - }, - "title": { - "type": "text" - } - } - }, - "references": { - "properties": { - "id": { - "type": "keyword" - }, - "name": { - "type": "keyword" - }, - "type": { - "type": "keyword" - } - }, - "type": "nested" - }, - "sample-data-telemetry": { - "properties": { - "installCount": { - "type": "long" - }, - "unInstallCount": { - "type": "long" - } - } - }, - "search": { - "properties": { - "columns": { - "doc_values": false, - "index": false, - "type": "keyword" - }, - "description": { - "type": "text" - }, - "grid": { - "enabled": false, - "type": "object" - }, - "hideChart": { - "doc_values": false, - "index": false, - "type": "boolean" - }, - "hits": { - "doc_values": false, - "index": false, - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "index": false, - "type": "text" - } - } - }, - "sort": { - "doc_values": false, - "index": false, - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "search-telemetry": { - "dynamic": "false", - "type": "object" - }, - "server": { - "dynamic": "false", - "type": "object" - }, - "telemetry": { - "properties": { - "allowChangingOptInStatus": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "lastReported": { - "type": "date" - }, - "lastVersionChecked": { - "type": "keyword" - }, - "reportFailureCount": { - "type": "integer" - }, - "reportFailureVersion": { - "type": "keyword" - }, - "sendUsageFrom": { - "type": "keyword" - }, - "userHasSeenNotice": { - "type": "boolean" - } - } - }, - "tsvb-validation-telemetry": { - "dynamic": "false", - "type": "object" - }, - "type": { - "type": "keyword" - }, - "ui-counter": { - "properties": { - "count": { - "type": "integer" - } - } - }, - "ui-metric": { - "properties": { - "count": { - "type": "integer" - } - } - }, - "updated_at": { - "type": "date" - }, - "url": { - "properties": { - "accessCount": { - "type": "long" - }, - "accessDate": { - "type": "date" - }, - "createDate": { - "type": "date" - }, - "url": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "usage-counters": { - "dynamic": "false", - "properties": { - "domainId": { - "type": "keyword" - } - } - }, - "visualization": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "index": false, - "type": "text" - } - } - }, - "savedSearchRefName": { - "doc_values": false, - "index": false, - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "index": false, - "type": "text" - }, - "version": { - "type": "integer" - }, - "visState": { - "index": false, - "type": "text" - } - } - } - } - }, - "settings": { - "index": { - "auto_expand_replicas": "0-1", - "number_of_replicas": "0", - "number_of_shards": "1", - "priority": "10", - "refresh_interval": "1s", - "routing_partition_size": "1" - } - } - } -} \ No newline at end of file diff --git a/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json new file mode 100644 index 000000000000..711f242d16ac --- /dev/null +++ b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json @@ -0,0 +1,2653 @@ +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Rendering Test: tsvb-table", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: tsvb-table\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"table\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_color_rules\":[{\"id\":\"e0be22e0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_width\":10,\"gauge_inner_width\":10,\"gauge_style\":\"half\",\"markdown\":\"\\nHi Avg last bytes: {{ average_of_bytes.last.raw }}\",\"pivot_id\":\"bytes\",\"pivot_label\":\"Hello\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" + }, + "coreMigrationVersion": "8.0.1", + "id": "02a2e4e0-3dcd-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T15:06:30.351Z", + "version": "WzIzMSwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Filter Bytes Test: tsvb time series with bytes filter split by clientip", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: tsvb time series with bytes filter split by clientip\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"timeseries\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"terms\",\"metrics\":[{\"value\":\"\",\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"sum\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"terms_field\":\"clientip\",\"filter\":{\"query\":\"Filter Bytes Test:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"use_kibana_indexes\":false,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" + }, + "coreMigrationVersion": "8.0.1", + "id": "039e4770-4194-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T15:06:35.220Z", + "version": "WzI0NSwxXQ==" +} + +{ + "attributes": { + "fieldFormatMap": "{\"machine.ram\":{\"id\":\"number\",\"params\":{\"pattern\":\"0,0.[000] b\"}}}", + "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", + "timeFieldName": "@timestamp", + "title": "logstash-*" + }, + "coreMigrationVersion": "8.0.1", + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "index-pattern": "8.0.0" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2018-04-16T16:57:12.263Z", + "version": "WzIxNiwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Filter Bytes Test: Goal unique count", + "uiStateJSON": "{\"vis\":{\"defaultColors\":{\"0 - 10000\":\"rgb(0,104,55)\"}}}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: Goal unique count\",\"type\":\"goal\",\"params\":{\"addTooltip\":true,\"addLegend\":false,\"isDisplayWarning\":false,\"type\":\"gauge\",\"gauge\":{\"verticalSplit\":false,\"autoExtend\":false,\"percentageMode\":false,\"gaugeType\":\"Arc\",\"gaugeStyle\":\"Full\",\"backStyle\":\"Full\",\"orientation\":\"vertical\",\"useRanges\":false,\"colorSchema\":\"Green to Red\",\"gaugeColorMode\":\"None\",\"colorsRange\":[{\"from\":0,\"to\":10000}],\"invertColors\":false,\"labels\":{\"show\":true,\"color\":\"black\"},\"scale\":{\"show\":false,\"labels\":false,\"color\":\"#333\",\"width\":2},\"type\":\"meter\",\"style\":{\"bgFill\":\"#000\",\"bgColor\":false,\"labelColor\":false,\"subText\":\"\",\"fontSize\":60}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"cardinality\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "03d2afd0-4192-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:36.269Z", + "version": "WzI0OSwxXQ==" +} + +{ + "attributes": { + "fieldFormatMap": "{\"weightLbs\":{\"id\":\"number\",\"params\":{\"pattern\":\"0,0.0\"}},\"is_dog\":{\"id\":\"boolean\"},\"isDog\":{\"id\":\"boolean\"}}", + "fields": "[{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"animal\",\"type\":\"string\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"animal.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"name\",\"type\":\"string\",\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"name.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"sound\",\"type\":\"string\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sound.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"weightLbs\",\"type\":\"number\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"isDog\",\"type\":\"boolean\",\"count\":0,\"scripted\":true,\"script\":\"return doc['animal.keyword'].value == 'dog'\",\"lang\":\"painless\",\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false}]", + "timeFieldName": "@timestamp", + "title": "animals-*" + }, + "coreMigrationVersion": "8.0.1", + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "index-pattern": "8.0.0" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2018-05-09T20:55:44.314Z", + "version": "WzI2NiwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"weightLbs:>10\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Weight in lbs pie created in 6.2", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Weight in lbs pie created in 6.2\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"weightLbs\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "0644f890-53cb-11e8-b481-c9426d020fcd", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-05-09T20:53:28.345Z", + "version": "WzI2NCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Filter Bytes Test: input control with filter", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: input control with filter\",\"type\":\"input_control_vis\",\"params\":{\"controls\":[{\"id\":\"1523896850250\",\"fieldName\":\"bytes\",\"parent\":\"\",\"label\":\"Byte Options\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":10,\"order\":\"desc\"},\"indexPatternRefName\":\"control_0_index_pattern\"}],\"updateFiltersOnChange\":false,\"useTimeFilter\":false,\"pinFilters\":false},\"aggs\":[]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "0ca8c600-4195-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "control_0_index_pattern", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:35.229Z", + "version": "WzI0NCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Rendering Test: metric", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: metric\",\"type\":\"metric\",\"params\":{\"addTooltip\":true,\"addLegend\":false,\"type\":\"metric\",\"metric\":{\"percentageMode\":false,\"useRanges\":false,\"colorSchema\":\"Green to Red\",\"metricColorMode\":\"None\",\"colorsRange\":[{\"from\":0,\"to\":10000}],\"labels\":{\"show\":true},\"invertColors\":false,\"style\":{\"bgFill\":\"#000\",\"bgColor\":false,\"labelColor\":false,\"subText\":\"\",\"fontSize\":60}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"cardinality\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "11ae2bd0-3dcc-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:32.133Z", + "version": "WzIyMSwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Rendering Test: heatmap", + "uiStateJSON": "{\"vis\":{\"defaultColors\":{\"0 - 15\":\"rgb(247,252,245)\",\"15 - 30\":\"rgb(199,233,192)\",\"30 - 45\":\"rgb(116,196,118)\",\"45 - 60\":\"rgb(35,139,69)\"}}}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: heatmap\",\"type\":\"heatmap\",\"params\":{\"type\":\"heatmap\",\"addTooltip\":true,\"addLegend\":true,\"enableHover\":false,\"legendPosition\":\"right\",\"times\":[],\"colorsNumber\":4,\"colorSchema\":\"Greens\",\"setColorRange\":false,\"colorsRange\":[],\"invertColors\":false,\"percentageMode\":false,\"valueAxes\":[{\"show\":false,\"id\":\"ValueAxis-1\",\"type\":\"value\",\"scale\":{\"type\":\"linear\",\"defaultYExtents\":false},\"labels\":{\"show\":false,\"rotate\":0,\"overwriteColor\":false,\"color\":\"#555\"}}]},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"bytes\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"geo.src\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "145ced90-3dcb-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:32.134Z", + "version": "WzIyMiwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "im empty", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "14616b50-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:02:51.909Z", + "version": "WzE5NCwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "im empty too", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "19523860-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:03:00.198Z", + "version": "WzE5MywxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "table created in 6_2", + "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", + "version": 1, + "visState": "{\"title\":\"table created in 6_2\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMeticsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\",\"showToolbar\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"weightLbs\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"animal.keyword\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "edb65990-53ca-11e8-b481-c9426d020fcd", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-05-09T20:52:47.144Z", + "version": "WzI2MywxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"weightLbs:>15\"},\"filter\":[{\"meta\":{\"field\":\"isDog\",\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"isDog\",\"value\":\"true\",\"params\":{\"value\":true},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"script\":{\"script\":{\"inline\":\"boolean compare(Supplier s, def v) {return s.get() == v;}compare(() -> { return doc['animal.keyword'].value == 'dog' }, params.value);\",\"lang\":\"painless\",\"params\":{\"value\":true}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":12,\"x\":24,\"y\":0,\"i\":\"4\"},\"panelIndex\":\"4\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_4\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":12,\"x\":0,\"y\":0,\"i\":\"5\"},\"panelIndex\":\"5\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_5\"}]", + "refreshInterval": { + "display": "Off", + "pause": false, + "value": 0 + }, + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "title": "Animal Weights (created in 6.2)", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "1b2f47b0-53cb-11e8-b481-c9426d020fcd", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + }, + { + "id": "edb65990-53ca-11e8-b481-c9426d020fcd", + "name": "4:panel_4", + "type": "visualization" + }, + { + "id": "0644f890-53cb-11e8-b481-c9426d020fcd", + "name": "5:panel_5", + "type": "visualization" + } + ], + "type": "dashboard", + "updated_at": "2018-05-09T20:54:03.435Z", + "version": "WzI2NSwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"bytes:>100\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Filter Bytes Test: min bytes metric with query", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: min bytes metric with query\",\"type\":\"metric\",\"params\":{\"addTooltip\":true,\"addLegend\":false,\"type\":\"metric\",\"metric\":{\"percentageMode\":false,\"useRanges\":false,\"colorSchema\":\"Green to Red\",\"metricColorMode\":\"None\",\"colorsRange\":[{\"from\":0,\"to\":10000}],\"labels\":{\"show\":true},\"invertColors\":false,\"style\":{\"bgFill\":\"#000\",\"bgColor\":false,\"labelColor\":false,\"subText\":\"\",\"fontSize\":60}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"min\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "1dcdfe30-4192-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:59:56.976Z", + "version": "WzI1NSwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"geo.src\",\"value\":\"CN\",\"params\":{\"query\":\"CN\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"geo.src\":{\"query\":\"CN\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"query\":\"bytes >= 10000\",\"language\":\"kuery\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Kuery: pie bytes with kuery and filter", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Kuery: pie bytes with kuery and filter\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"bytes\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":10,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "29bd0240-4197-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-16T16:56:53.092Z", + "version": "WzIxNSwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Rendering Test: guage", + "uiStateJSON": "{\"vis\":{\"colors\":{\"0 - 50000\":\"#EF843C\",\"75000 - 10000000\":\"#3F6833\"},\"defaultColors\":{\"0 - 5000000\":\"rgb(0,104,55)\",\"50000000 - 74998990099\":\"rgb(165,0,38)\"}}}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: guage\",\"type\":\"gauge\",\"params\":{\"addLegend\":true,\"addTooltip\":true,\"gauge\":{\"backStyle\":\"Full\",\"colorSchema\":\"Green to Red\",\"colorsRange\":[{\"from\":0,\"to\":5000000},{\"from\":50000000,\"to\":74998990099}],\"extendRange\":true,\"gaugeColorMode\":\"Labels\",\"gaugeStyle\":\"Full\",\"gaugeType\":\"Arc\",\"invertColors\":false,\"labels\":{\"color\":\"black\",\"show\":true},\"orientation\":\"vertical\",\"percentageMode\":false,\"scale\":{\"color\":\"#333\",\"labels\":false,\"show\":true},\"style\":{\"bgColor\":false,\"bgFill\":\"#eee\",\"bgMask\":false,\"bgWidth\":0.9,\"fontSize\":60,\"labelColor\":true,\"mask\":false,\"maskBars\":50,\"subText\":\"\",\"width\":0.9},\"type\":\"meter\",\"alignment\":\"horizontal\"},\"isDisplayWarning\":false,\"type\":\"gauge\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"avg\",\"schema\":\"metric\",\"params\":{\"field\":\"machine.ram\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "e2023110-3dcb-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:32.135Z", + "version": "WzIyMywxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":0,\"y\":0,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":24,\"y\":0,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"}]", + "timeRestore": false, + "title": "couple panels", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "2ae34a60-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [ + { + "id": "145ced90-3dcb-11e8-8660-4d65aa086b3c", + "name": "1:panel_1", + "type": "visualization" + }, + { + "id": "e2023110-3dcb-11e8-8660-4d65aa086b3c", + "name": "2:panel_2", + "type": "visualization" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T22:03:29.670Z", + "version": "WzE4OSwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Rendering Test: input control", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: input control\",\"type\":\"input_control_vis\",\"params\":{\"controls\":[{\"id\":\"1523481142694\",\"fieldName\":\"bytes\",\"parent\":\"\",\"label\":\"Bytes Input List\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":5,\"order\":\"desc\"},\"indexPatternRefName\":\"control_0_index_pattern\"},{\"id\":\"1523481163654\",\"fieldName\":\"bytes\",\"parent\":\"\",\"label\":\"Bytes range\",\"type\":\"range\",\"options\":{\"decimalPlaces\":0,\"step\":1},\"indexPatternRefName\":\"control_1_index_pattern\"},{\"id\":\"1523481176519\",\"fieldName\":\"sound.keyword\",\"parent\":\"\",\"label\":\"Animal sounds\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":5,\"order\":\"desc\"},\"indexPatternRefName\":\"control_2_index_pattern\"}],\"updateFiltersOnChange\":false,\"useTimeFilter\":false,\"pinFilters\":false},\"aggs\":[]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "2d1b1620-3dcd-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "control_0_index_pattern", + "type": "index-pattern" + }, + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "control_1_index_pattern", + "type": "index-pattern" + }, + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "control_2_index_pattern", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:31.123Z", + "version": "WzIyNywxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Rendering Test: datatable", + "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: datatable\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMeticsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\",\"showToolbar\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"clientip\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "4b5d6ef0-3dcb-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:33.162Z", + "version": "WzIzNywxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"}]", + "timeRestore": false, + "title": "few panels", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "33bb8ad0-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [ + { + "id": "145ced90-3dcb-11e8-8660-4d65aa086b3c", + "name": "1:panel_1", + "type": "visualization" + }, + { + "id": "e2023110-3dcb-11e8-8660-4d65aa086b3c", + "name": "2:panel_2", + "type": "visualization" + }, + { + "id": "4b5d6ef0-3dcb-11e8-8660-4d65aa086b3c", + "name": "3:panel_3", + "type": "visualization" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T22:03:44.509Z", + "version": "WzE5NSwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Rendering Test: bar", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: bar\",\"type\":\"horizontal_bar\",\"params\":{\"type\":\"histogram\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":200},\"title\":{}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":75,\"filter\":true,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":true,\"type\":\"histogram\",\"mode\":\"normal\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"valueAxis\":\"ValueAxis-1\",\"drawLinesBetweenPoints\":true,\"showCircles\":true}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false,\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"isVislibVis\":true,\"detailedTooltip\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1,\"extended_bounds\":{}}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"geo.src\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":3,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "3525b840-3dcb-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:33.163Z", + "version": "WzIzOCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Filter Bytes Test: tsvb metric with custom interval and bytes filter", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: tsvb metric with custom interval and bytes filter\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"metric\",\"series\":[{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"value\":\"\",\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"sum\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"terms_field\":\"clientip\",\"filter\":{\"query\":\"Filter Bytes Test:>1000\",\"language\":\"lucene\"},\"override_index_pattern\":1,\"series_index_pattern\":\"logstash-*\",\"series_time_field\":\"utc_time\",\"series_interval\":\"1d\",\"value_template\":\"{{value}} custom template\",\"split_color_mode\":\"gradient\",\"series_drop_last_bucket\":1}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"06893260-4194-11e8-a461-7d278185cba4\"}],\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":0,\"isModelInvalid\":false,\"bar_color_rules\":[{\"id\":\"71f4e260-4186-11ec-8262-619fbabeae59\"}]}}" + }, + "coreMigrationVersion": "8.0.1", + "id": "35417e50-4194-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T16:06:03.785Z", + "version": "WzI1NiwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "1", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "3de0bda0-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:04:01.530Z", + "version": "WzIwMiwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Rendering Test: pie", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: pie\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"bytes\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "3fe22200-3dcb-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:32.130Z", + "version": "WzIyMCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Rendering Test: input control parent", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: input control parent\",\"type\":\"input_control_vis\",\"params\":{\"controls\":[{\"id\":\"1523481216736\",\"fieldName\":\"animal.keyword\",\"parent\":\"\",\"label\":\"Animal type\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":5,\"order\":\"desc\"},\"indexPatternRefName\":\"control_0_index_pattern\"},{\"id\":\"1523481176519\",\"fieldName\":\"sound.keyword\",\"parent\":\"1523481216736\",\"label\":\"Animal sounds\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":5,\"order\":\"desc\"},\"indexPatternRefName\":\"control_1_index_pattern\"}],\"updateFiltersOnChange\":false,\"useTimeFilter\":false,\"pinFilters\":false},\"aggs\":[]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "42535e30-3dcd-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "control_0_index_pattern", + "type": "index-pattern" + }, + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "control_1_index_pattern", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:31.124Z", + "version": "WzIyOSwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "2", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "46c8b580-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:04:16.472Z", + "version": "WzIwMywxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "vis with missing index pattern", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"type\":\"pie\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}],\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"title\":\"vis with missing index pattern\"}" + }, + "coreMigrationVersion": "8.0.1", + "id": "47b5cf60-9e93-11ea-853e-adc0effaf76d", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "1b1789d0-9e93-11ea-853e-adc0effaf76d", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2020-05-25T15:16:27.743Z", + "version": "WzI2OCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[{\"meta\":{\"field\":\"isDog\",\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"isDog\",\"value\":\"true\",\"params\":{\"value\":true},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"script\":{\"script\":{\"inline\":\"boolean compare(Supplier s, def v) {return s.get() == v;}compare(() -> { return doc['animal.keyword'].value == 'dog' }, params.value);\",\"lang\":\"painless\",\"params\":{\"value\":true}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"query\":\"weightLbs:>40\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Rendering Test: scripted filter and query", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: scripted filter and query\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"sound.keyword\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "4c0c3f90-3e5a-11e8-9fc3-39e49624228e", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:33.166Z", + "version": "WzI0MCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Rendering Test: markdown", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: markdown\",\"type\":\"markdown\",\"params\":{\"fontSize\":20,\"openLinksInNewTab\":false,\"markdown\":\"I'm a markdown!\"},\"aggs\":[]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "4c0f47e0-3dcd-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T15:06:31.111Z", + "version": "WzIyNiwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "zz", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "4f0fd980-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:04:30.360Z", + "version": "WzIwMSwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}" + }, + "optionsJSON": "{\"hidePanelTitles\":false,\"useMargins\":true}", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"6cfbe6cc-1872-4cb4-9455-a02eeb75127e\"},\"panelIndex\":\"6cfbe6cc-1872-4cb4-9455-a02eeb75127e\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_6cfbe6cc-1872-4cb4-9455-a02eeb75127e\"}]", + "timeRestore": false, + "title": "dashboard with missing index pattern", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "502e63a0-9e93-11ea-853e-adc0effaf76d", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [ + { + "id": "47b5cf60-9e93-11ea-853e-adc0effaf76d", + "name": "6cfbe6cc-1872-4cb4-9455-a02eeb75127e:panel_6cfbe6cc-1872-4cb4-9455-a02eeb75127e", + "type": "visualization" + } + ], + "type": "dashboard", + "updated_at": "2020-05-25T15:16:27.743Z", + "version": "WzI2OSwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Rendering Test: animal sounds pie", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: animal sounds pie\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"sound.keyword\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "50643b60-3dd3-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:34.195Z", + "version": "WzI0MSwxXQ==" +} + +{ + "attributes": { + "columns": [ + "agent", + "bytes", + "clientip" + ], + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"query\":\"clientip : 73.14.212.83\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"range\",\"key\":\"bytes\",\"value\":\"100 to 1,000\",\"params\":{\"gte\":100,\"lt\":1000},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"range\":{\"bytes\":{\"gte\":100,\"lt\":1000}},\"$state\":{\"store\":\"appState\"}}],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "sort": [ + [ + "@timestamp", + "desc" + ] + ], + "title": "Bytes and kuery in saved search with filter", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "55d37a30-4197-11e8-bb13-d53698fb349a", + "migrationVersion": { + "search": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + } + ], + "type": "search", + "updated_at": "2018-04-16T16:58:07.059Z", + "version": "WzIxNywxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"bytes:>9000\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Filter Bytes Test: split by geo with query", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: split by geo with query\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"sum\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"geo.src\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "584c0300-4191-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T18:36:30.315Z", + "version": "WzI1OSwxXQ==" +} + +{ + "attributes": { + "columns": [ + "animal", + "isDog", + "name", + "sound", + "weightLbs" + ], + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"lucene\",\"query\":\"weightLbs:>40\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "sort": [ + [ + "weightLbs", + "desc" + ] + ], + "title": "animal weights", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "a16d1990-3dca-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "search": "8.0.0" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "search", + "updated_at": "2018-04-11T20:55:26.317Z", + "version": "WzE4NiwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + }, + "savedSearchRefName": "search_0", + "title": "Rendering Test: animal weights linked to search", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: animal weights linked to search\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"name.keyword\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "771b4f10-3e59-11e8-9fc3-39e49624228e", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "a16d1990-3dca-11e8-8660-4d65aa086b3c", + "name": "search_0", + "type": "search" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:34.200Z", + "version": "WzI0MiwxXQ==" +} + +{ + "attributes": { + "description": "dashboard with scripted filter, negated filter and query", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"weightLbs:<50\"},\"filter\":[{\"$state\":{\"store\":\"appState\"},\"meta\":{\"alias\":null,\"disabled\":false,\"key\":\"name.keyword\",\"negate\":true,\"params\":{\"query\":\"Fee Fee\",\"type\":\"phrase\"},\"type\":\"phrase\",\"value\":\"Fee Fee\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"name.keyword\":{\"query\":\"Fee Fee\",\"type\":\"phrase\"}}}},{\"$state\":{\"store\":\"appState\"},\"meta\":{\"alias\":\"is dog\",\"disabled\":false,\"field\":\"isDog\",\"key\":\"isDog\",\"negate\":false,\"params\":{\"value\":true},\"type\":\"phrase\",\"value\":\"true\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[1].meta.index\"},\"script\":{\"script\":{\"inline\":\"boolean compare(Supplier s, def v) {return s.get() == v;}compare(() -> { return doc['animal.keyword'].value == 'dog' }, params.value);\",\"lang\":\"painless\",\"params\":{\"value\":true}}}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":true,\"hidePanelTitles\":false,\"useMargins\":true}", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"4\"},\"panelIndex\":\"4\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_4\"}]", + "refreshInterval": { + "display": "Off", + "pause": false, + "section": 0, + "value": 0 + }, + "timeFrom": "Wed Apr 12 2017 10:06:21 GMT-0400", + "timeRestore": true, + "timeTo": "Thu Apr 12 2018 10:06:21 GMT-0400", + "title": "filters", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "5bac3a80-3e5b-11e8-9fc3-39e49624228e", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + }, + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[1].meta.index", + "type": "index-pattern" + }, + { + "id": "771b4f10-3e59-11e8-9fc3-39e49624228e", + "name": "1:panel_1", + "type": "visualization" + }, + { + "id": "4c0c3f90-3e5a-11e8-9fc3-39e49624228e", + "name": "3:panel_3", + "type": "visualization" + }, + { + "id": "50643b60-3dd3-11e8-b2b9-5d5dc1715159", + "name": "4:panel_4", + "type": "visualization" + } + ], + "type": "dashboard", + "updated_at": "2018-04-12T14:11:13.576Z", + "version": "WzIwOSwxXQ==" +} + +{ + "attributes": { + "fields": "[{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"activity level\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"barking level\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"breed\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"breed.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"size\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"size.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"trainability\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", + "title": "dogbreeds" + }, + "coreMigrationVersion": "8.0.1", + "id": "f908c8e0-3e6d-11e8-bbb9-e15942d5d48c", + "migrationVersion": { + "index-pattern": "8.0.0" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2018-04-12T16:24:29.357Z", + "version": "WzIxMCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "non timebased line chart - dog data", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"non timebased line chart - dog data\",\"type\":\"line\",\"params\":{\"type\":\"line\",\"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,\"filter\":true},\"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\":\"Max trainability\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"line\",\"mode\":\"normal\",\"data\":{\"label\":\"Max trainability\",\"id\":\"1\"},\"valueAxis\":\"ValueAxis-1\",\"drawLinesBetweenPoints\":true,\"showCircles\":true},{\"show\":true,\"mode\":\"normal\",\"type\":\"line\",\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"data\":{\"id\":\"3\",\"label\":\"Max barking level\"},\"valueAxis\":\"ValueAxis-1\"},{\"show\":true,\"mode\":\"normal\",\"type\":\"line\",\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"data\":{\"id\":\"4\",\"label\":\"Max activity level\"},\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false,\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"isVislibVis\":true,\"detailedTooltip\":true,\"fittingFunction\":\"zero\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"max\",\"schema\":\"metric\",\"params\":{\"field\":\"trainability\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"breed.keyword\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}},{\"id\":\"3\",\"enabled\":true,\"type\":\"max\",\"schema\":\"metric\",\"params\":{\"field\":\"barking level\"}},{\"id\":\"4\",\"enabled\":true,\"type\":\"max\",\"schema\":\"metric\",\"params\":{\"field\":\"activity level\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "5e085850-3e6e-11e8-bbb9-e15942d5d48c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "f908c8e0-3e6d-11e8-bbb9-e15942d5d48c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-12T16:27:17.973Z", + "version": "WzIxMSwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "zz 2", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "60659030-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:04:59.443Z", + "version": "WzE5NiwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":0,\"y\":0,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"w\":24,\"h\":15,\"x\":24,\"y\":0,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"}]", + "refreshInterval": { + "display": "Off", + "pause": false, + "value": 0 + }, + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "title": "dashboard with filter", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "61c58ad0-3dd3-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + }, + { + "id": "50643b60-3dd3-11e8-b2b9-5d5dc1715159", + "name": "1:panel_1", + "type": "visualization" + }, + { + "id": "a16d1990-3dca-11e8-8660-4d65aa086b3c", + "name": "2:panel_2", + "type": "search" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z", + "version": "WzE4OCwxXQ==" +} + +{ + "attributes": { + "columns": [ + "animal", + "sound", + "weightLbs" + ], + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"lucene\",\"query\":\"weightLbs:>10\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"sound.keyword\",\"value\":\"growl\",\"params\":{\"query\":\"growl\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"sound.keyword\":{\"query\":\"growl\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "sort": [ + [ + "@timestamp", + "desc" + ] + ], + "title": "Search created in 6.2", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "6351c590-53cb-11e8-b481-c9426d020fcd", + "migrationVersion": { + "search": "8.0.0" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + } + ], + "type": "search", + "updated_at": "2018-05-09T20:56:04.457Z", + "version": "WzI2NywxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "zz 3", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "65227c00-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:05:07.392Z", + "version": "WzE5NywxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "zz 4", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "6803a2f0-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:05:12.223Z", + "version": "WzE5OCwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "zz 5", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "6b18f940-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:05:17.396Z", + "version": "WzE5OSwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "dashboard-name-has-dashes", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "6c0b16e0-3dd3-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T21:58:09.486Z", + "version": "WzE5MiwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "zz 6", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "6e12ff60-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:05:22.390Z", + "version": "WzIwMCwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\n \"query\": {\n \"language\": \"kuery\",\n \"query\": \"\"\n },\n \"filter\": [\n {\n \"meta\": {\n \"alias\": null,\n \"negate\": false,\n \"disabled\": true,\n \"type\": \"phrase\",\n \"key\": \"name\",\n \"params\": {\n \"query\": \"moo\"\n },\n \"indexRefName\": \"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"\n },\n \"query\": {\n \"match_phrase\": {\n \"name\": \"moo\"\n }\n },\n \"$state\": {\n \"store\": \"appState\"\n }\n },\n {\n \"meta\": {\n \"alias\": null,\n \"negate\": false,\n \"disabled\": true,\n \"type\": \"phrase\",\n \"key\": \"baad-field\",\n \"params\": {\n \"query\": \"moo\"\n },\n \"indexRefName\": \"kibanaSavedObjectMeta.searchSourceJSON.filter[1].meta.index\"\n },\n \"query\": {\n \"match_phrase\": {\n \"baad-field\": \"moo\"\n }\n },\n \"$state\": {\n \"store\": \"appState\"\n }\n },\n {\n \"meta\": {\n \"alias\": null,\n \"negate\": false,\n \"disabled\": false,\n \"type\": \"phrase\",\n \"key\": \"@timestamp\",\n \"params\": {\n \"query\": \"123\"\n },\n \"indexRefName\": \"kibanaSavedObjectMeta.searchSourceJSON.filter[2].meta.index\"\n },\n \"query\": {\n \"match_phrase\": {\n \"@timestamp\": \"123\"\n }\n },\n \"$state\": {\n \"store\": \"appState\"\n }\n },\n {\n \"meta\": {\n \"alias\": null,\n \"negate\": false,\n \"disabled\": false,\n \"type\": \"exists\",\n \"key\": \"extension\",\n \"value\": \"exists\",\n \"indexRefName\": \"kibanaSavedObjectMeta.searchSourceJSON.filter[3].meta.index\"\n },\n \"exists\": {\n \"field\": \"extension\"\n },\n \"$state\": {\n \"store\": \"appState\"\n }\n },\n {\n \"meta\": {\n \"alias\": null,\n \"negate\": false,\n \"disabled\": false,\n \"type\": \"phrase\",\n \"key\": \"banana\",\n \"params\": {\n \"query\": \"yellow\"\n }\n },\n \"query\": {\n \"match_phrase\": {\n \"banana\": \"yellow\"\n }\n },\n \"$state\": {\n \"store\": \"appState\"\n }\n }\n ]\n}" + }, + "optionsJSON": "{\n \"hidePanelTitles\": false,\n \"useMargins\": true\n}", + "panelsJSON": "[{\"version\":\"8.0.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"94a3dc1d-508a-4d42-a480-65b158925ba0\"},\"panelIndex\":\"94a3dc1d-508a-4d42-a480-65b158925ba0\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_94a3dc1d-508a-4d42-a480-65b158925ba0\"}]", + "refreshInterval": { + "pause": true, + "value": 0 + }, + "timeFrom": "now-10y", + "timeRestore": true, + "timeTo": "now", + "title": "dashboard with bad filters", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "6eb8a840-a32e-11ea-88c2-d56dd2b14bd7", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-bad-index", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + }, + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[1].meta.index", + "type": "index-pattern" + }, + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[2].meta.index", + "type": "index-pattern" + }, + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[3].meta.index", + "type": "index-pattern" + }, + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[4].meta.index", + "type": "index-pattern" + }, + { + "id": "50643b60-3dd3-11e8-b2b9-5d5dc1715159", + "name": "94a3dc1d-508a-4d42-a480-65b158925ba0:panel_94a3dc1d-508a-4d42-a480-65b158925ba0", + "type": "visualization" + } + ], + "type": "dashboard", + "updated_at": "2020-06-04T09:26:04.272Z", + "version": "WzI3MCwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "zz 7", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "708fe640-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:05:26.564Z", + "version": "WzIwNCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"geo.src\",\"value\":\"US\",\"params\":{\"query\":\"US\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"geo.src\":{\"query\":\"US\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Filter Bytes Test: max bytes in US - area chart with filter", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: max bytes in US - area chart with filter\",\"type\":\"area\",\"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,\"filter\":true},\"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\":\"Max bytes\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Max bytes\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false,\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"isVislibVis\":true,\"detailedTooltip\":true,\"fittingFunction\":\"zero\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"max\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1,\"extended_bounds\":{}}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "760a9060-4190-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:35.235Z", + "version": "WzI0NiwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Filter Bytes Test: tsvb top n with bytes filter", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: tsvb top n with bytes filter\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"top_n\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"filters\",\"metrics\":[{\"id\":\"482d6560-4194-11e8-a461-7d278185cba4\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"terms_field\":\"clientip\",\"filter\":{\"query\":\"Filter Bytes Test:>1000\",\"language\":\"lucene\"},\"override_index_pattern\":0,\"series_index_pattern\":\"logstash-*\",\"series_time_field\":\"utc_time\",\"series_interval\":\"1m\",\"value_template\":\"\",\"split_filters\":[{\"filter\":{\"query\":\"Filter Bytes Test:>100\",\"language\":\"lucene\"},\"label\":\"\",\"color\":\"#68BC00\",\"id\":\"39a107e0-4194-11e8-a461-7d278185cba4\"}],\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"4fd5b150-4194-11e8-a461-7d278185cba4\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"4fd5b151-4194-11e8-a461-7d278185cba4\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"Filter Bytes Test:>3000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"06893260-4194-11e8-a461-7d278185cba4\"}],\"bar_color_rules\":[{\"id\":\"36a0e740-4194-11e8-a461-7d278185cba4\"}],\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" + }, + "coreMigrationVersion": "8.0.1", + "id": "76c7f020-4194-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T15:06:34.583Z", + "version": "WzI0MywxXQ==" +} + +{ + "attributes": { + "description": "and_descriptions_has_underscores", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "dashboard_with_underscores", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "76d03330-3dd3-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T21:58:27.555Z", + "version": "WzE5MCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Rendering Test: tag cloud", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: tag cloud\",\"type\":\"tagcloud\",\"params\":{\"scale\":\"linear\",\"orientation\":\"single\",\"minFontSize\":18,\"maxFontSize\":72,\"showLabel\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"geo.src\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "78803be0-3dcd-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:32.127Z", + "version": "WzIxOSwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "Hi i have a lot of words in my dashboard name! It's pretty long i wonder what it'll look like", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "7b8d50a0-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:05:45.002Z", + "version": "WzIwNSwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "bye", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "7e42d3b0-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:05:49.547Z", + "version": "WzIwNiwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Rendering Test: vega", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: vega\",\"type\":\"vega\",\"params\":{\"spec\":\"{\\n/*\\n\\nWelcome to Vega visualizations. Here you can design your own dataviz from scratch using a declarative language called Vega, or its simpler form Vega-Lite. In Vega, you have the full control of what data is loaded, even from multiple sources, how that data is transformed, and what visual elements are used to show it. Use help icon to view Vega examples, tutorials, and other docs. Use the wrench icon to reformat this text, or to remove comments.\\n\\nThis example graph shows the document count in all indexes in the current time range. You might need to adjust the time filter in the upper right corner.\\n*/\\n\\n $schema: https://vega.github.io/schema/vega-lite/v2.json\\n title: Event counts from all indexes\\n\\n // Define the data source\\n data: {\\n url: {\\n/*\\nAn object instead of a string for the \\\"url\\\" param is treated as an Elasticsearch query. Anything inside this object is not part of the Vega language, but only understood by Kibana and Elasticsearch server. This query counts the number of documents per time interval, assuming you have a @timestamp field in your data.\\n\\nKibana has a special handling for the fields surrounded by \\\"%\\\". They are processed before the the query is sent to Elasticsearch. This way the query becomes context aware, and can use the time range and the dashboard filters.\\n*/\\n\\n // Apply dashboard context filters when set\\n %context%: true\\n // Filter the time picker (upper right corner) with this field\\n %timefield%: @timestamp\\n\\n/*\\nSee .search() documentation for : https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html#api-search\\n*/\\n\\n // Which index to search\\n index: _all\\n // Aggregate data by the time field into time buckets, counting the number of documents in each bucket.\\n body: {\\n aggs: {\\n time_buckets: {\\n date_histogram: {\\n // Use date histogram aggregation on @timestamp field\\n field: @timestamp\\n // The interval value will depend on the daterange picker (true), or use an integer to set an approximate bucket count\\n interval: {%autointerval%: true}\\n // Make sure we get an entire range, even if it has no data\\n extended_bounds: {\\n // Use the current time range's start and end\\n min: {%timefilter%: \\\"min\\\"}\\n max: {%timefilter%: \\\"max\\\"}\\n }\\n // Use this for linear (e.g. line, area) graphs. Without it, empty buckets will not show up\\n min_doc_count: 0\\n }\\n }\\n }\\n // Speed up the response by only including aggregation results\\n size: 0\\n }\\n }\\n/*\\nElasticsearch will return results in this format:\\n\\naggregations: {\\n time_buckets: {\\n buckets: [\\n {\\n key_as_string: 2015-11-30T22:00:00.000Z\\n key: 1448920800000\\n doc_count: 0\\n },\\n {\\n key_as_string: 2015-11-30T23:00:00.000Z\\n key: 1448924400000\\n doc_count: 0\\n }\\n ...\\n ]\\n }\\n}\\n\\nFor our graph, we only need the list of bucket values. Use the format.property to discard everything else.\\n*/\\n format: {property: \\\"aggregations.time_buckets.buckets\\\"}\\n }\\n\\n // \\\"mark\\\" is the graphics element used to show our data. Other mark values are: area, bar, circle, line, point, rect, rule, square, text, and tick. See https://vega.github.io/vega-lite/docs/mark.html\\n mark: line\\n\\n // \\\"encoding\\\" tells the \\\"mark\\\" what data to use and in what way. See https://vega.github.io/vega-lite/docs/encoding.html\\n encoding: {\\n x: {\\n // The \\\"key\\\" value is the timestamp in milliseconds. Use it for X axis.\\n field: key\\n type: temporal\\n axis: {title: false} // Customize X axis format\\n }\\n y: {\\n // The \\\"doc_count\\\" is the count per bucket. Use it for Y axis.\\n field: doc_count\\n type: quantitative\\n axis: {title: \\\"Document count\\\"}\\n }\\n }\\n}\\n\"},\"aggs\":[]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "7fda8ee0-3dcd-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T15:06:30.344Z", + "version": "WzIzMCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Filter Bytes Test: Data table top hit with significant terms geo.src", + "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: Data table top hit with significant terms geo.src\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMeticsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\",\"showToolbar\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"top_hits\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\",\"aggregate\":\"average\",\"size\":1,\"sortField\":\"@timestamp\",\"sortOrder\":\"desc\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"significant_terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"geo.src\",\"size\":10}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "7ff2c4c0-4191-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:36.270Z", + "version": "WzI1MCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Filter Bytes Test: vega", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: vega\",\"type\":\"vega\",\"params\":{\"spec\":\"{ \\nconfig: { kibana: { renderer: \\\"svg\\\" }},\\n/*\\n\\nWelcome to Vega visualizations. Here you can design your own dataviz from scratch using a declarative language called Vega, or its simpler form Vega-Lite. In Vega, you have the full control of what data is loaded, even from multiple sources, how that data is transformed, and what visual elements are used to show it. Use help icon to view Vega examples, tutorials, and other docs. Use the wrench icon to reformat this text, or to remove comments.\\n\\nThis example graph shows the document count in all indexes in the current time range. You might need to adjust the time filter in the upper right corner.\\n*/\\n\\n $schema: https://vega.github.io/schema/vega-lite/v2.json\\n title: Event counts from all indexes\\n\\n // Define the data source\\n data: {\\n url: {\\n/*\\nAn object instead of a string for the \\\"url\\\" param is treated as an Elasticsearch query. Anything inside this object is not part of the Vega language, but only understood by Kibana and Elasticsearch server. This query counts the number of documents per time interval, assuming you have a @timestamp field in your data.\\n\\nKibana has a special handling for the fields surrounded by \\\"%\\\". They are processed before the the query is sent to Elasticsearch. This way the query becomes context aware, and can use the time range and the dashboard filters.\\n*/\\n\\n // Apply dashboard context filters when set\\n %context%: true\\n // Filter the time picker (upper right corner) with this field\\n %timefield%: @timestamp\\n\\n/*\\nSee .search() documentation for : https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html#api-search\\n*/\\n\\n // Which index to search\\n index: _all\\n // Aggregate data by the time field into time buckets, counting the number of documents in each bucket.\\n body: {\\n aggs: {\\n time_buckets: {\\n date_histogram: {\\n // Use date histogram aggregation on @timestamp field\\n field: @timestamp\\n // The interval value will depend on the daterange picker (true), or use an integer to set an approximate bucket count\\n interval: {%autointerval%: true}\\n // Make sure we get an entire range, even if it has no data\\n extended_bounds: {\\n // Use the current time range's start and end\\n min: {%timefilter%: \\\"min\\\"}\\n max: {%timefilter%: \\\"max\\\"}\\n }\\n // Use this for linear (e.g. line, area) graphs. Without it, empty buckets will not show up\\n min_doc_count: 0\\n }\\n }\\n }\\n // Speed up the response by only including aggregation results\\n size: 0\\n }\\n }\\n/*\\nElasticsearch will return results in this format:\\n\\naggregations: {\\n time_buckets: {\\n buckets: [\\n {\\n key_as_string: 2015-11-30T22:00:00.000Z\\n key: 1448920800000\\n doc_count: 0\\n },\\n {\\n key_as_string: 2015-11-30T23:00:00.000Z\\n key: 1448924400000\\n doc_count: 0\\n }\\n ...\\n ]\\n }\\n}\\n\\nFor our graph, we only need the list of bucket values. Use the format.property to discard everything else.\\n*/\\n format: {property: \\\"aggregations.time_buckets.buckets\\\"}\\n }\\n\\n // \\\"mark\\\" is the graphics element used to show our data. Other mark values are: area, bar, circle, line, point, rect, rule, square, text, and tick. See https://vega.github.io/vega-lite/docs/mark.html\\n mark: line\\n\\n // \\\"encoding\\\" tells the \\\"mark\\\" what data to use and in what way. See https://vega.github.io/vega-lite/docs/encoding.html\\n encoding: {\\n x: {\\n // The \\\"key\\\" value is the timestamp in milliseconds. Use it for X axis.\\n field: key\\n type: temporal\\n axis: {title: false} // Customize X axis format\\n }\\n y: {\\n // The \\\"doc_count\\\" is the count per bucket. Use it for Y axis.\\n field: doc_count\\n type: quantitative\\n axis: {title: \\\"Document count\\\"}\\n }\\n }\\n}\\n\"},\"aggs\":[]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "8090dcb0-4195-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T19:28:21.967Z", + "version": "WzI2MSwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "last", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "846988b0-3dd4-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:05:59.867Z", + "version": "WzIwNywxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"size.keyword\",\"value\":\"extra large\",\"params\":{\"query\":\"extra large\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"size.keyword\":{\"query\":\"extra large\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Rendering Test: non timebased line chart - dog data - with filter", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"aggs\":[{\"enabled\":true,\"id\":\"1\",\"params\":{\"field\":\"trainability\"},\"schema\":\"metric\",\"type\":\"max\"},{\"enabled\":true,\"id\":\"2\",\"params\":{\"field\":\"breed.keyword\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"order\":\"desc\",\"orderBy\":\"1\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"size\":5},\"schema\":\"segment\",\"type\":\"terms\"},{\"enabled\":true,\"id\":\"3\",\"params\":{\"field\":\"barking level\"},\"schema\":\"metric\",\"type\":\"max\"},{\"enabled\":true,\"id\":\"4\",\"params\":{\"field\":\"activity level\"},\"schema\":\"metric\",\"type\":\"max\"}],\"params\":{\"addLegend\":true,\"addTimeMarker\":false,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"labels\":{\"show\":true,\"truncate\":100,\"filter\":true},\"position\":\"bottom\",\"scale\":{\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{},\"type\":\"category\"}],\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"legendPosition\":\"right\",\"seriesParams\":[{\"data\":{\"id\":\"1\",\"label\":\"Max trainability\"},\"drawLinesBetweenPoints\":true,\"mode\":\"normal\",\"show\":\"true\",\"showCircles\":true,\"type\":\"line\",\"valueAxis\":\"ValueAxis-1\"},{\"data\":{\"id\":\"3\",\"label\":\"Max barking level\"},\"drawLinesBetweenPoints\":true,\"mode\":\"normal\",\"show\":true,\"showCircles\":true,\"type\":\"line\",\"valueAxis\":\"ValueAxis-1\"},{\"data\":{\"id\":\"4\",\"label\":\"Max activity level\"},\"drawLinesBetweenPoints\":true,\"mode\":\"normal\",\"show\":true,\"showCircles\":true,\"type\":\"line\",\"valueAxis\":\"ValueAxis-1\"}],\"times\":[],\"type\":\"line\",\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"labels\":{\"filter\":false,\"rotate\":0,\"show\":true,\"truncate\":100},\"name\":\"LeftAxis-1\",\"position\":\"left\",\"scale\":{\"mode\":\"normal\",\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"Max trainability\"},\"type\":\"value\"}],\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"isVislibVis\":true,\"detailedTooltip\":true,\"fittingFunction\":\"zero\"},\"title\":\"Rendering Test: non timebased line chart - dog data - with filter\",\"type\":\"line\"}" + }, + "coreMigrationVersion": "8.0.1", + "id": "8bc8d6c0-3e6e-11e8-bbb9-e15942d5d48c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "f908c8e0-3e6d-11e8-bbb9-e15942d5d48c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "f908c8e0-3e6d-11e8-bbb9-e15942d5d48c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:31.173Z", + "version": "WzIyOCwxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "* hi & $%!!@# 漢字 ^--=++[]{};'~`~<>?,./:\";'\\|\\\\ special chars", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "9b780cd0-3dd3-11e8-b2b9-5d5dc1715159", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [], + "type": "dashboard", + "updated_at": "2018-04-11T22:00:07.322Z", + "version": "WzE5MSwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Filter Bytes Test: timelion split 5 on bytes", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: timelion split 5 on bytes\",\"type\":\"timelion\",\"params\":{\"expression\":\".es(*, split=bytes:5)\",\"interval\":\"auto\"},\"aggs\":[]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "9bebe980-4192-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T15:59:42.648Z", + "version": "WzI1NCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Filter Bytes Test: tsvb markdown", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: tsvb markdown\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"markdown\",\"series\":[{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"filters\",\"metrics\":[{\"id\":\"482d6560-4194-11e8-a461-7d278185cba4\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"terms_field\":\"clientip\",\"filter\":{\"query\":\"Filter Bytes Test:>1000\",\"language\":\"lucene\"},\"override_index_pattern\":0,\"series_index_pattern\":\"logstash-*\",\"series_time_field\":\"utc_time\",\"series_interval\":\"1m\",\"value_template\":\"\",\"split_filters\":[{\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"label\":\"\",\"color\":\"#68BC00\",\"id\":\"39a107e0-4194-11e8-a461-7d278185cba4\"}],\"label\":\"\",\"var_name\":\"\",\"split_color_mode\":\"gradient\",\"series_drop_last_bucket\":1}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"06893260-4194-11e8-a461-7d278185cba4\"}],\"bar_color_rules\":[{\"id\":\"36a0e740-4194-11e8-a461-7d278185cba4\"}],\"markdown\":\"{{bytes_1000.last.formatted}}\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" + }, + "coreMigrationVersion": "8.0.1", + "id": "9fb4c670-4194-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T16:32:59.086Z", + "version": "WzI1NywxXQ==" +} + +{ + "attributes": { + "description": "I have two visualizations that are created off a non time based index", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":0,\"y\":0,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":24,\"y\":0,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"}]", + "timeRestore": false, + "title": "Non time based", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "a5d56330-3e6e-11e8-bbb9-e15942d5d48c", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [ + { + "id": "5e085850-3e6e-11e8-bbb9-e15942d5d48c", + "name": "1:panel_1", + "type": "visualization" + }, + { + "id": "8bc8d6c0-3e6e-11e8-bbb9-e15942d5d48c", + "name": "2:panel_2", + "type": "visualization" + } + ], + "type": "dashboard", + "updated_at": "2018-04-12T16:29:18.435Z", + "version": "WzIxMiwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Filter Bytes Test: standard deviation heatmap with other bucket", + "uiStateJSON": "{\"vis\":{\"defaultColors\":{\"-4,000 - 1,000\":\"rgb(247,252,245)\",\"1,000 - 6,000\":\"rgb(199,233,192)\",\"6,000 - 11,000\":\"rgb(116,196,118)\",\"11,000 - 16,000\":\"rgb(35,139,69)\"}}}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: standard deviation heatmap with other bucket\",\"type\":\"heatmap\",\"params\":{\"type\":\"heatmap\",\"addTooltip\":true,\"addLegend\":true,\"enableHover\":false,\"legendPosition\":\"right\",\"times\":[],\"colorsNumber\":4,\"colorSchema\":\"Greens\",\"setColorRange\":false,\"colorsRange\":[],\"invertColors\":false,\"percentageMode\":false,\"valueAxes\":[{\"show\":false,\"id\":\"ValueAxis-1\",\"type\":\"value\",\"scale\":{\"type\":\"linear\",\"defaultYExtents\":false},\"labels\":{\"show\":false,\"rotate\":0,\"overwriteColor\":false,\"color\":\"#555\"}}]},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"std_dev\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"geo.src\",\"otherBucket\":true,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":10,\"order\":\"desc\",\"orderBy\":\"_term\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "b3e70d00-4190-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:35.236Z", + "version": "WzI0NywxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Filter Bytes Test: max bytes guage percent mode", + "uiStateJSON": "{\"vis\":{\"defaultColors\":{\"0 - 1\":\"rgb(0,104,55)\",\"1 - 15\":\"rgb(255,255,190)\",\"15 - 100\":\"rgb(165,0,38)\"}}}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: max bytes guage percent mode\",\"type\":\"gauge\",\"params\":{\"type\":\"gauge\",\"addTooltip\":true,\"addLegend\":true,\"isDisplayWarning\":false,\"gauge\":{\"extendRange\":true,\"percentageMode\":true,\"gaugeType\":\"Arc\",\"gaugeStyle\":\"Full\",\"backStyle\":\"Full\",\"orientation\":\"vertical\",\"colorSchema\":\"Green to Red\",\"gaugeColorMode\":\"Labels\",\"colorsRange\":[{\"from\":0,\"to\":500},{\"from\":500,\"to\":7500},{\"from\":7500,\"to\":50000}],\"invertColors\":false,\"labels\":{\"show\":true,\"color\":\"black\"},\"scale\":{\"show\":true,\"labels\":false,\"color\":\"#333\"},\"type\":\"meter\",\"style\":{\"bgWidth\":0.9,\"width\":0.9,\"mask\":false,\"bgMask\":false,\"maskBars\":50,\"bgFill\":\"#eee\",\"bgColor\":false,\"subText\":\"Im subtext\",\"fontSize\":60,\"labelColor\":true},\"alignment\":\"horizontal\"}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"max\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "c10c6b00-4191-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:36.267Z", + "version": "WzI0OCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[{\"meta\":{\"negate\":true,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"bytes\",\"value\":\"0\",\"params\":{\"query\":0,\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"bytes\":{\"query\":0,\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Filter Bytes Test: tag cloud with not 0 bytes filter", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Filter Bytes Test: tag cloud with not 0 bytes filter\",\"type\":\"tagcloud\",\"params\":{\"scale\":\"linear\",\"orientation\":\"single\",\"minFontSize\":18,\"maxFontSize\":72,\"showLabel\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"bytes\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "df72ad40-4194-11e8-bb13-d53698fb349a", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:36.276Z", + "version": "WzI1MSwxXQ==" +} + +{ + "attributes": { + "description": "Bytes bytes and more bytes", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":15,\"w\":17,\"h\":8,\"i\":\"4\"},\"panelIndex\":\"4\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_4\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":30,\"w\":18,\"h\":13,\"i\":\"5\"},\"panelIndex\":\"5\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_5\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":37,\"w\":24,\"h\":12,\"i\":\"6\"},\"panelIndex\":\"6\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_6\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":18,\"y\":30,\"w\":9,\"h\":7,\"i\":\"7\"},\"panelIndex\":\"7\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_7\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":28,\"y\":23,\"w\":15,\"h\":13,\"i\":\"8\"},\"panelIndex\":\"8\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_8\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":43,\"w\":24,\"h\":15,\"i\":\"9\"},\"panelIndex\":\"9\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_9\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":49,\"w\":18,\"h\":12,\"i\":\"10\"},\"panelIndex\":\"10\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_10\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":58,\"w\":24,\"h\":15,\"i\":\"11\"},\"panelIndex\":\"11\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_11\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":61,\"w\":5,\"h\":4,\"i\":\"12\"},\"panelIndex\":\"12\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_12\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":73,\"w\":17,\"h\":6,\"i\":\"13\"},\"panelIndex\":\"13\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_13\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":65,\"w\":24,\"h\":15,\"i\":\"14\"},\"panelIndex\":\"14\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_14\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":79,\"w\":24,\"h\":6,\"i\":\"15\"},\"panelIndex\":\"15\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_15\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":80,\"w\":24,\"h\":15,\"i\":\"16\"},\"panelIndex\":\"16\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_16\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":85,\"w\":13,\"h\":11,\"i\":\"17\"},\"panelIndex\":\"17\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_17\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":24,\"y\":95,\"w\":23,\"h\":11,\"i\":\"18\"},\"panelIndex\":\"18\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_18\"}]", + "refreshInterval": { + "display": "Off", + "pause": false, + "value": 0 + }, + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "title": "All about those bytes", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "b60de070-4197-11e8-bb13-d53698fb349a", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [ + { + "id": "7ff2c4c0-4191-11e8-bb13-d53698fb349a", + "name": "1:panel_1", + "type": "visualization" + }, + { + "id": "03d2afd0-4192-11e8-bb13-d53698fb349a", + "name": "2:panel_2", + "type": "visualization" + }, + { + "id": "63983430-4192-11e8-bb13-d53698fb349a", + "name": "3:panel_3", + "type": "visualization" + }, + { + "id": "0ca8c600-4195-11e8-bb13-d53698fb349a", + "name": "4:panel_4", + "type": "visualization" + }, + { + "id": "c10c6b00-4191-11e8-bb13-d53698fb349a", + "name": "5:panel_5", + "type": "visualization" + }, + { + "id": "760a9060-4190-11e8-bb13-d53698fb349a", + "name": "6:panel_6", + "type": "visualization" + }, + { + "id": "1dcdfe30-4192-11e8-bb13-d53698fb349a", + "name": "7:panel_7", + "type": "visualization" + }, + { + "id": "584c0300-4191-11e8-bb13-d53698fb349a", + "name": "8:panel_8", + "type": "visualization" + }, + { + "id": "b3e70d00-4190-11e8-bb13-d53698fb349a", + "name": "9:panel_9", + "type": "visualization" + }, + { + "id": "df72ad40-4194-11e8-bb13-d53698fb349a", + "name": "10:panel_10", + "type": "visualization" + }, + { + "id": "9bebe980-4192-11e8-bb13-d53698fb349a", + "name": "11:panel_11", + "type": "visualization" + }, + { + "id": "9fb4c670-4194-11e8-bb13-d53698fb349a", + "name": "12:panel_12", + "type": "visualization" + }, + { + "id": "35417e50-4194-11e8-bb13-d53698fb349a", + "name": "13:panel_13", + "type": "visualization" + }, + { + "id": "039e4770-4194-11e8-bb13-d53698fb349a", + "name": "14:panel_14", + "type": "visualization" + }, + { + "id": "76c7f020-4194-11e8-bb13-d53698fb349a", + "name": "15:panel_15", + "type": "visualization" + }, + { + "id": "8090dcb0-4195-11e8-bb13-d53698fb349a", + "name": "16:panel_16", + "type": "visualization" + }, + { + "id": "29bd0240-4197-11e8-bb13-d53698fb349a", + "name": "17:panel_17", + "type": "visualization" + }, + { + "id": "55d37a30-4197-11e8-bb13-d53698fb349a", + "name": "18:panel_18", + "type": "search" + } + ], + "type": "dashboard", + "updated_at": "2018-04-16T17:00:48.503Z", + "version": "WzIxOCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Rendering Test: timelion", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: timelion\",\"type\":\"timelion\",\"params\":{\"expression\":\".es(*, metric=avg:bytes, split=ip:5)\",\"interval\":\"auto\"},\"aggs\":[]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "b92ae920-3dcc-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T15:06:31.110Z", + "version": "WzIyNCwxXQ==" +} + +{ + "attributes": { + "columns": [ + "agent", + "bytes", + "clientip" + ], + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "sort": [ + [ + "@timestamp", + "desc" + ] + ], + "title": "Rendering Test: saved search", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "be5accf0-3dca-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "search": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "search", + "updated_at": "2018-04-17T15:09:39.805Z", + "version": "WzI1MiwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal.keyword\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal.keyword\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"language\":\"lucene\",\"query\":\"\"}}" + }, + "savedSearchRefName": "search_0", + "title": "Filter Test: animals: linked to search with filter", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Filter Test: animals: linked to search with filter\",\"type\":\"pie\",\"params\":{\"addLegend\":true,\"addTooltip\":true,\"isDonut\":true,\"labels\":{\"last_level\":true,\"show\":false,\"truncate\":100,\"values\":true},\"legendPosition\":\"right\",\"type\":\"pie\",\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"name.keyword\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "befdb6b0-3e59-11e8-9fc3-39e49624228e", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + }, + { + "id": "a16d1990-3dca-11e8-8660-4d65aa086b3c", + "name": "search_0", + "type": "search" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T17:16:27.743Z", + "version": "WzI1OCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Rendering Test: tsvb-ts", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: tsvb-ts\",\"type\":\"metrics\",\"params\":{\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"timeseries\",\"series\":[{\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"count\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"use_kibana_indexes\":false,\"drop_last_bucket\":1},\"aggs\":[]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "c40f4d40-3dcc-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T15:06:30.347Z", + "version": "WzIzNSwxXQ==" +} + +{ + "attributes": { + "columns": [ + "agent", + "bytes", + "clientip" + ], + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[{\"meta\":{\"negate\":false,\"type\":\"phrase\",\"key\":\"bytes\",\"value\":\"1,607\",\"params\":{\"query\":1607,\"type\":\"phrase\"},\"disabled\":false,\"alias\":null,\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"bytes\":{\"query\":1607,\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "sort": [ + [ + "@timestamp", + "desc" + ] + ], + "title": "Filter Bytes Test: search with filter", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "ca5ada40-3dca-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "search": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + } + ], + "type": "search", + "updated_at": "2018-04-17T15:09:55.976Z", + "version": "WzI1MywxXQ==" +} + +{ + "attributes": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"weightLbs:<50\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":true,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"name.keyword\",\"value\":\"Fee Fee\",\"params\":{\"query\":\"Fee Fee\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"name.keyword\":{\"query\":\"Fee Fee\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":true,\"useMargins\":true,\"hidePanelTitles\":true}", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"}]", + "timeRestore": false, + "title": "bug", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "cbd3bc30-3e5a-11e8-9fc3-39e49624228e", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + }, + { + "id": "771b4f10-3e59-11e8-9fc3-39e49624228e", + "name": "1:panel_1", + "type": "visualization" + }, + { + "id": "befdb6b0-3e59-11e8-9fc3-39e49624228e", + "name": "2:panel_2", + "type": "visualization" + }, + { + "id": "4c0c3f90-3e5a-11e8-9fc3-39e49624228e", + "name": "3:panel_3", + "type": "visualization" + } + ], + "type": "dashboard", + "updated_at": "2018-04-12T14:07:12.243Z", + "version": "WzIwOCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Rendering Test: tsvb-metric", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: tsvb-metric\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"metric\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"sum_of_squares\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" + }, + "coreMigrationVersion": "8.0.1", + "id": "cc43fab0-3dcc-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T15:06:30.353Z", + "version": "WzIzNCwxXQ==" +} + +{ + "attributes": { + "description": "I have one of every visualization type since the last time I was created!", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"}]", + "refreshInterval": { + "display": "Off", + "pause": false, + "value": 0 + }, + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "title": "dashboard with table", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "d2525040-3dcd-11e8-8660-4d65aa086b3b", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [ + { + "id": "4b5d6ef0-3dcb-11e8-8660-4d65aa086b3c", + "name": "3:panel_3", + "type": "visualization" + } + ], + "type": "dashboard", + "updated_at": "2018-04-16T16:05:02.915Z", + "version": "WzIxNCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[{\"meta\":{\"negate\":true,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"geo.src\",\"value\":\"CN\",\"params\":{\"query\":\"CN\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"geo.src\":{\"query\":\"CN\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Rendering Test: area with not filter", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: area with not filter\",\"type\":\"area\",\"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,\"filter\":true},\"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\":\"Count\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false,\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"isVislibVis\":true,\"detailedTooltip\":true,\"fittingFunction\":\"zero\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1,\"extended_bounds\":{}}},{\"id\":\"3\",\"enabled\":true,\"type\":\"filters\",\"schema\":\"group\",\"params\":{\"filters\":[{\"input\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"label\":\"\"},{\"input\":{\"query\":\"bytes:>10\",\"language\":\"lucene\"}}]}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "e6140540-3dca-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:33.165Z", + "version": "WzIzOSwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Rendering Test: goal", + "uiStateJSON": "{\"vis\":{\"defaultColors\":{\"0 - 100\":\"rgb(0,104,55)\"}}}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: goal\",\"type\":\"goal\",\"params\":{\"addTooltip\":true,\"addLegend\":false,\"isDisplayWarning\":false,\"type\":\"gauge\",\"gauge\":{\"verticalSplit\":false,\"autoExtend\":false,\"percentageMode\":true,\"gaugeType\":\"Arc\",\"gaugeStyle\":\"Full\",\"backStyle\":\"Full\",\"orientation\":\"vertical\",\"useRanges\":false,\"colorSchema\":\"Green to Red\",\"gaugeColorMode\":\"None\",\"colorsRange\":[{\"from\":0,\"to\":4000}],\"invertColors\":false,\"labels\":{\"show\":true,\"color\":\"black\"},\"scale\":{\"show\":false,\"labels\":false,\"color\":\"#333\",\"width\":2},\"type\":\"meter\",\"style\":{\"bgFill\":\"#000\",\"bgColor\":false,\"labelColor\":false,\"subText\":\"\",\"fontSize\":60}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"geo.src\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":2,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + }, + "coreMigrationVersion": "8.0.1", + "id": "ffa2e0c0-3dcb-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [ + { + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:33.153Z", + "version": "WzIzNiwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Rendering Test: tsvb-guage", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: tsvb-guage\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"gauge\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_color_rules\":[{\"id\":\"e0be22e0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_width\":10,\"gauge_inner_width\":10,\"gauge_style\":\"half\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" + }, + "coreMigrationVersion": "8.0.1", + "id": "e4d8b430-3dcc-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T15:06:31.106Z", + "version": "WzIyNSwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Rendering Test: tsvb-markdown", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: tsvb-markdown\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"markdown\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_color_rules\":[{\"id\":\"e0be22e0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_width\":10,\"gauge_inner_width\":10,\"gauge_style\":\"half\",\"markdown\":\"\\nHi Avg last bytes: {{ average_of_bytes.last.raw }}\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" + }, + "coreMigrationVersion": "8.0.1", + "id": "f81134a0-3dcc-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T15:06:30.355Z", + "version": "WzIzMiwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Rendering Test: tsvb-topn", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: tsvb-topn\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"top_n\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" + }, + "coreMigrationVersion": "8.0.1", + "id": "df815d20-3dcc-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "visualization": "8.0.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2018-04-17T15:06:30.349Z", + "version": "WzIzMywxXQ==" +} + +{ + "attributes": { + "description": "I have one of every visualization type since the last time I was created!", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":30,\"w\":24,\"h\":15,\"i\":\"5\"},\"panelIndex\":\"5\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_5\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":30,\"w\":24,\"h\":15,\"i\":\"6\"},\"panelIndex\":\"6\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_6\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":45,\"w\":24,\"h\":15,\"i\":\"7\"},\"panelIndex\":\"7\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_7\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":45,\"w\":24,\"h\":15,\"i\":\"8\"},\"panelIndex\":\"8\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_8\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":60,\"w\":24,\"h\":15,\"i\":\"9\"},\"panelIndex\":\"9\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_9\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":60,\"w\":24,\"h\":15,\"i\":\"10\"},\"panelIndex\":\"10\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_10\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":75,\"w\":24,\"h\":15,\"i\":\"11\"},\"panelIndex\":\"11\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_11\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":75,\"w\":24,\"h\":15,\"i\":\"12\"},\"panelIndex\":\"12\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_12\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":90,\"w\":24,\"h\":15,\"i\":\"13\"},\"panelIndex\":\"13\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_13\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":105,\"w\":24,\"h\":15,\"i\":\"15\"},\"panelIndex\":\"15\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_15\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":105,\"w\":24,\"h\":15,\"i\":\"16\"},\"panelIndex\":\"16\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_16\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":120,\"w\":24,\"h\":15,\"i\":\"17\"},\"panelIndex\":\"17\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_17\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":120,\"w\":24,\"h\":15,\"i\":\"18\"},\"panelIndex\":\"18\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_18\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":135,\"w\":24,\"h\":15,\"i\":\"19\"},\"panelIndex\":\"19\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_19\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":135,\"w\":24,\"h\":15,\"i\":\"20\"},\"panelIndex\":\"20\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_20\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":150,\"w\":24,\"h\":15,\"i\":\"21\"},\"panelIndex\":\"21\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_21\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":150,\"w\":24,\"h\":15,\"i\":\"22\"},\"panelIndex\":\"22\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_22\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":165,\"w\":24,\"h\":15,\"i\":\"23\"},\"panelIndex\":\"23\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_23\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":24,\"y\":165,\"w\":24,\"h\":15,\"i\":\"24\"},\"panelIndex\":\"24\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_24\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":0,\"y\":180,\"w\":24,\"h\":15,\"i\":\"25\"},\"panelIndex\":\"25\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_25\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":24,\"y\":180,\"w\":24,\"h\":15,\"i\":\"26\"},\"panelIndex\":\"26\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_26\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":195,\"w\":24,\"h\":15,\"i\":\"27\"},\"panelIndex\":\"27\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_27\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":195,\"w\":24,\"h\":15,\"i\":\"28\"},\"panelIndex\":\"28\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_28\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":210,\"w\":24,\"h\":15,\"i\":\"29\"},\"panelIndex\":\"29\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_29\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":24,\"y\":210,\"i\":\"30\"},\"panelIndex\":\"30\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_30\"}]", + "refreshInterval": { + "display": "Off", + "pause": false, + "value": 0 + }, + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "title": "dashboard with everything", + "version": 1 + }, + "coreMigrationVersion": "8.0.1", + "id": "d2525040-3dcd-11e8-8660-4d65aa086b3c", + "migrationVersion": { + "dashboard": "8.0.1" + }, + "references": [ + { + "id": "e6140540-3dca-11e8-8660-4d65aa086b3c", + "name": "1:panel_1", + "type": "visualization" + }, + { + "id": "3525b840-3dcb-11e8-8660-4d65aa086b3c", + "name": "2:panel_2", + "type": "visualization" + }, + { + "id": "4b5d6ef0-3dcb-11e8-8660-4d65aa086b3c", + "name": "3:panel_3", + "type": "visualization" + }, + { + "id": "ffa2e0c0-3dcb-11e8-8660-4d65aa086b3c", + "name": "5:panel_5", + "type": "visualization" + }, + { + "id": "e2023110-3dcb-11e8-8660-4d65aa086b3c", + "name": "6:panel_6", + "type": "visualization" + }, + { + "id": "145ced90-3dcb-11e8-8660-4d65aa086b3c", + "name": "7:panel_7", + "type": "visualization" + }, + { + "id": "2d1b1620-3dcd-11e8-8660-4d65aa086b3c", + "name": "8:panel_8", + "type": "visualization" + }, + { + "id": "42535e30-3dcd-11e8-8660-4d65aa086b3c", + "name": "9:panel_9", + "type": "visualization" + }, + { + "id": "42535e30-3dcd-11e8-8660-4d65aa086b3c", + "name": "10:panel_10", + "type": "visualization" + }, + { + "id": "4c0f47e0-3dcd-11e8-8660-4d65aa086b3c", + "name": "11:panel_11", + "type": "visualization" + }, + { + "id": "11ae2bd0-3dcc-11e8-8660-4d65aa086b3c", + "name": "12:panel_12", + "type": "visualization" + }, + { + "id": "3fe22200-3dcb-11e8-8660-4d65aa086b3c", + "name": "13:panel_13", + "type": "visualization" + }, + { + "id": "78803be0-3dcd-11e8-8660-4d65aa086b3c", + "name": "15:panel_15", + "type": "visualization" + }, + { + "id": "b92ae920-3dcc-11e8-8660-4d65aa086b3c", + "name": "16:panel_16", + "type": "visualization" + }, + { + "id": "e4d8b430-3dcc-11e8-8660-4d65aa086b3c", + "name": "17:panel_17", + "type": "visualization" + }, + { + "id": "f81134a0-3dcc-11e8-8660-4d65aa086b3c", + "name": "18:panel_18", + "type": "visualization" + }, + { + "id": "cc43fab0-3dcc-11e8-8660-4d65aa086b3c", + "name": "19:panel_19", + "type": "visualization" + }, + { + "id": "02a2e4e0-3dcd-11e8-8660-4d65aa086b3c", + "name": "20:panel_20", + "type": "visualization" + }, + { + "id": "df815d20-3dcc-11e8-8660-4d65aa086b3c", + "name": "21:panel_21", + "type": "visualization" + }, + { + "id": "c40f4d40-3dcc-11e8-8660-4d65aa086b3c", + "name": "22:panel_22", + "type": "visualization" + }, + { + "id": "7fda8ee0-3dcd-11e8-8660-4d65aa086b3c", + "name": "23:panel_23", + "type": "visualization" + }, + { + "id": "a16d1990-3dca-11e8-8660-4d65aa086b3c", + "name": "24:panel_24", + "type": "search" + }, + { + "id": "be5accf0-3dca-11e8-8660-4d65aa086b3c", + "name": "25:panel_25", + "type": "search" + }, + { + "id": "ca5ada40-3dca-11e8-8660-4d65aa086b3c", + "name": "26:panel_26", + "type": "search" + }, + { + "id": "771b4f10-3e59-11e8-9fc3-39e49624228e", + "name": "27:panel_27", + "type": "visualization" + }, + { + "id": "5e085850-3e6e-11e8-bbb9-e15942d5d48c", + "name": "28:panel_28", + "type": "visualization" + }, + { + "id": "8bc8d6c0-3e6e-11e8-bbb9-e15942d5d48c", + "name": "29:panel_29", + "type": "visualization" + }, + { + "id": "befdb6b0-3e59-11e8-9fc3-39e49624228e", + "name": "30:panel_30", + "type": "visualization" + } + ], + "type": "dashboard", + "updated_at": "2018-04-16T16:05:02.915Z", + "version": "WzIxMywxXQ==" +} + +{ + "attributes": { + "fieldFormatMap": "{\"machine.ram\":{\"id\":\"number\",\"params\":{\"pattern\":\"0,0.[000] b\"}}}", + "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", + "timeFieldName": "@timestamp", + "title": "to-be-deleted" + }, + "coreMigrationVersion": "7.14.0", + "id": "1b1789d0-9e93-11ea-853e-adc0effaf76d", + "migrationVersion": { + "index-pattern": "7.11.0" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2018-04-16T16:57:12.263Z", + "version": "WzE3NiwxXQ==" +} + +{ + "attributes": { + "fieldFormatMap": "{\"machine.ram\":{\"id\":\"number\",\"params\":{\"pattern\":\"0,0.[000] b\"}}}", + "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", + "timeFieldName": "@timestamp", + "title": "to-be-deleted" + }, + "coreMigrationVersion": "7.14.0", + "id": "a0f483a0-3dc9-11e8-8660-bad-index", + "migrationVersion": { + "index-pattern": "7.11.0" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2018-04-16T16:57:12.263Z", + "version": "WzE3NiwxXQ==" +} \ No newline at end of file diff --git a/test/functional/fixtures/kbn_archiver/dashboard/current/kibana_unload.json b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana_unload.json new file mode 100644 index 000000000000..d2543cf42a6d --- /dev/null +++ b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana_unload.json @@ -0,0 +1,35 @@ +{ + "attributes": { + "fieldFormatMap": "{\"machine.ram\":{\"id\":\"number\",\"params\":{\"pattern\":\"0,0.[000] b\"}}}", + "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", + "timeFieldName": "@timestamp", + "title": "to-be-deleted" + }, + "coreMigrationVersion": "7.14.0", + "id": "1b1789d0-9e93-11ea-853e-adc0effaf76d", + "migrationVersion": { + "index-pattern": "7.11.0" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2018-04-16T16:57:12.263Z", + "version": "WzE3NiwxXQ==" +} + +{ + "attributes": { + "fieldFormatMap": "{\"machine.ram\":{\"id\":\"number\",\"params\":{\"pattern\":\"0,0.[000] b\"}}}", + "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", + "timeFieldName": "@timestamp", + "title": "to-be-deleted" + }, + "coreMigrationVersion": "7.14.0", + "id": "a0f483a0-3dc9-11e8-8660-bad-index", + "migrationVersion": { + "index-pattern": "7.11.0" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2018-04-16T16:57:12.263Z", + "version": "WzE3NiwxXQ==" +} \ No newline at end of file diff --git a/test/functional/fixtures/kbn_archiver/dashboard/legacy.json b/test/functional/fixtures/kbn_archiver/dashboard/legacy.json new file mode 100644 index 000000000000..0a05af3b40bb --- /dev/null +++ b/test/functional/fixtures/kbn_archiver/dashboard/legacy.json @@ -0,0 +1,241 @@ +{ + "attributes": { + "fields": "[{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false}]", + "timeFieldName": "@timestamp", + "title": "logstash-*" + }, + "coreMigrationVersion": "7.17.1", + "id": "logstash-*", + "migrationVersion": { + "index-pattern": "7.11.0" + }, + "references": [], + "type": "index-pattern", + "version": "WzksMl0=" +} + +{ + "attributes": { + "description": "InputControl", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + }, + "title": "Visualization InputControl", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"logstash control panel\",\"type\":\"input_control_vis\",\"params\":{\"controls\":[{\"id\":\"1508761640807\",\"fieldName\":\"machine.os.raw\",\"label\":\"\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":5,\"order\":\"desc\"},\"indexPatternRefName\":\"control_0_index_pattern\"},{\"id\":\"1508761655907\",\"fieldName\":\"bytes\",\"label\":\"\",\"type\":\"range\",\"options\":{\"decimalPlaces\":0,\"step\":512},\"indexPatternRefName\":\"control_1_index_pattern\"},{\"id\":\"1510594169532\",\"fieldName\":\"geo.dest\",\"label\":\"\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":5,\"order\":\"desc\"},\"indexPatternRefName\":\"control_2_index_pattern\"}],\"updateFiltersOnChange\":false},\"aggs\":[]}" + }, + "coreMigrationVersion": "7.17.1", + "id": "Visualization-InputControl", + "migrationVersion": { + "visualization": "7.17.0" + }, + "references": [ + { + "id": "logstash-*", + "name": "control_0_index_pattern", + "type": "index-pattern" + }, + { + "id": "logstash-*", + "name": "control_1_index_pattern", + "type": "index-pattern" + }, + { + "id": "logstash-*", + "name": "control_2_index_pattern", + "type": "index-pattern" + } + ], + "type": "visualization", + "version": "WzEwLDJd" +} + +{ + "attributes": { + "description": "MetricChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Visualization MetricChart", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"New Visualization\",\"type\":\"metric\",\"params\":{\"handleNoResults\":true,\"fontSize\":60},\"aggs\":[{\"id\":\"1\",\"type\":\"percentile_ranks\",\"schema\":\"metric\",\"params\":{\"field\":\"memory\",\"values\":[99]}}],\"listeners\":{}}" + }, + "coreMigrationVersion": "7.17.1", + "id": "Visualization-MetricChart", + "migrationVersion": { + "visualization": "7.17.0" + }, + "references": [ + { + "id": "logstash-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "version": "WzE2LDJd" +} + +{ + "attributes": { + "description": "PieChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Visualization PieChart", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"New Visualization\",\"type\":\"pie\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"isDonut\":false,\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"memory\",\"interval\":40000,\"extended_bounds\":{}}}],\"listeners\":{}}" + }, + "coreMigrationVersion": "7.17.1", + "id": "Visualization-PieChart", + "migrationVersion": { + "visualization": "7.17.0" + }, + "references": [ + { + "id": "logstash-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "version": "WzE0LDJd" +} + +{ + "attributes": { + "description": "TileMap", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Visualization TileMap", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"New Visualization\",\"type\":\"tile_map\",\"params\":{\"mapType\":\"Scaled Circle Markers\",\"isDesaturated\":true,\"addTooltip\":true,\"heatMaxZoom\":16,\"heatMinOpacity\":0.1,\"heatRadius\":25,\"heatBlur\":15,\"heatNormalizeData\":true,\"wms\":{\"enabled\":false,\"url\":\"https://basemap.nationalmap.gov/arcgis/services/USGSTopo/MapServer/WMSServer\",\"options\":{\"version\":\"1.3.0\",\"layers\":\"0\",\"format\":\"image/png\",\"transparent\":true,\"attribution\":\"Maps provided by USGS\",\"styles\":\"\"}}},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"geohash_grid\",\"schema\":\"segment\",\"params\":{\"field\":\"geo.coordinates\",\"autoPrecision\":true,\"precision\":2}}],\"listeners\":{}}" + }, + "coreMigrationVersion": "7.17.1", + "id": "Visualization-TileMap", + "migrationVersion": { + "visualization": "7.17.0" + }, + "references": [ + { + "id": "logstash-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "version": "WzEzLDJd" +} + +{ + "attributes": { + "description": "VerticalBarChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Visualization☺ VerticalBarChart", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"New Visualization\",\"type\":\"histogram\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"scale\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}" + }, + "coreMigrationVersion": "7.17.1", + "id": "Visualization☺-VerticalBarChart", + "migrationVersion": { + "visualization": "7.17.0" + }, + "references": [ + { + "id": "logstash-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "version": "WzEyLDJd" +} + +{ + "attributes": { + "description": "DataTable", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Visualization☺漢字 DataTable", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"New Visualization\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMeticsAtAllLevels\":false,\"showToolbar\":true},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"histogram\",\"schema\":\"bucket\",\"params\":{\"field\":\"bytes\",\"interval\":2000,\"extended_bounds\":{}}}],\"listeners\":{}}" + }, + "coreMigrationVersion": "7.17.1", + "id": "Visualization☺漢字-DataTable", + "migrationVersion": { + "visualization": "7.17.0" + }, + "references": [ + { + "id": "logstash-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "version": "WzExLDJd" +} + +{ + "attributes": { + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Visualization漢字 AreaChart", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"New Visualization\",\"type\":\"area\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"smoothLines\":false,\"scale\":\"linear\",\"interpolate\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}" + }, + "coreMigrationVersion": "7.17.1", + "id": "Visualization漢字-AreaChart", + "migrationVersion": { + "visualization": "7.17.0" + }, + "references": [ + { + "id": "logstash-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "version": "WzE3LDJd" +} + +{ + "attributes": { + "description": "LineChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Visualization漢字 LineChart", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"New Visualization\",\"type\":\"line\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"showCircles\":true,\"smoothLines\":false,\"interpolate\":\"linear\",\"scale\":\"linear\",\"drawLinesBetweenPoints\":true,\"radiusRatio\":9,\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{},\"row\":false},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"terms\",\"schema\":\"split\",\"params\":{\"field\":\"extension.raw\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}" + }, + "coreMigrationVersion": "7.17.1", + "id": "Visualization漢字-LineChart", + "migrationVersion": { + "visualization": "7.17.0" + }, + "references": [ + { + "id": "logstash-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "version": "WzE1LDJd" +} \ No newline at end of file diff --git a/test/functional/fixtures/kbn_archiver/date_nested_ccs.json b/test/functional/fixtures/kbn_archiver/date_nested_ccs.json index 933b21d920c0..9a411ba96705 100644 --- a/test/functional/fixtures/kbn_archiver/date_nested_ccs.json +++ b/test/functional/fixtures/kbn_archiver/date_nested_ccs.json @@ -2,10 +2,10 @@ "attributes": { "fields": "[]", "timeFieldName": "nested.timestamp", - "title": "remote:date-nested" + "title": "ftr-remote:date-nested" }, "coreMigrationVersion": "8.2.0", - "id": "remote:date-nested", + "id": "ftr-remote:date-nested", "migrationVersion": { "index-pattern": "8.0.0" }, diff --git a/test/functional/fixtures/kbn_archiver/discover_ccs.json b/test/functional/fixtures/kbn_archiver/discover_ccs.json index d53aa1bc759a..4c1143ed4e79 100644 --- a/test/functional/fixtures/kbn_archiver/discover_ccs.json +++ b/test/functional/fixtures/kbn_archiver/discover_ccs.json @@ -3,10 +3,10 @@ "fieldAttrs": "{\"referer\":{\"customLabel\":\"Referer custom\"}}", "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"@message\"}}},{\"name\":\"@tags\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"@tags\"}}},{\"name\":\"@timestamp\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"agent\"}}},{\"name\":\"bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"extension\"}}},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"headings\"}}},{\"name\":\"host\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"host\"}}},{\"name\":\"id\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"index\"}}},{\"name\":\"ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"links\"}}},{\"name\":\"machine.os\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"machine.os\"}}},{\"name\":\"machine.ram\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"esTypes\":[\"double\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"nestedField.child\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"nested\":{\"path\":\"nestedField\"}}},{\"name\":\"phpmemory\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.article:section\"}}},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.article:tag\"}}},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:description\"}}},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:image\"}}},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:image:height\"}}},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:image:width\"}}},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:site_name\"}}},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:title\"}}},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:type\"}}},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:url\"}}},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:card\"}}},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:description\"}}},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:image\"}}},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:site\"}}},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:title\"}}},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.url\"}}},{\"name\":\"request\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"request\"}}},{\"name\":\"response\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"response\"}}},{\"name\":\"spaces\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"spaces\"}}},{\"name\":\"type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"url\"}}},{\"name\":\"utc_time\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"xss\"}}}]", "timeFieldName": "@timestamp", - "title": "remote:logstash-*" + "title": "ftr-remote:logstash-*" }, "coreMigrationVersion": "8.0.0", - "id": "remote:logstash-*", + "id": "ftr-remote:logstash-*", "migrationVersion": { "index-pattern": "7.11.0" }, @@ -41,7 +41,7 @@ }, "references": [ { - "id": "remote:logstash-*", + "id": "ftr-remote:logstash-*", "name": "kibanaSavedObjectMeta.searchSourceJSON.index", "type": "index-pattern" } diff --git a/test/functional/page_objects/console_page.ts b/test/functional/page_objects/console_page.ts index e6450480bbb0..32c859cc1aed 100644 --- a/test/functional/page_objects/console_page.ts +++ b/test/functional/page_objects/console_page.ts @@ -85,8 +85,8 @@ export class ConsolePageObject extends FtrService { public async promptAutocomplete() { const textArea = await this.testSubjects.find('console-textarea'); - // There should be autocomplete for this on all license levels - await textArea.pressKeys([Key.CONTROL, Key.SPACE]); + await textArea.clickMouseButton(); + await textArea.type('b'); await this.retry.waitFor('autocomplete to be visible', () => this.isAutocompleteVisible()); } diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index acda3d5b8b02..d2376d84bdcc 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -9,6 +9,8 @@ export const PIE_CHART_VIS_NAME = 'Visualization PieChart'; export const AREA_CHART_VIS_NAME = 'Visualization漢字 AreaChart'; export const LINE_CHART_VIS_NAME = 'Visualization漢字 LineChart'; + +import expect from '@kbn/expect'; import { FtrService } from '../ftr_provider_context'; interface SaveDashboardOptions { @@ -51,6 +53,13 @@ export class DashboardPageObject extends FtrService { await this.common.navigateToApp('dashboard'); } + public async expectAppStateRemovedFromURL() { + this.retry.try(async () => { + const url = await this.browser.getCurrentUrl(); + expect(url.indexOf('_a')).to.be(-1); + }); + } + public async preserveCrossAppState() { const url = await this.browser.getCurrentUrl(); await this.browser.get(url, false); diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index 1adc60b3596b..5cf7cbe0b92e 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -64,10 +64,8 @@ export class DashboardPageControls extends FtrService { public async openCreateControlFlyout(type: string) { this.log.debug(`Opening flyout for ${type} control`); - await this.testSubjects.click('controls-create-button'); - if (await this.testSubjects.exists('control-type-picker')) { - await this.testSubjects.click(`create-${type}-control`); - } + await this.testSubjects.click('dashboardControlsMenuButton'); + await this.testSubjects.click(`create-${type}-control`); await this.retry.try(async () => { await this.testSubjects.existOrFail('control-editor-flyout'); }); diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 1583903be499..842f13f2666e 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -7,6 +7,7 @@ */ import expect from '@kbn/expect'; +import _saved_queries from '../apps/discover/_saved_queries'; import { FtrService } from '../ftr_provider_context'; export class DiscoverPageObject extends FtrService { @@ -369,10 +370,18 @@ export class DiscoverPageObject extends FtrService { } public async clickCreateNewDataView() { - await this.retry.try(async () => { - await this.testSubjects.click('dataview-create-new'); - await this.find.byClassName('indexPatternEditor__form'); + await this.retry.waitForWithTimeout('data create new to be visible', 15000, async () => { + return await this.testSubjects.isDisplayed('dataview-create-new'); }); + await this.testSubjects.click('dataview-create-new'); + await this.retry.waitForWithTimeout( + 'index pattern editor form to be visible', + 15000, + async () => { + return await (await this.find.byClassName('indexPatternEditor__form')).isDisplayed(); + } + ); + await (await this.find.byClassName('indexPatternEditor__form')).click(); } public async hasNoResults() { diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index b1e4aa823821..26701359f6ea 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -164,6 +164,19 @@ export class SettingsPageObject extends FtrService { return await this.testSubjects.find('saveIndexPatternButton'); } + async getSaveDataViewButtonActive() { + await this.retry.try(async () => { + expect( + ( + await this.find.allByCssSelector( + '[data-test-subj="saveIndexPatternButton"]:not(.euiButton-isDisabled)' + ) + ).length + ).to.be(1); + }); + return await this.testSubjects.find('saveIndexPatternButton'); + } + async getCreateButton() { return await this.find.displayedByCssSelector('[type="submit"]'); } @@ -288,7 +301,10 @@ export class SettingsPageObject extends FtrService { } async setScriptedFieldLanguageFilter(language: string) { - await this.testSubjects.clickWhenNotDisabled('scriptedFieldLanguageFilterDropdown'); + await this.retry.try(async () => { + await this.testSubjects.clickWhenNotDisabled('scriptedFieldLanguageFilterDropdown'); + return await this.find.byCssSelector('div.euiPopover__panel-isOpen'); + }); await this.testSubjects.existOrFail('scriptedFieldLanguageFilterDropdown-popover'); await this.testSubjects.existOrFail(`scriptedFieldLanguageFilterDropdown-option-${language}`); await this.testSubjects.click(`scriptedFieldLanguageFilterDropdown-option-${language}`); @@ -341,7 +357,7 @@ export class SettingsPageObject extends FtrService { } async clickIndexPatternByName(name: string) { - const indexLink = await this.find.byXPath(`//a[descendant::*[text()='${name}']]`); + const indexLink = await this.find.byXPath(`//a[text()='${name}']`); await indexLink.click(); } @@ -547,7 +563,7 @@ export class SettingsPageObject extends FtrService { name: string, language: string, type: string, - format: Record, + format: Record | null, popularity: string, script: string ) { @@ -787,7 +803,7 @@ export class SettingsPageObject extends FtrService { await this.flyout.ensureClosed('scriptedFieldsHelpFlyout'); } - async executeScriptedField(script: string, additionalField: string) { + async executeScriptedField(script: string, additionalField?: string) { this.log.debug('execute Scripted Fields help'); await this.closeScriptedFieldHelp(); // ensure script help is closed so script input is not blocked await this.setScriptedFieldScript(script); @@ -798,7 +814,7 @@ export class SettingsPageObject extends FtrService { await this.testSubjects.click('runScriptButton'); await this.testSubjects.waitForDeleted('.euiLoadingSpinner'); } - let scriptResults; + let scriptResults: string = ''; await this.retry.try(async () => { scriptResults = await this.testSubjects.getVisibleText('scriptedFieldPreview'); }); diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index 4054656028b6..f08845b23071 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -802,9 +802,7 @@ export class VisualBuilderPageObject extends FtrService { } public async checkSelectedMetricsGroupByValue(value: string) { - const groupBy = await this.find.byCssSelector( - '.tvbAggRow--split [data-test-subj="comboBoxInput"]' - ); + const groupBy = await this.testSubjects.find('groupBySelect'); return await this.comboBox.isOptionSelected(groupBy, value); } diff --git a/test/functional/services/dashboard/add_panel.ts b/test/functional/services/dashboard/add_panel.ts index 43ab1f966bc9..e42c221a4947 100644 --- a/test/functional/services/dashboard/add_panel.ts +++ b/test/functional/services/dashboard/add_panel.ts @@ -46,6 +46,11 @@ export class DashboardAddPanelService extends FtrService { async clickEditorMenuButton() { this.log.debug('DashboardAddPanel.clickEditorMenuButton'); await this.testSubjects.click('dashboardEditorMenuButton'); + await this.testSubjects.existOrFail('dashboardEditorContextMenu'); + } + + async expectEditorMenuClosed() { + await this.testSubjects.missingOrFail('dashboardEditorContextMenu'); } async clickAggBasedVisualizations() { diff --git a/test/functional/services/filter_bar.ts b/test/functional/services/filter_bar.ts index ec4d03041df8..eee1a1027f54 100644 --- a/test/functional/services/filter_bar.ts +++ b/test/functional/services/filter_bar.ts @@ -103,6 +103,11 @@ export class FilterBarService extends FtrService { return filters.length; } + public async getFiltersLabel(): Promise { + const filters = await this.testSubjects.findAll('~filter'); + return Promise.all(filters.map((filter) => filter.getVisibleText())); + } + /** * Adds a filter to the filter bar. * diff --git a/test/functional_ccs/apps/discover/_data_view_ccs.ts b/test/functional_ccs/apps/discover/data_view_ccs.ts similarity index 77% rename from test/functional_ccs/apps/discover/_data_view_ccs.ts rename to test/functional_ccs/apps/discover/data_view_ccs.ts index 91d9cb2faf68..5fc39ff5705d 100644 --- a/test/functional_ccs/apps/discover/_data_view_ccs.ts +++ b/test/functional_ccs/apps/discover/data_view_ccs.ts @@ -6,13 +6,13 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from './ftr_provider_context'; +import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); - const esArchiver = getService('esArchiver'); + const remoteEsArchiver = getService('remoteEsArchiver'); const security = getService('security'); const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']); @@ -29,22 +29,30 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('discover integration with data view editor', function describeIndexTests() { before(async function () { - await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); - await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await security.testUser.setRoles([ + 'kibana_admin', + 'test_logstash_reader', + 'ccs_remote_search', + ]); + await remoteEsArchiver.loadIfNeeded( + 'test/functional/fixtures/es_archiver/logstash_functional' + ); await kibanaServer.savedObjects.clean({ types: ['saved-search', 'index-pattern'] }); - await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover'); + // The test creates the 'ftr-remote:logstash*" data view but we have to load the discover_ccs + // which contains ftr-remote:logstash-* otherwise, discover will redirect us to another page. + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover_ccs'); await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); }); after(async () => { await security.testUser.restoreDefaults(); - await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); + await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover_ccs'); await kibanaServer.savedObjects.clean({ types: ['saved-search', 'index-pattern'] }); }); it('use ccs to create a new data view', async function () { - const dataViewToCreate = 'remote:logstash'; + const dataViewToCreate = 'ftr-remote:logstash'; await createDataView(dataViewToCreate); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.waitForWithTimeout( diff --git a/test/functional_ccs/apps/discover/index.ts b/test/functional_ccs/apps/discover/index.ts index 629423b1b75a..2e9d428f44c6 100644 --- a/test/functional_ccs/apps/discover/index.ts +++ b/test/functional_ccs/apps/discover/index.ts @@ -6,38 +6,24 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from './ftr_provider_context'; +import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, loadTestFile }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const browser = getService('browser'); - const esClient = getService('es'); describe('discover app css', function () { this.tags('ciGroup6'); - before(async function () { - await esClient.cluster.putSettings({ - persistent: { - cluster: { - remote: { - remote: { - skip_unavailable: 'true', - seeds: ['localhost:9300'], - }, - }, - }, - }, - }); - return browser.setWindowSize(1300, 800); + before(async () => { + await browser.setWindowSize(1300, 800); }); - after(function unloadMakelogs() { - // Make sure to clean up the cluster setting from the before above. - return esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); - }); + loadTestFile(require.resolve('./data_view_ccs')); + loadTestFile(require.resolve('./saved_queries_ccs')); - loadTestFile(require.resolve('./_data_view_ccs')); - loadTestFile(require.resolve('./_saved_queries_ccs')); + after(async () => { + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + }); }); } diff --git a/test/functional_ccs/apps/discover/_saved_queries_ccs.ts b/test/functional_ccs/apps/discover/saved_queries_ccs.ts similarity index 93% rename from test/functional_ccs/apps/discover/_saved_queries_ccs.ts rename to test/functional_ccs/apps/discover/saved_queries_ccs.ts index 325f279ff28a..08b6d61368f5 100644 --- a/test/functional_ccs/apps/discover/_saved_queries_ccs.ts +++ b/test/functional_ccs/apps/discover/saved_queries_ccs.ts @@ -8,12 +8,12 @@ import expect from '@kbn/expect'; -import { FtrProviderContext } from './ftr_provider_context'; +import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const log = getService('log'); - const esArchiver = getService('esArchiver'); + const remoteEsArchiver = getService('remoteEsArchiver'); const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); const browser = getService('browser'); @@ -47,8 +47,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await kibanaServer.importExport.load( 'test/functional/fixtures/kbn_archiver/date_nested_ccs.json' ); - await esArchiver.load('test/functional/fixtures/es_archiver/date_nested'); - await esArchiver.load('test/functional/fixtures/es_archiver/logstash_functional'); + await remoteEsArchiver.load('test/functional/fixtures/es_archiver/date_nested'); + await remoteEsArchiver.load('test/functional/fixtures/es_archiver/logstash_functional'); await kibanaServer.uiSettings.replace(defaultSettings); log.debug('discover'); @@ -61,8 +61,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await kibanaServer.importExport.unload( 'test/functional/fixtures/kbn_archiver/date_nested_ccs' ); - await esArchiver.unload('test/functional/fixtures/es_archiver/date_nested'); - await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + await remoteEsArchiver.unload('test/functional/fixtures/es_archiver/date_nested'); + await remoteEsArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); await PageObjects.common.unsetTime(); }); @@ -87,12 +87,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(false); expect(await queryBar.getQueryString()).to.eql(''); - await PageObjects.discover.selectIndexPattern('remote:date-nested'); + await PageObjects.discover.selectIndexPattern('ftr-remote:date-nested'); expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(false); expect(await queryBar.getQueryString()).to.eql(''); - await PageObjects.discover.selectIndexPattern('remote:logstash-*'); + await PageObjects.discover.selectIndexPattern('ftr-remote:logstash-*'); expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(false); expect(await queryBar.getQueryString()).to.eql(''); diff --git a/test/functional_ccs/config.js b/test/functional_ccs/config.js deleted file mode 100644 index 4cd887579837..000000000000 --- a/test/functional_ccs/config.js +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 { services } from '../functional/services'; - -export default async function ({ readConfigFile }) { - const functionalConfig = await readConfigFile(require.resolve('../functional/config')); - - return { - ...functionalConfig.getAll(), - - testFiles: [require.resolve('./apps/discover')], - - services, - - junit: { - reportName: 'Kibana CCS Tests', - }, - }; -} diff --git a/test/functional_ccs/config.ts b/test/functional_ccs/config.ts new file mode 100644 index 000000000000..e99a5310453d --- /dev/null +++ b/test/functional_ccs/config.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { FtrConfigProviderContext } from '@kbn/test'; +import { services } from './services'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../functional/config')); + + return { + ...functionalConfig.getAll(), + + testFiles: [require.resolve('./apps/discover')], + + services, + + junit: { + reportName: 'Kibana CCS Tests', + }, + + security: { + ...functionalConfig.get('security'), + remoteEsRoles: { + ccs_remote_search: { + indices: [ + { + names: ['*'], + privileges: ['read', 'view_index_metadata', 'read_cross_cluster'], + }, + ], + }, + }, + defaultRoles: [...(functionalConfig.get('security.defaultRoles') ?? []), 'ccs_remote_search'], + }, + + esTestCluster: { + ...functionalConfig.get('esTestCluster'), + ccs: { + remoteClusterUrl: + process.env.REMOTE_CLUSTER_URL ?? + `http://elastic:changeme@localhost:${ + functionalConfig.get('servers.elasticsearch.port') + 1 + }`, + }, + }, + }; +} diff --git a/test/functional_ccs/apps/discover/ftr_provider_context.d.ts b/test/functional_ccs/ftr_provider_context.ts similarity index 80% rename from test/functional_ccs/apps/discover/ftr_provider_context.d.ts rename to test/functional_ccs/ftr_provider_context.ts index ea232d23463e..8fa82b46ac40 100644 --- a/test/functional_ccs/apps/discover/ftr_provider_context.d.ts +++ b/test/functional_ccs/ftr_provider_context.ts @@ -7,7 +7,7 @@ */ import { GenericFtrProviderContext } from '@kbn/test'; -import { services } from '../../../functional/services'; -import { pageObjects } from '../../../functional/page_objects'; +import { services } from './services'; +import { pageObjects } from '../functional/page_objects'; export type FtrProviderContext = GenericFtrProviderContext; diff --git a/test/functional_ccs/services/index.ts b/test/functional_ccs/services/index.ts new file mode 100644 index 000000000000..dcdffa077fe0 --- /dev/null +++ b/test/functional_ccs/services/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { services as functionalServices } from '../../functional/services'; +import { RemoteEsProvider } from './remote_es'; +import { RemoteEsArchiverProvider } from './remote_es_archiver'; + +export const services = { + ...functionalServices, + remoteEs: RemoteEsProvider, + remoteEsArchiver: RemoteEsArchiverProvider, +}; diff --git a/test/functional_ccs/services/remote_es.ts b/test/functional_ccs/services/remote_es.ts new file mode 100644 index 000000000000..05a10d9e068f --- /dev/null +++ b/test/functional_ccs/services/remote_es.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 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'; + +import { systemIndicesSuperuser, createRemoteEsClientForFtrConfig } from '@kbn/test'; +import { FtrProviderContext } from '../ftr_provider_context'; + +/** + * Kibana-specific @elastic/elasticsearch client instance. + */ +export function RemoteEsProvider({ getService }: FtrProviderContext): Client { + const config = getService('config'); + + return createRemoteEsClientForFtrConfig(config, { + // Use system indices user so tests can write to system indices + authOverride: systemIndicesSuperuser, + }); +} diff --git a/test/functional_ccs/services/remote_es_archiver.ts b/test/functional_ccs/services/remote_es_archiver.ts new file mode 100644 index 000000000000..569792d050a4 --- /dev/null +++ b/test/functional_ccs/services/remote_es_archiver.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 { EsArchiver } from '@kbn/es-archiver'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export function RemoteEsArchiverProvider({ getService }: FtrProviderContext): EsArchiver { + const remoteEs = getService('remoteEs'); + const log = getService('log'); + const kibanaServer = getService('kibanaServer'); + + return new EsArchiver({ + client: remoteEs, + log, + kbnClient: kibanaServer, + }); +} diff --git a/test/interpreter_functional/test_suites/run_pipeline/esaggs_topmetrics.ts b/test/interpreter_functional/test_suites/run_pipeline/esaggs_topmetrics.ts new file mode 100644 index 000000000000..4f43709ba4a7 --- /dev/null +++ b/test/interpreter_functional/test_suites/run_pipeline/esaggs_topmetrics.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { ExpectExpression, expectExpressionProvider } from './helpers'; +import { FtrProviderContext } from '../../../functional/ftr_provider_context'; + +export default function ({ + getService, + updateBaselines, +}: FtrProviderContext & { updateBaselines: boolean }) { + let expectExpression: ExpectExpression; + + describe('esaggs_topmetrics', () => { + before(() => { + expectExpression = expectExpressionProvider({ getService, updateBaselines }); + }); + + const timeRange = { + from: '2015-09-21T00:00:00Z', + to: '2015-09-22T00:00:00Z', + }; + + describe('aggTopMetrics', () => { + it('can execute aggTopMetrics', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggTerms id="1" enabled=true schema="bucket" field="extension.raw"} + aggs={aggTopMetrics id="2" enabled=true schema="metric" field="bytes" sortField="@timestamp" sortOrder="desc" size=3 } + `; + const result = await expectExpression('aggTopMetrics', expression).getResponse(); + + expect(result.rows.map((r: { 'col-0-1': string }) => r['col-0-1'])).to.eql([ + 'jpg', + 'css', + 'png', + 'gif', + 'php', + ]); + + result.rows.forEach((r: { 'col-1-2': number[] }) => { + expect(r['col-1-2'].length).to.be(3); + expect( + r['col-1-2'].forEach((metric) => { + expect(typeof metric).to.be('number'); + }) + ); + }); + }); + + it('can execute aggTopMetrics with different sortOrder and size', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggTerms id="1" enabled=true schema="bucket" field="extension.raw"} + aggs={aggTopMetrics id="2" enabled=true schema="metric" field="bytes" sortField="@timestamp" sortOrder="asc" size=1 } + `; + const result = await expectExpression('aggTopMetrics', expression).getResponse(); + + expect(result.rows.map((r: { 'col-0-1': string }) => r['col-0-1'])).to.eql([ + 'jpg', + 'css', + 'png', + 'gif', + 'php', + ]); + + result.rows.forEach((r: { 'col-1-2': number[] }) => { + expect(typeof r['col-1-2']).to.be('number'); + }); + }); + + it('can use aggTopMetrics as an orderAgg of aggTerms', async () => { + const expressionSortBytesAsc = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggTerms id="1" enabled=true schema="bucket" field="extension.raw" size=1 orderAgg={aggTopMetrics id="order" enabled=true schema="metric" field="bytes" sortField="@timestamp" sortOrder="asc" size=1}} + aggs={aggCount id="2" enabled=true schema="metric"} + `; + + const resultSortBytesAsc = await expectExpression( + 'sortBytesAsc', + expressionSortBytesAsc + ).getResponse(); + + const expressionSortBytesDesc = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggTerms id="1" enabled=true schema="bucket" field="extension.raw" size=1 orderAgg={aggTopMetrics id="order" enabled=true schema="metric" field="bytes" sortField="@timestamp" sortOrder="desc" size=1}} + aggs={aggCount id="2" enabled=true schema="metric"} + `; + + const resultSortBytesDesc = await expectExpression( + 'sortBytesDesc', + expressionSortBytesDesc + ).getResponse(); + + expect(resultSortBytesAsc.rows.length).to.be(1); + expect(resultSortBytesAsc.rows[0]['col-0-1']).to.be('jpg'); + + expect(resultSortBytesDesc.rows.length).to.be(1); + expect(resultSortBytesDesc.rows[0]['col-0-1']).to.be('php'); + }); + }); + }); +} diff --git a/test/interpreter_functional/test_suites/run_pipeline/index.ts b/test/interpreter_functional/test_suites/run_pipeline/index.ts index 97387fc0a965..e24563a5918e 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/index.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/index.ts @@ -25,6 +25,7 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'Australia/North', defaultIndex: 'logstash-*', + 'bfetch:disableCompression': true, // makes it easier to debug while developing tests }); await browser.setWindowSize(1300, 900); await PageObjects.common.navigateToApp('settings'); @@ -47,5 +48,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid loadTestFile(require.resolve('./esaggs_sampler')); loadTestFile(require.resolve('./esaggs_significanttext')); loadTestFile(require.resolve('./esaggs_rareterms')); + loadTestFile(require.resolve('./esaggs_topmetrics')); }); } diff --git a/test/package/Vagrantfile b/test/package/Vagrantfile index 34c29eb2cefe..6a094e013c34 100644 --- a/test/package/Vagrantfile +++ b/test/package/Vagrantfile @@ -2,26 +2,35 @@ Vagrant.configure("2") do |config| config.vm.synced_folder '../../target/', '/packages' config.vm.define "deb" do |deb| + deb.vm.provider :virtualbox do |vb| + vb.memory = 2048 + end deb.vm.box = 'elastic/debian-9-x86_64' deb.vm.provision "ansible" do |ansible| ansible.playbook = "deb.yml" end - deb.vm.network "private_network", ip: "192.168.50.5" + deb.vm.network "private_network", ip: "192.168.56.5" end config.vm.define "rpm" do |rpm| + rpm.vm.provider :virtualbox do |vb| + vb.memory = 2048 + end rpm.vm.box = 'elastic/centos-7-x86_64' rpm.vm.provision "ansible" do |ansible| ansible.playbook = "rpm.yml" end - rpm.vm.network "private_network", ip: "192.168.50.6" + rpm.vm.network "private_network", ip: "192.168.56.6" end config.vm.define "docker" do |docker| + docker.vm.provider :virtualbox do |vb| + vb.memory = 2048 + end docker.vm.box = 'elastic/ubuntu-18.04-x86_64' docker.vm.provision "ansible" do |ansible| ansible.playbook = "docker.yml" end - docker.vm.network "private_network", ip: "192.168.50.7" + docker.vm.network "private_network", ip: "192.168.56.7" end end diff --git a/test/package/roles/install_kibana_docker/tasks/main.yml b/test/package/roles/install_kibana_docker/tasks/main.yml index c883a3ece2a1..2b0b70de30b6 100644 --- a/test/package/roles/install_kibana_docker/tasks/main.yml +++ b/test/package/roles/install_kibana_docker/tasks/main.yml @@ -21,6 +21,6 @@ network_mode: host env: SERVER_HOST: 0.0.0.0 - ELASTICSEARCH_HOSTS: http://192.168.50.1:9200 + ELASTICSEARCH_HOSTS: http://192.168.56.1:9200 ELASTICSEARCH_USERNAME: '{{ elasticsearch_username }}' ELASTICSEARCH_PASSWORD: '{{ elasticsearch_password }}' diff --git a/test/package/templates/kibana.yml b/test/package/templates/kibana.yml index 710434a2a577..16f5aee16f40 100644 --- a/test/package/templates/kibana.yml +++ b/test/package/templates/kibana.yml @@ -1,6 +1,6 @@ server.host: 0.0.0.0 -elasticsearch.hosts: http://192.168.50.1:9200 +elasticsearch.hosts: http://192.168.56.1:9200 elasticsearch.username: "{{ elasticsearch_username }}" elasticsearch.password: "{{ elasticsearch_password }}" diff --git a/test/plugin_functional/test_suites/core_plugins/applications.ts b/test/plugin_functional/test_suites/core_plugins/applications.ts index 0145a84423b3..df4ac37b9646 100644 --- a/test/plugin_functional/test_suites/core_plugins/applications.ts +++ b/test/plugin_functional/test_suites/core_plugins/applications.ts @@ -49,7 +49,8 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const navigateTo = async (path: string) => await browser.navigateTo(`${deployment.getHostPort()}${path}`); - describe('ui applications', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/127545 + describe.skip('ui applications', function describeIndexTests() { before(async () => { await esArchiver.emptyKibanaIndex(); await PageObjects.common.navigateToApp('foo'); diff --git a/test/plugin_functional/test_suites/data_plugin/session.ts b/test/plugin_functional/test_suites/data_plugin/session.ts index 54b86f0c8060..e119d60a9024 100644 --- a/test/plugin_functional/test_suites/data_plugin/session.ts +++ b/test/plugin_functional/test_suites/data_plugin/session.ts @@ -15,6 +15,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const testSubjects = getService('testSubjects'); const toasts = getService('toasts'); const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); const getSessionIds = async () => { const sessionsBtn = await testSubjects.find('showSessionsButton'); @@ -73,8 +74,9 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide await esArchiver.loadIfNeeded( 'test/functional/fixtures/es_archiver/dashboard/current/data' ); - await esArchiver.loadIfNeeded( - 'test/functional/fixtures/es_archiver/dashboard/current/kibana' + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' ); await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.loadSavedDashboard('dashboard with filter'); @@ -88,7 +90,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide after(async () => { await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data'); - await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); }); it('on load there is a single session', async () => { diff --git a/test/plugin_functional/test_suites/panel_actions/index.js b/test/plugin_functional/test_suites/panel_actions/index.js index 70e6ed67b218..13958f758e1a 100644 --- a/test/plugin_functional/test_suites/panel_actions/index.js +++ b/test/plugin_functional/test_suites/panel_actions/index.js @@ -15,7 +15,10 @@ export default function ({ getService, getPageObjects, loadTestFile }) { describe('pluggable panel actions', function () { before(async () => { await browser.setWindowSize(1300, 900); - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*', @@ -26,7 +29,7 @@ export default function ({ getService, getPageObjects, loadTestFile }) { after(async function () { await PageObjects.dashboard.clearSavedObjectsFromAppLinks(); - await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/kibana'); + await kibanaServer.savedObjects.cleanStandardList(); await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data'); }); diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/import.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/import.ts index 70241ade50a3..f8fa3c11a1b8 100644 --- a/test/plugin_functional/test_suites/saved_objects_hidden_type/import.ts +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/import.ts @@ -72,7 +72,6 @@ export default function ({ getService }: PluginFunctionalProviderContext) { { id: 'some-id-1', type: 'test-hidden-non-importable-exportable', - title: 'my title', meta: { title: 'my title', }, diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts index 8af5e07ab69a..be2af8cd5d11 100644 --- a/test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts @@ -95,7 +95,6 @@ export default function ({ getService }: PluginFunctionalProviderContext) { { id: 'op3767a1-9rcg-53u7-jkb3-3dnb74193awc', type: 'test-hidden-non-importable-exportable', - title: 'new title!', meta: { title: 'new title!', }, diff --git a/test/scripts/jenkins_xpack_package_build.sh b/test/scripts/jenkins_xpack_package_build.sh deleted file mode 100755 index 86e846f72080..000000000000 --- a/test/scripts/jenkins_xpack_package_build.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -set -e - -source src/dev/ci_setup/setup_env.sh - -export TMP=/tmp -export TMPDIR=/tmp - -node scripts/build --all-platforms --debug - -gsutil -q -m cp 'target/*' "gs://ci-artifacts.kibana.dev/package-testing/$GIT_COMMIT/" diff --git a/test/scripts/jenkins_xpack_package_deb.sh b/test/scripts/jenkins_xpack_package_deb.sh deleted file mode 100755 index 626036e8db3f..000000000000 --- a/test/scripts/jenkins_xpack_package_deb.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash - -set -e - -source src/dev/ci_setup/setup_env.sh - -mkdir -p target -gsutil -q -m cp "gs://ci-artifacts.kibana.dev/package-testing/$GIT_COMMIT/kibana-*.deb" ./target - -export VAGRANT_CWD=test/package -vagrant up deb --no-provision - -node scripts/es snapshot \ - -E network.bind_host=127.0.0.1,192.168.50.1 \ - -E discovery.type=single-node \ - --license=trial & -while ! timeout 1 bash -c "echo > /dev/tcp/localhost/9200"; do sleep 30; done - -vagrant provision deb - -export TEST_BROWSER_HEADLESS=1 -export TEST_KIBANA_URL=http://elastic:changeme@192.168.50.5:5601 -export TEST_ES_URL=http://elastic:changeme@192.168.50.1:9200 - -cd x-pack -node scripts/functional_test_runner.js --include-tag=smoke diff --git a/test/scripts/jenkins_xpack_package_docker.sh b/test/scripts/jenkins_xpack_package_docker.sh deleted file mode 100755 index c9f94b2c1eb4..000000000000 --- a/test/scripts/jenkins_xpack_package_docker.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash - -set -e - -source src/dev/ci_setup/setup_env.sh - -mkdir -p target -gsutil -q -m cp "gs://ci-artifacts.kibana.dev/package-testing/$GIT_COMMIT/kibana-[0-9]*-docker-image.tar.gz" ./target - -export VAGRANT_CWD=test/package -vagrant up docker --no-provision - -node scripts/es snapshot \ - -E network.bind_host=127.0.0.1,192.168.50.1 \ - -E discovery.type=single-node \ - --license=trial & -while ! timeout 1 bash -c "echo > /dev/tcp/localhost/9200"; do sleep 30; done - -vagrant provision docker - -export TEST_BROWSER_HEADLESS=1 -export TEST_KIBANA_URL=http://elastic:changeme@192.168.50.7:5601 -export TEST_ES_URL=http://elastic:changeme@192.168.50.1:9200 - -cd x-pack -node scripts/functional_test_runner.js --include-tag=smoke diff --git a/test/scripts/jenkins_xpack_package_rpm.sh b/test/scripts/jenkins_xpack_package_rpm.sh deleted file mode 100755 index 08095ce48c1e..000000000000 --- a/test/scripts/jenkins_xpack_package_rpm.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash - -set -e - -source src/dev/ci_setup/setup_env.sh - -mkdir -p target -gsutil -q -m cp "gs://ci-artifacts.kibana.dev/package-testing/$GIT_COMMIT/kibana-*.rpm" ./target - -export VAGRANT_CWD=test/package -vagrant up rpm --no-provision - -node scripts/es snapshot \ - -E network.bind_host=127.0.0.1,192.168.50.1 \ - -E discovery.type=single-node \ - --license=trial & -while ! timeout 1 bash -c "echo > /dev/tcp/localhost/9200"; do sleep 30; done - -vagrant provision rpm - -export TEST_BROWSER_HEADLESS=1 -export TEST_KIBANA_URL=http://elastic:changeme@192.168.50.6:5601 -export TEST_ES_URL=http://elastic:changeme@192.168.50.1:9200 - -cd x-pack -node scripts/functional_test_runner.js --include-tag=smoke diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index c48041c1e188..fe2f3dea0017 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -52,6 +52,7 @@ "xpack.security": "plugins/security", "xpack.server": "legacy/server", "xpack.securitySolution": "plugins/security_solution", + "xpack.sessionView": "plugins/session_view", "xpack.snapshotRestore": "plugins/snapshot_restore", "xpack.spaces": "plugins/spaces", "xpack.savedObjectsTagging": ["plugins/saved_objects_tagging"], @@ -70,6 +71,7 @@ "exclude": ["examples"], "translations": [ "plugins/translations/translations/zh-CN.json", - "plugins/translations/translations/ja-JP.json" + "plugins/translations/translations/ja-JP.json", + "plugins/translations/translations/fr-FR.json" ] } diff --git a/x-pack/plugins/actions/server/action_type_registry.ts b/x-pack/plugins/actions/server/action_type_registry.ts index 76b360ce8b17..9f8d2dec2128 100644 --- a/x-pack/plugins/actions/server/action_type_registry.ts +++ b/x-pack/plugins/actions/server/action_type_registry.ts @@ -122,7 +122,7 @@ export class ActionTypeRegistry { ) ); } - this.actionTypes.set(actionType.id, { ...actionType } as ActionType); + this.actionTypes.set(actionType.id, { ...actionType } as unknown as ActionType); this.taskManager.registerTaskDefinitions({ [`actions:${actionType.id}`]: { title: actionType.name, @@ -141,7 +141,7 @@ export class ActionTypeRegistry { // No need to notify usage on basic action types if (actionType.minimumLicenseRequired !== 'basic') { this.licensing.featureUsage.register( - getActionTypeFeatureUsageName(actionType as ActionType), + getActionTypeFeatureUsageName(actionType as unknown as ActionType), actionType.minimumLicenseRequired ); } diff --git a/x-pack/plugins/actions/server/feature.ts b/x-pack/plugins/actions/server/feature.ts index c6265a17b122..ecb2e0e0b9ea 100644 --- a/x-pack/plugins/actions/server/feature.ts +++ b/x-pack/plugins/actions/server/feature.ts @@ -13,6 +13,12 @@ import { } from './constants/saved_objects'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; +/** + * The order of appearance in the feature privilege page + * under the management section. + */ +const FEATURE_ORDER = 3000; + export const ACTIONS_FEATURE = { id: 'actions', name: i18n.translate('xpack.actions.featureRegistry.actionsFeatureName', { @@ -20,6 +26,7 @@ export const ACTIONS_FEATURE = { }), category: DEFAULT_APP_CATEGORIES.management, app: [], + order: FEATURE_ORDER, management: { insightsAndAlerting: ['triggersActions'], }, diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index a18c62047a0d..903d190a216b 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -116,7 +116,6 @@ This is the primary function for a rule type. Whenever the rule needs to execute |services.log(tags, [data], [timestamp])|Use this to create server logs. (This is the same function as server.log)| |services.shouldWriteAlerts()|This returns a boolean indicating whether the executor should write out alerts as data. This is determined by whether rule execution has been cancelled due to timeout AND whether both the Kibana `cancelAlertsOnRuleTimeout` flag and the rule type `cancelAlertsOnRuleTimeout` are set to `true`.| |services.shouldStopExecution()|This returns a boolean indicating whether rule execution has been cancelled due to timeout.| -|services.search|This provides an implementation of Elasticsearch client `search` function that aborts searches if rule execution is cancelled mid-search.| |startedAt|The date and time the rule type started execution.| |previousStartedAt|The previous date and time the rule type started a successful execution.| |params|Parameters for the execution. This is where the parameters you require will be passed in. (e.g. threshold). Use rule type validation to ensure values are set before execution.| @@ -321,7 +320,7 @@ const myRuleType: RuleType< // Query Elasticsearch using a cancellable search // If rule execution is cancelled mid-search, the search request will be aborted // and an error will be thrown. - const esClient = services.search.asCurrentUser; + const esClient = services.scopedClusterClient.asCurrentUser; await esClient.search(esQuery); // Call a function to get the server's current CPU usage diff --git a/x-pack/plugins/alerting/common/alert.ts b/x-pack/plugins/alerting/common/alert.ts index 35058aa343b1..da916ee7ed98 100644 --- a/x-pack/plugins/alerting/common/alert.ts +++ b/x-pack/plugins/alerting/common/alert.ts @@ -63,6 +63,13 @@ export interface AlertAggregations { ruleMutedStatus: { muted: number; unmuted: number }; } +export interface MappedParamsProperties { + risk_score?: number; + severity?: string; +} + +export type MappedParams = SavedObjectAttributes & MappedParamsProperties; + export interface Alert { id: string; enabled: boolean; @@ -73,6 +80,7 @@ export interface Alert { schedule: IntervalSchedule; actions: AlertAction[]; params: Params; + mapped_params?: MappedParams; scheduledTaskId?: string; createdBy: string | null; updatedBy: string | null; diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index 46f0b7e28416..343a4e103991 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -36,11 +36,6 @@ export type { PublicAlert as Alert } from './alert'; export { parseDuration } from './lib'; export { getEsErrorMessage } from './lib/errors'; export type { PublicAlertingConfig } from './config'; -export type { - IAbortableEsClient, - IAbortableClusterClient, -} from './lib/create_abortable_es_client_factory'; -export { createAbortableEsClientFactory } from './lib/create_abortable_es_client_factory'; export { ReadOperations, AlertingAuthorizationFilterType, diff --git a/x-pack/plugins/alerting/server/lib/create_abortable_es_client_factory.test.ts b/x-pack/plugins/alerting/server/lib/create_abortable_es_client_factory.test.ts deleted file mode 100644 index d6ac850e5921..000000000000 --- a/x-pack/plugins/alerting/server/lib/create_abortable_es_client_factory.test.ts +++ /dev/null @@ -1,107 +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 { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; -import { createAbortableEsClientFactory } from './create_abortable_es_client_factory'; - -const esQuery = { - body: { query: { bool: { filter: { range: { '@timestamp': { gte: 0 } } } } } }, -}; - -describe('createAbortableEsClientFactory', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - test('searches with asInternalUser when specified', async () => { - const abortController = new AbortController(); - const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - const abortableSearchClient = createAbortableEsClientFactory({ - scopedClusterClient, - abortController, - }); - - await abortableSearchClient.asInternalUser.search(esQuery); - expect(scopedClusterClient.asInternalUser.search).toHaveBeenCalledWith(esQuery, { - signal: abortController.signal, - meta: true, - }); - expect(scopedClusterClient.asCurrentUser.search).not.toHaveBeenCalled(); - }); - - test('searches with asCurrentUser when specified', async () => { - const abortController = new AbortController(); - const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - const abortableSearchClient = createAbortableEsClientFactory({ - scopedClusterClient, - abortController, - }); - - await abortableSearchClient.asCurrentUser.search(esQuery); - expect(scopedClusterClient.asCurrentUser.search).toHaveBeenCalledWith(esQuery, { - signal: abortController.signal, - meta: true, - }); - expect(scopedClusterClient.asInternalUser.search).not.toHaveBeenCalled(); - }); - - test('uses search options when specified', async () => { - const abortController = new AbortController(); - const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - const abortableSearchClient = createAbortableEsClientFactory({ - scopedClusterClient, - abortController, - }); - - await abortableSearchClient.asInternalUser.search(esQuery, { ignore: [404] }); - expect(scopedClusterClient.asInternalUser.search).toHaveBeenCalledWith(esQuery, { - ignore: [404], - signal: abortController.signal, - meta: true, - }); - expect(scopedClusterClient.asCurrentUser.search).not.toHaveBeenCalled(); - }); - - test('re-throws error when search throws error', async () => { - const abortController = new AbortController(); - const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - scopedClusterClient.asInternalUser.search.mockRejectedValueOnce( - new Error('something went wrong!') - ); - const abortableSearchClient = createAbortableEsClientFactory({ - scopedClusterClient, - abortController, - }); - - await expect( - abortableSearchClient.asInternalUser.search - ).rejects.toThrowErrorMatchingInlineSnapshot(`"something went wrong!"`); - }); - - test('throws error when search throws abort error', async () => { - const abortController = new AbortController(); - abortController.abort(); - const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - scopedClusterClient.asInternalUser.search.mockRejectedValueOnce( - new Error('Request has been aborted by the user') - ); - const abortableSearchClient = createAbortableEsClientFactory({ - scopedClusterClient, - abortController, - }); - - await expect( - abortableSearchClient.asInternalUser.search - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Search has been aborted due to cancelled execution"` - ); - }); -}); diff --git a/x-pack/plugins/alerting/server/lib/create_abortable_es_client_factory.ts b/x-pack/plugins/alerting/server/lib/create_abortable_es_client_factory.ts deleted file mode 100644 index a24ad544557d..000000000000 --- a/x-pack/plugins/alerting/server/lib/create_abortable_es_client_factory.ts +++ /dev/null @@ -1,77 +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 { TransportRequestOptions, TransportResult } from '@elastic/elasticsearch'; -import type { SearchRequest, SearchResponse } from '@elastic/elasticsearch/lib/api/types'; -import type { - SearchRequest as SearchRequestWithBody, - AggregationsAggregate, -} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { IScopedClusterClient } from 'src/core/server'; - -export interface IAbortableEsClient { - search: >( - query: SearchRequest | SearchRequestWithBody, - options?: TransportRequestOptions - ) => Promise, unknown>>; -} - -export interface IAbortableClusterClient { - readonly asInternalUser: IAbortableEsClient; - readonly asCurrentUser: IAbortableEsClient; -} - -export interface CreateAbortableEsClientFactoryOpts { - scopedClusterClient: IScopedClusterClient; - abortController: AbortController; -} - -export function createAbortableEsClientFactory(opts: CreateAbortableEsClientFactoryOpts) { - const { scopedClusterClient, abortController } = opts; - return { - asInternalUser: { - search: async >( - query: SearchRequest | SearchRequestWithBody, - options?: TransportRequestOptions - ) => { - try { - const searchOptions = options ?? {}; - return await scopedClusterClient.asInternalUser.search(query, { - ...searchOptions, - signal: abortController.signal, - meta: true, - }); - } catch (e) { - if (abortController.signal.aborted) { - throw new Error('Search has been aborted due to cancelled execution'); - } - throw e; - } - }, - }, - asCurrentUser: { - search: async >( - query: SearchRequest | SearchRequestWithBody, - options?: TransportRequestOptions - ) => { - try { - const searchOptions = options ?? {}; - return await scopedClusterClient.asCurrentUser.search(query, { - ...searchOptions, - signal: abortController.signal, - meta: true, - }); - } catch (e) { - if (abortController.signal.aborted) { - throw new Error('Search has been aborted due to cancelled execution'); - } - throw e; - } - }, - }, - }; -} diff --git a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.test.ts b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.test.ts index 94daa0030cd6..046b18553d04 100644 --- a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.test.ts +++ b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.test.ts @@ -9,7 +9,6 @@ import { Client } from '@elastic/elasticsearch'; import { loggingSystemMock } from 'src/core/server/mocks'; import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { createWrappedScopedClusterClientFactory } from './wrap_scoped_cluster_client'; -import { ElasticsearchClientWithChild } from '../types'; const esQuery = { body: { query: { bool: { filter: { range: { '@timestamp': { gte: 0 } } } } } }, @@ -33,78 +32,86 @@ describe('wrapScopedClusterClient', () => { jest.useRealTimers(); }); + afterEach(() => { + jest.resetAllMocks(); + }); + test('searches with asInternalUser when specified', async () => { + const abortController = new AbortController(); const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); const childClient = elasticsearchServiceMock.createElasticsearchClient(); - ( - scopedClusterClient.asInternalUser as unknown as jest.Mocked - ).child.mockReturnValue(childClient as unknown as Client); + scopedClusterClient.asInternalUser.child.mockReturnValue(childClient as unknown as Client); const asInternalUserWrappedSearchFn = childClient.search; const wrappedSearchClient = createWrappedScopedClusterClientFactory({ scopedClusterClient, rule, logger, + abortController, }).client(); await wrappedSearchClient.asInternalUser.search(esQuery); - expect(asInternalUserWrappedSearchFn).toHaveBeenCalledWith(esQuery, {}); + expect(asInternalUserWrappedSearchFn).toHaveBeenCalledWith(esQuery, { + signal: abortController.signal, + }); expect(scopedClusterClient.asInternalUser.search).not.toHaveBeenCalled(); expect(scopedClusterClient.asCurrentUser.search).not.toHaveBeenCalled(); }); test('searches with asCurrentUser when specified', async () => { + const abortController = new AbortController(); const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); const childClient = elasticsearchServiceMock.createElasticsearchClient(); - ( - scopedClusterClient.asCurrentUser as unknown as jest.Mocked - ).child.mockReturnValue(childClient as unknown as Client); + scopedClusterClient.asCurrentUser.child.mockReturnValue(childClient as unknown as Client); const asCurrentUserWrappedSearchFn = childClient.search; const wrappedSearchClient = createWrappedScopedClusterClientFactory({ scopedClusterClient, rule, logger, + abortController, }).client(); await wrappedSearchClient.asCurrentUser.search(esQuery); - expect(asCurrentUserWrappedSearchFn).toHaveBeenCalledWith(esQuery, {}); + expect(asCurrentUserWrappedSearchFn).toHaveBeenCalledWith(esQuery, { + signal: abortController.signal, + }); expect(scopedClusterClient.asInternalUser.search).not.toHaveBeenCalled(); expect(scopedClusterClient.asCurrentUser.search).not.toHaveBeenCalled(); }); test('uses search options when specified', async () => { + const abortController = new AbortController(); const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); const childClient = elasticsearchServiceMock.createElasticsearchClient(); - ( - scopedClusterClient.asInternalUser as unknown as jest.Mocked - ).child.mockReturnValue(childClient as unknown as Client); + scopedClusterClient.asInternalUser.child.mockReturnValue(childClient as unknown as Client); const asInternalUserWrappedSearchFn = childClient.search; const wrappedSearchClient = createWrappedScopedClusterClientFactory({ scopedClusterClient, rule, logger, + abortController, }).client(); await wrappedSearchClient.asInternalUser.search(esQuery, { ignore: [404] }); expect(asInternalUserWrappedSearchFn).toHaveBeenCalledWith(esQuery, { ignore: [404], + signal: abortController.signal, }); expect(scopedClusterClient.asInternalUser.search).not.toHaveBeenCalled(); expect(scopedClusterClient.asCurrentUser.search).not.toHaveBeenCalled(); }); test('re-throws error when search throws error', async () => { + const abortController = new AbortController(); const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); const childClient = elasticsearchServiceMock.createElasticsearchClient(); - ( - scopedClusterClient.asInternalUser as unknown as jest.Mocked - ).child.mockReturnValue(childClient as unknown as Client); + scopedClusterClient.asInternalUser.child.mockReturnValue(childClient as unknown as Client); const asInternalUserWrappedSearchFn = childClient.search; asInternalUserWrappedSearchFn.mockRejectedValueOnce(new Error('something went wrong!')); @@ -112,6 +119,7 @@ describe('wrapScopedClusterClient', () => { scopedClusterClient, rule, logger, + abortController, }).client(); await expect( @@ -119,13 +127,41 @@ describe('wrapScopedClusterClient', () => { ).rejects.toThrowErrorMatchingInlineSnapshot(`"something went wrong!"`); }); + test('handles empty search result object', async () => { + const abortController = new AbortController(); + const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + const childClient = elasticsearchServiceMock.createElasticsearchClient(); + + scopedClusterClient.asInternalUser.child.mockReturnValue(childClient as unknown as Client); + const asInternalUserWrappedSearchFn = childClient.search; + // @ts-ignore incomplete return type + asInternalUserWrappedSearchFn.mockResolvedValue({}); + + const wrappedSearchClientFactory = createWrappedScopedClusterClientFactory({ + scopedClusterClient, + rule, + logger, + abortController, + }); + + const wrappedSearchClient = wrappedSearchClientFactory.client(); + await wrappedSearchClient.asInternalUser.search(esQuery); + + expect(asInternalUserWrappedSearchFn).toHaveBeenCalledTimes(1); + expect(scopedClusterClient.asInternalUser.search).not.toHaveBeenCalled(); + expect(scopedClusterClient.asCurrentUser.search).not.toHaveBeenCalled(); + + const stats = wrappedSearchClientFactory.getMetrics(); + expect(stats.numSearches).toEqual(1); + expect(stats.esSearchDurationMs).toEqual(0); + }); + test('keeps track of number of queries', async () => { + const abortController = new AbortController(); const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); const childClient = elasticsearchServiceMock.createElasticsearchClient(); - ( - scopedClusterClient.asInternalUser as unknown as jest.Mocked - ).child.mockReturnValue(childClient as unknown as Client); + scopedClusterClient.asInternalUser.child.mockReturnValue(childClient as unknown as Client); const asInternalUserWrappedSearchFn = childClient.search; // @ts-ignore incomplete return type asInternalUserWrappedSearchFn.mockResolvedValue({ took: 333 }); @@ -134,6 +170,7 @@ describe('wrapScopedClusterClient', () => { scopedClusterClient, rule, logger, + abortController, }); const wrappedSearchClient = wrappedSearchClientFactory.client(); await wrappedSearchClient.asInternalUser.search(esQuery); @@ -152,4 +189,27 @@ describe('wrapScopedClusterClient', () => { `executing query for rule .test-rule-type:abcdefg in space my-space - {\"body\":{\"query\":{\"bool\":{\"filter\":{\"range\":{\"@timestamp\":{\"gte\":0}}}}}}} - with options {}` ); }); + + test('throws error when search throws abort error', async () => { + const abortController = new AbortController(); + abortController.abort(); + const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + const childClient = elasticsearchServiceMock.createElasticsearchClient(); + + scopedClusterClient.asInternalUser.child.mockReturnValue(childClient as unknown as Client); + childClient.search.mockRejectedValueOnce(new Error('Request has been aborted by the user')); + + const abortableSearchClient = createWrappedScopedClusterClientFactory({ + scopedClusterClient, + rule, + logger, + abortController, + }).client(); + + await expect( + abortableSearchClient.asInternalUser.search + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Search has been aborted due to cancelled execution"` + ); + }); }); diff --git a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts index 00ee3302c88c..69d4817f21a4 100644 --- a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts +++ b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts @@ -21,7 +21,7 @@ import type { AggregationsAggregate, } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { IScopedClusterClient, ElasticsearchClient, Logger } from 'src/core/server'; -import { ElasticsearchClientWithChild, RuleExecutionMetrics } from '../types'; +import { RuleExecutionMetrics } from '../types'; import { Alert as Rule } from '../types'; type RuleInfo = Pick & { spaceId: string }; @@ -29,6 +29,7 @@ interface WrapScopedClusterClientFactoryOpts { scopedClusterClient: IScopedClusterClient; rule: RuleInfo; logger: Logger; + abortController: AbortController; } type WrapScopedClusterClientOpts = WrapScopedClusterClientFactoryOpts & { @@ -87,8 +88,7 @@ function wrapScopedClusterClient(opts: WrapScopedClusterClientOpts): IScopedClus function wrapEsClient(opts: WrapEsClientOpts): ElasticsearchClient { const { esClient, ...rest } = opts; - // Core hides access to .child via TS - const wrappedClient = (esClient as ElasticsearchClientWithChild).child({}); + const wrappedClient = esClient.child({}); // Mutating the functions we want to wrap wrappedClient.search = getWrappedSearchFn({ esClient: wrappedClient, ...rest }); @@ -141,6 +141,7 @@ function getWrappedSearchFn(opts: WrapEsClientOpts) { ); const result = (await originalSearch.call(opts.esClient, params, { ...searchOptions, + signal: opts.abortController.signal, })) as | TransportResult, unknown> | SearchResponse; @@ -158,9 +159,12 @@ function getWrappedSearchFn(opts: WrapEsClientOpts) { took = (result as SearchResponse).took; } - opts.logMetricsFn({ esSearchDuration: took, totalSearchDuration: durationMs }); + opts.logMetricsFn({ esSearchDuration: took ?? 0, totalSearchDuration: durationMs }); return result; } catch (e) { + if (opts.abortController.signal.aborted) { + throw new Error('Search has been aborted due to cancelled execution'); + } throw e; } } diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index d58630e18cd4..a94b30b59104 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -11,6 +11,7 @@ import { Alert, AlertFactoryDoneUtils } from './alert'; import { elasticsearchServiceMock, savedObjectsClientMock, + uiSettingsServiceMock, } from '../../../../src/core/server/mocks'; import { AlertInstanceContext, AlertInstanceState } from './types'; @@ -105,6 +106,7 @@ const createAlertServicesMock = < done: jest.fn().mockReturnValue(alertFactoryMockDone), }, savedObjectsClient: savedObjectsClientMock.create(), + uiSettingsClient: uiSettingsServiceMock.createClient(), scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), shouldWriteAlerts: () => true, shouldStopExecution: () => true, diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 760aa6e0050a..939068e23e2b 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -384,6 +384,7 @@ export class AlertingPlugin { taskRunnerFactory.initialize({ logger, savedObjects: core.savedObjects, + uiSettings: core.uiSettings, elasticsearch: core.elasticsearch, getRulesClientWithRequest, spaceIdToNamespace, diff --git a/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.test.ts new file mode 100644 index 000000000000..d8618d0ed6c2 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright 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 { fromKueryExpression } from '@kbn/es-query'; +import { + getMappedParams, + getModifiedFilter, + getModifiedField, + getModifiedSearchFields, + getModifiedSearch, + getModifiedValue, + modifyFilterKueryNode, +} from './mapped_params_utils'; + +describe('getModifiedParams', () => { + it('converts params to mapped params', () => { + const params = { + riskScore: 42, + severity: 'medium', + a: 'test', + b: 'test', + c: 'test,', + }; + + expect(getMappedParams(params)).toEqual({ + risk_score: 42, + severity: '40-medium', + }); + }); + + it('returns empty mapped params if nothing exists in the input params', () => { + const params = { + a: 'test', + b: 'test', + c: 'test', + }; + + expect(getMappedParams(params)).toEqual({}); + }); +}); + +describe('getModifiedFilter', () => { + it('converts params filters to mapped params filters', () => { + // Make sure it works for both camel and snake case params + const filter = 'alert.attributes.params.risk_score: 45'; + + expect(getModifiedFilter(filter)).toEqual('alert.attributes.mapped_params.risk_score: 45'); + }); +}); + +describe('getModifiedField', () => { + it('converts sort field to mapped params sort field', () => { + expect(getModifiedField('params.risk_score')).toEqual('mapped_params.risk_score'); + expect(getModifiedField('params.riskScore')).toEqual('mapped_params.risk_score'); + expect(getModifiedField('params.invalid')).toEqual('params.invalid'); + }); +}); + +describe('getModifiedSearchFields', () => { + it('converts a list of params search fields to mapped param search fields', () => { + const searchFields = [ + 'params.risk_score', + 'params.riskScore', + 'params.severity', + 'params.invalid', + 'invalid', + ]; + + expect(getModifiedSearchFields(searchFields)).toEqual([ + 'mapped_params.risk_score', + 'mapped_params.risk_score', + 'mapped_params.severity', + 'params.invalid', + 'invalid', + ]); + }); +}); + +describe('getModifiedSearch', () => { + it('converts the search value depending on the search field', () => { + const searchFields = ['params.severity', 'another']; + + expect(getModifiedSearch(searchFields, 'medium')).toEqual('40-medium'); + expect(getModifiedSearch(searchFields, 'something else')).toEqual('something else'); + expect(getModifiedSearch('params.risk_score', 'something else')).toEqual('something else'); + expect(getModifiedSearch('mapped_params.severity', 'medium')).toEqual('40-medium'); + }); +}); + +describe('getModifiedValue', () => { + it('converts severity strings to sortable strings', () => { + expect(getModifiedValue('severity', 'low')).toEqual('20-low'); + expect(getModifiedValue('severity', 'medium')).toEqual('40-medium'); + expect(getModifiedValue('severity', 'high')).toEqual('60-high'); + expect(getModifiedValue('severity', 'critical')).toEqual('80-critical'); + }); +}); + +describe('modifyFilterKueryNode', () => { + it('modifies the resulting kuery node AST filter for alert params', () => { + const astFilter = fromKueryExpression( + 'alert.attributes.name: "Rule I" and alert.attributes.tags: "fast" and alert.attributes.params.severity > medium' + ); + + expect(astFilter.arguments[2].arguments[0]).toEqual({ + type: 'literal', + value: 'alert.attributes.params.severity', + }); + + expect(astFilter.arguments[2].arguments[2]).toEqual({ + type: 'literal', + value: 'medium', + }); + + modifyFilterKueryNode({ astFilter }); + + expect(astFilter.arguments[2].arguments[0]).toEqual({ + type: 'literal', + value: 'alert.attributes.mapped_params.severity', + }); + + expect(astFilter.arguments[2].arguments[2]).toEqual({ + type: 'literal', + value: '40-medium', + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.ts b/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.ts new file mode 100644 index 000000000000..b4d82990654c --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { snakeCase } from 'lodash'; +import { AlertTypeParams, MappedParams, MappedParamsProperties } from '../../types'; +import { SavedObjectAttribute } from '../../../../../../src/core/server'; +import { + iterateFilterKureyNode, + IterateFilterKureyNodeParams, + IterateActionProps, + getFieldNameAttribute, +} from './validate_attributes'; + +export const MAPPED_PARAMS_PROPERTIES: Array = [ + 'risk_score', + 'severity', +]; + +const SEVERITY_MAP: Record = { + low: '20-low', + medium: '40-medium', + high: '60-high', + critical: '80-critical', +}; + +/** + * Returns the mapped_params object when given a params object. + * The function will match params present in MAPPED_PARAMS_PROPERTIES and + * return an empty object if nothing is matched. + */ +export const getMappedParams = (params: AlertTypeParams) => { + return Object.entries(params).reduce((result, [key, value]) => { + const snakeCaseKey = snakeCase(key); + + if (MAPPED_PARAMS_PROPERTIES.includes(snakeCaseKey as keyof MappedParamsProperties)) { + result[snakeCaseKey] = getModifiedValue( + snakeCaseKey, + value as string + ) as SavedObjectAttribute; + } + + return result; + }, {}); +}; + +/** + * Returns a string of the filter, but with params replaced with mapped_params. + * This function will check both camel and snake case to make sure we're consistent + * with the naming + * + * i.e.: 'alerts.attributes.params.riskScore' -> 'alerts.attributes.mapped_params.risk_score' + */ +export const getModifiedFilter = (filter: string) => { + return filter.replace('.params.', '.mapped_params.'); +}; + +/** + * Returns modified field with mapped_params instead of params. + * + * i.e.: 'params.riskScore' -> 'mapped_params.risk_score' + */ +export const getModifiedField = (field: string | undefined) => { + if (!field) { + return field; + } + + const sortFieldToReplace = `${snakeCase(field.replace('params.', ''))}`; + + if (MAPPED_PARAMS_PROPERTIES.includes(sortFieldToReplace as keyof MappedParamsProperties)) { + return `mapped_params.${sortFieldToReplace}`; + } + + return field; +}; + +/** + * Returns modified search fields with mapped_params instead of params. + * + * i.e.: + * [ + * 'params.riskScore', + * 'params.severity', + * ] + * -> + * [ + * 'mapped_params.riskScore', + * 'mapped_params.severity', + * ] + */ +export const getModifiedSearchFields = (searchFields: string[] | undefined) => { + if (!searchFields) { + return searchFields; + } + + return searchFields.reduce((result, field) => { + const modifiedField = getModifiedField(field); + if (modifiedField) { + return [...result, modifiedField]; + } + return result; + }, []); +}; + +export const getModifiedValue = (key: string, value: string) => { + if (key === 'severity') { + return SEVERITY_MAP[value] || ''; + } + return value; +}; + +export const getModifiedSearch = (searchFields: string | string[] | undefined, value: string) => { + if (!searchFields) { + return value; + } + + const fieldNames = Array.isArray(searchFields) ? searchFields : [searchFields]; + + const modifiedSearchValues = fieldNames.map((fieldName) => { + const firstAttribute = getFieldNameAttribute(fieldName, [ + 'alert', + 'attributes', + 'params', + 'mapped_params', + ]); + return getModifiedValue(firstAttribute, value); + }); + + return modifiedSearchValues.find((search) => search !== value) || value; +}; + +export const modifyFilterKueryNode = ({ + astFilter, + hasNestedKey = false, + nestedKeys, + storeValue, + path = 'arguments', +}: IterateFilterKureyNodeParams) => { + const action = ({ index, ast, fieldName, localFieldName }: IterateActionProps) => { + // First index, assuming ast value is the attribute name + if (index === 0) { + const firstAttribute = getFieldNameAttribute(fieldName, ['alert', 'attributes']); + // Replace the ast.value for params to mapped_params + if (firstAttribute === 'params') { + ast.value = getModifiedFilter(ast.value); + } + } + + // Subsequent indices, assuming ast value is the filtering value + else { + const firstAttribute = getFieldNameAttribute(localFieldName, ['alert', 'attributes']); + + // Replace the ast.value for params value to the modified mapped_params value + if (firstAttribute === 'params' && ast.value) { + const attribute = getFieldNameAttribute(localFieldName, ['alert', 'attributes', 'params']); + ast.value = getModifiedValue(attribute, ast.value); + } + } + }; + + iterateFilterKureyNode({ + astFilter, + hasNestedKey, + nestedKeys, + storeValue, + path, + action, + }); +}; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.test.ts index 652c30ff380c..1777a36d80a2 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.test.ts @@ -13,7 +13,7 @@ import { } from './validate_attributes'; describe('Validate attributes', () => { - const excludedFieldNames = ['monitoring']; + const excludedFieldNames = ['monitoring', 'mapped_params']; describe('validateSortField', () => { test('should NOT throw an error, when sort field is not part of the field to exclude', () => { expect(() => validateSortField('name.keyword', excludedFieldNames)).not.toThrow(); @@ -86,6 +86,17 @@ describe('Validate attributes', () => { ).not.toThrow(); }); + test('should NOT throw an error, when filter contains params with validate properties', () => { + expect(() => + validateFilterKueryNode({ + astFilter: esKuery.fromKueryExpression( + 'alert.attributes.name: "Rule I" and alert.attributes.tags: "fast" and alert.attributes.params.risk_score > 50' + ), + excludedFieldNames, + }) + ).not.toThrow(); + }); + test('should throw an error, when filter contains the field to exclude', () => { expect(() => validateFilterKueryNode({ @@ -111,5 +122,18 @@ describe('Validate attributes', () => { `"Filter is not supported on this field alert.attributes.actions"` ); }); + + test('should throw an error, when filtering contains a property that is not valid', () => { + expect(() => + validateFilterKueryNode({ + astFilter: esKuery.fromKueryExpression( + 'alert.attributes.name: "Rule I" and alert.attributes.tags: "fast" and alert.attributes.mapped_params.risk_score > 50' + ), + excludedFieldNames, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Filter is not supported on this field alert.attributes.mapped_params.risk_score"` + ); + }); }); }); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.ts b/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.ts index fa65f4c2f099..ad17ede1b99a 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.ts @@ -7,11 +7,18 @@ import { KueryNode } from '@kbn/es-query'; import { get, isEmpty } from 'lodash'; - import mappings from '../../saved_objects/mappings.json'; const astFunctionType = ['is', 'range', 'nested']; +export const getFieldNameAttribute = (fieldName: string, attributesToIgnore: string[]) => { + const fieldNameSplit = (fieldName || '') + .split('.') + .filter((fn: string) => !attributesToIgnore.includes(fn)); + + return fieldNameSplit.length > 0 ? fieldNameSplit[0] : ''; +}; + export const validateOperationOnAttributes = ( astFilter: KueryNode | null, sortField: string | undefined, @@ -44,28 +51,41 @@ export const validateSearchFields = (searchFields: string[], excludedFieldNames: } }; -interface ValidateFilterKueryNodeParams { +export interface IterateActionProps { + ast: KueryNode; + index: number; + fieldName: string; + localFieldName: string; +} + +export interface IterateFilterKureyNodeParams { astFilter: KueryNode; - excludedFieldNames: string[]; hasNestedKey?: boolean; nestedKeys?: string; storeValue?: boolean; path?: string; + action?: (props: IterateActionProps) => void; } -export const validateFilterKueryNode = ({ +export interface ValidateFilterKueryNodeParams extends IterateFilterKureyNodeParams { + excludedFieldNames: string[]; +} + +export const iterateFilterKureyNode = ({ astFilter, - excludedFieldNames, hasNestedKey = false, nestedKeys, storeValue, path = 'arguments', -}: ValidateFilterKueryNodeParams) => { + action = () => {}, +}: IterateFilterKureyNodeParams) => { let localStoreValue = storeValue; let localNestedKeys: string | undefined; + let localFieldName: string = ''; if (localStoreValue === undefined) { localStoreValue = astFilter.type === 'function' && astFunctionType.includes(astFilter.function); } + astFilter.arguments.forEach((ast: KueryNode, index: number) => { if (hasNestedKey && ast.type === 'literal' && ast.value != null) { localNestedKeys = ast.value; @@ -80,25 +100,56 @@ export const validateFilterKueryNode = ({ if (ast.arguments) { const myPath = `${path}.${index}`; - validateFilterKueryNode({ + iterateFilterKureyNode({ astFilter: ast, - excludedFieldNames, storeValue: ast.type === 'function' && astFunctionType.includes(ast.function), path: `${myPath}.arguments`, hasNestedKey: ast.type === 'function' && ast.function === 'nested', nestedKeys: localNestedKeys || nestedKeys, + action, }); } - if (localStoreValue && index === 0) { + if (localStoreValue) { const fieldName = nestedKeys != null ? `${nestedKeys}.${ast.value}` : ast.value; - const fieldNameSplit = fieldName - .split('.') - .filter((fn: string) => !['alert', 'attributes'].includes(fn)); - const firstAttribute = fieldNameSplit.length > 0 ? fieldNameSplit[0] : ''; + + if (index === 0) { + localFieldName = fieldName; + } + + action({ + ast, + index, + fieldName, + localFieldName, + }); + } + }); +}; + +export const validateFilterKueryNode = ({ + astFilter, + excludedFieldNames, + hasNestedKey = false, + nestedKeys, + storeValue, + path = 'arguments', +}: ValidateFilterKueryNodeParams) => { + const action = ({ index, fieldName }: IterateActionProps) => { + if (index === 0) { + const firstAttribute = getFieldNameAttribute(fieldName, ['alert', 'attributes']); if (excludedFieldNames.includes(firstAttribute)) { throw new Error(`Filter is not supported on this field ${fieldName}`); } } + }; + + iterateFilterKureyNode({ + astFilter, + hasNestedKey, + nestedKeys, + storeValue, + path, + action, }); }; 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 6d3ffc822a62..1512959384ac 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -78,6 +78,13 @@ import { Alert } from '../alert'; import { EVENT_LOG_ACTIONS } from '../plugin'; import { createAlertEventLogRecordObject } from '../lib/create_alert_event_log_record_object'; import { getDefaultRuleMonitoring } from '../task_runner/task_runner'; +import { + getMappedParams, + getModifiedField, + getModifiedSearchFields, + getModifiedSearch, + modifyFilterKueryNode, +} from './lib/mapped_params_utils'; export interface RegistryAlertTypeWithAuth extends RegistryRuleType { authorizedConsumers: string[]; @@ -251,7 +258,10 @@ export class RulesClient { private readonly kibanaVersion!: PluginInitializerContext['env']['packageInfo']['version']; private readonly auditLogger?: AuditLogger; private readonly eventLogger?: IEventLogger; - private readonly fieldsToExcludeFromPublicApi: Array = ['monitoring']; + private readonly fieldsToExcludeFromPublicApi: Array = [ + 'monitoring', + 'mapped_params', + ]; constructor({ ruleTypeRegistry, @@ -371,6 +381,12 @@ export class RulesClient { monitoring: getDefaultRuleMonitoring(), }; + const mappedParams = getMappedParams(updatedParams); + + if (Object.keys(mappedParams).length) { + rawRule.mapped_params = mappedParams; + } + this.auditLogger?.log( ruleAuditEvent({ action: RuleAuditAction.CREATE, @@ -578,7 +594,7 @@ export class RulesClient { page: 1, per_page: 10000, start: parsedDateStart.toISOString(), - sort_order: 'desc', + sort: [{ sort_field: '@timestamp', sort_order: 'desc' }], end: dateNow.toISOString(), }, rule.legacyId !== null ? [rule.legacyId] : undefined @@ -590,7 +606,7 @@ export class RulesClient { page: 1, per_page: numberOfExecutions ?? 60, filter: 'event.provider: alerting AND event.action:execute', - sort_order: 'desc', + sort: [{ sort_field: '@timestamp', sort_order: 'desc' }], end: dateNow.toISOString(), }, rule.legacyId !== null ? [rule.legacyId] : undefined @@ -634,9 +650,10 @@ export class RulesClient { ); throw error; } + const { filter: authorizationFilter, ensureRuleTypeIsAuthorized } = authorizationTuple; const filterKueryNode = options.filter ? esKuery.fromKueryExpression(options.filter) : null; - const sortField = mapSortField(options.sortField); + let sortField = mapSortField(options.sortField); if (excludeFromPublicApi) { try { validateOperationOnAttributes( @@ -650,6 +667,24 @@ export class RulesClient { } } + sortField = mapSortField(getModifiedField(options.sortField)); + + // Generate new modified search and search fields, translating certain params properties + // to mapped_params. Thus, allowing for sort/search/filtering on params. + // We do the modifcation after the validate check to make sure the public API does not + // use the mapped_params in their queries. + options = { + ...options, + ...(options.searchFields && { searchFields: getModifiedSearchFields(options.searchFields) }), + ...(options.search && { search: getModifiedSearch(options.searchFields, options.search) }), + }; + + // Modifies kuery node AST to translate params filter and the filter value to mapped_params. + // This translation is done in place, and therefore is not a pure function. + if (filterKueryNode) { + modifyFilterKueryNode({ astFilter: filterKueryNode }); + } + const { page, per_page: perPage, @@ -1027,6 +1062,13 @@ export class RulesClient { updatedBy: username, updatedAt: new Date().toISOString(), }); + + const mappedParams = getMappedParams(updatedParams); + + if (Object.keys(mappedParams).length) { + createAttributes.mapped_params = mappedParams; + } + try { updatedObject = await this.unsecuredSavedObjectsClient.create( 'alert', 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 6ccc640dcc13..8cecb47f23a8 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 @@ -1878,6 +1878,167 @@ describe('create()', () => { `); }); + test('should create alerts with mapped_params', async () => { + const data = getMockData({ + params: { + bar: true, + risk_score: 42, + severity: 'low', + }, + }); + + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + risk_score: 42, + severity: 'low', + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '123', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + + const result = await rulesClient.create({ data }); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + enabled: true, + name: 'abc', + tags: ['foo'], + alertTypeId: '123', + consumer: 'bar', + schedule: { + interval: '1m', + }, + throttle: null, + notifyWhen: 'onActiveAlert', + params: { + bar: true, + risk_score: 42, + severity: 'low', + }, + actions: [ + { + group: 'default', + params: { + foo: true, + }, + actionRef: 'action_0', + actionTypeId: 'test', + }, + ], + apiKeyOwner: null, + apiKey: null, + legacyId: null, + createdBy: 'elastic', + updatedBy: 'elastic', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'pending', + lastExecutionDate: '2019-02-12T21:01:22.479Z', + error: null, + }, + monitoring: { + execution: { + history: [], + calculated_metrics: { + success_ratio: 0, + }, + }, + }, + mapped_params: { + risk_score: 42, + severity: '20-low', + }, + meta: { + versionApiKeyLastmodified: 'v8.0.0', + }, + }, + { + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + id: 'mock-saved-object-id', + } + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "consumer": "bar", + "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, + "id": "123", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", + "notifyWhen": null, + "params": Object { + "bar": true, + "risk_score": 42, + "severity": "low", + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": null, + "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", + } + `); + }); + test('should validate params', async () => { const data = getMockData(); ruleTypeRegistry.get.mockReturnValue({ diff --git a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts index 60aac3f266e7..bd382faa6d6c 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts @@ -290,6 +290,37 @@ describe('find()', () => { expect(jest.requireMock('../lib/map_sort_field').mapSortField).toHaveBeenCalledWith('name'); }); + test('should translate filter/sort/search on params to mapped_params', async () => { + const filter = esKuery.fromKueryExpression( + '((alert.attributes.alertTypeId:myType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myOtherApp))' + ); + authorization.getFindAuthorizationFilter.mockResolvedValue({ + filter, + ensureRuleTypeIsAuthorized() {}, + }); + + const rulesClient = new RulesClient(rulesClientParams); + await rulesClient.find({ + options: { + sortField: 'params.risk_score', + searchFields: ['params.risk_score', 'params.severity'], + filter: 'alert.attributes.params.risk_score > 50', + }, + excludeFromPublicApi: true, + }); + + const findCallParams = unsecuredSavedObjectsClient.find.mock.calls[0][0]; + + expect(findCallParams.searchFields).toEqual([ + 'mapped_params.risk_score', + 'mapped_params.severity', + ]); + + expect(findCallParams.filter.arguments[0].arguments[0].value).toEqual( + 'alert.attributes.mapped_params.risk_score' + ); + }); + test('should call useSavedObjectReferences.injectReferences if defined for rule type', async () => { jest.resetAllMocks(); authorization.getFindAuthorizationFilter.mockResolvedValue({ diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts index 4e6f627dcd4a..fcf90bc35036 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts @@ -223,7 +223,12 @@ describe('getAlertSummary()', () => { "end": "2019-02-12T21:01:22.479Z", "page": 1, "per_page": 10000, - "sort_order": "desc", + "sort": Array [ + Object { + "sort_field": "@timestamp", + "sort_order": "desc", + }, + ], "start": "2019-02-12T21:00:22.479Z", }, undefined, @@ -260,7 +265,12 @@ describe('getAlertSummary()', () => { "end": "2019-02-12T21:01:22.479Z", "page": 1, "per_page": 10000, - "sort_order": "desc", + "sort": Array [ + Object { + "sort_field": "@timestamp", + "sort_order": "desc", + }, + ], "start": "2019-02-12T21:00:22.479Z", }, Array [ 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 1def4b7d60f4..be2f859ac96b 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 @@ -252,6 +252,8 @@ describe('update()', () => { tags: ['foo'], params: { bar: true, + risk_score: 40, + severity: 'low', }, throttle: null, notifyWhen: 'onActiveAlert', @@ -362,6 +364,10 @@ describe('update()', () => { "apiKeyOwner": null, "consumer": "myApp", "enabled": true, + "mapped_params": Object { + "risk_score": 40, + "severity": "20-low", + }, "meta": Object { "versionApiKeyLastmodified": "v7.10.0", }, @@ -369,6 +375,8 @@ describe('update()', () => { "notifyWhen": "onActiveAlert", "params": Object { "bar": true, + "risk_score": 40, + "severity": "low", }, "schedule": Object { "interval": "1m", diff --git a/x-pack/plugins/alerting/server/saved_objects/mappings.json b/x-pack/plugins/alerting/server/saved_objects/mappings.json index d6ebd25d4af3..e6eedced7891 100644 --- a/x-pack/plugins/alerting/server/saved_objects/mappings.json +++ b/x-pack/plugins/alerting/server/saved_objects/mappings.json @@ -53,6 +53,16 @@ "type": "flattened", "ignore_above": 4096 }, + "mapped_params": { + "properties": { + "risk_score": { + "type": "float" + }, + "severity": { + "type": "keyword" + } + } + }, "scheduledTaskId": { "type": "keyword" }, @@ -155,6 +165,10 @@ } } } + }, + "snoozeEndTime": { + "type": "date", + "format": "strict_date_time" } } } diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts index 1d7d3d2a362a..28b1f599f957 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -2229,6 +2229,30 @@ describe('successful migrations', () => { ); }); + describe('8.2.0', () => { + test('migrates params to mapped_params', () => { + const migration820 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.2.0']; + const alert = getMockData( + { + params: { + risk_score: 60, + severity: 'high', + foo: 'bar', + }, + alertTypeId: 'siem.signals', + }, + true + ); + + const migratedAlert820 = migration820(alert, migrationContext); + + expect(migratedAlert820.attributes.mapped_params).toEqual({ + risk_score: 60, + severity: '60-high', + }); + }); + }); + describe('Metrics Inventory Threshold rule', () => { test('Migrates incorrect action group spelling', () => { const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index 6e6c886d91b5..09d505aec0f0 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -21,6 +21,7 @@ import { RawRule, RawAlertAction } from '../types'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; import type { IsMigrationNeededPredicate } from '../../../encrypted_saved_objects/server'; import { extractRefsFromGeoContainmentAlert } from './geo_containment/migrations'; +import { getMappedParams } from '../../server/rules_client/lib/mapped_params_utils'; const SIEM_APP_ID = 'securitySolution'; const SIEM_SERVER_APP_ID = 'siem'; @@ -145,6 +146,12 @@ export function getMigrations( pipeMigrations(addSecuritySolutionAADRuleTypeTags) ); + const migrationRules820 = createEsoMigration( + encryptedSavedObjects, + (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => true, + pipeMigrations(addMappedParams) + ); + return { '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), @@ -155,6 +162,7 @@ export function getMigrations( '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'), }; } @@ -822,6 +830,28 @@ function fixInventoryThresholdGroupId( } } +function addMappedParams( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + const { + attributes: { params }, + } = doc; + + const mappedParams = getMappedParams(params); + + if (Object.keys(mappedParams).length) { + return { + ...doc, + attributes: { + ...doc.attributes, + mapped_params: mappedParams, + }, + }; + } + + return doc; +} + function getCorrespondingAction( actions: SavedObjectAttribute, connectorRef: string diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 99feefb472df..bdebc66911e9 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 @@ -30,6 +30,7 @@ import { executionContextServiceMock, savedObjectsServiceMock, elasticsearchServiceMock, + uiSettingsServiceMock, } from '../../../../../src/core/server/mocks'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; @@ -95,6 +96,7 @@ describe('Task Runner', () => { const ruleTypeRegistry = ruleTypeRegistryMock.create(); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); const elasticsearchService = elasticsearchServiceMock.createInternalStart(); + const uiSettingsService = uiSettingsServiceMock.createStartContract(); type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { actionsPlugin: jest.Mocked; @@ -106,6 +108,7 @@ describe('Task Runner', () => { const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = { savedObjects: savedObjectsService, + uiSettings: uiSettingsService, elasticsearch: elasticsearchService, actionsPlugin: actionsMock.createStart(), getRulesClientWithRequest: jest.fn().mockReturnValue(rulesClient), 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 dbc7749a0fbd..b4cb262e571e 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -62,7 +62,6 @@ import { createAlertEventLogRecordObject, Event, } from '../lib/create_alert_event_log_record_object'; -import { createAbortableEsClientFactory } from '../lib/create_abortable_es_client_factory'; import { createWrappedScopedClusterClientFactory } from '../lib'; import { getRecoveredAlerts } from '../lib'; import { @@ -142,7 +141,7 @@ export class TaskRunner< this.executionId = uuid.v4(); } - async getDecryptedAttributes( + private async getDecryptedAttributes( ruleId: string, spaceId: string ): Promise<{ apiKey: string | null; enabled: boolean }> { @@ -267,7 +266,7 @@ export class TaskRunner< } } - async executeAlert( + private async executeAlert( alertId: string, alert: CreatedAlert, executionHandler: ExecutionHandler @@ -283,7 +282,7 @@ export class TaskRunner< return executionHandler({ actionGroup, actionSubgroup, context, state, alertId }); } - async executeAlerts( + private async executeAlerts( fakeRequest: KibanaRequest, rule: SanitizedAlert, params: Params, @@ -339,6 +338,7 @@ export class TaskRunner< spaceId, }, logger: this.logger, + abortController: this.searchAbortController, }); let updatedRuleTypeState: void | Record; @@ -352,14 +352,17 @@ export class TaskRunner< }] namespace`, }; + const savedObjectsClient = this.context.savedObjects.getScopedClient(fakeRequest, { + includedHiddenTypes: ['alert', 'action'], + }); + updatedRuleTypeState = await this.context.executionContext.withContext(ctx, () => this.ruleType.executor({ alertId: ruleId, executionId: this.executionId, services: { - savedObjectsClient: this.context.savedObjects.getScopedClient(fakeRequest, { - includedHiddenTypes: ['alert', 'action'], - }), + savedObjectsClient, + uiSettingsClient: this.context.uiSettings.asScopedToClient(savedObjectsClient), scopedClusterClient: wrappedScopedClusterClient.client(), alertFactory: createAlertFactory< InstanceState, @@ -372,10 +375,6 @@ export class TaskRunner< }), shouldWriteAlerts: () => this.shouldLogAndScheduleActionsForAlerts(), shouldStopExecution: () => this.cancelled, - search: createAbortableEsClientFactory({ - scopedClusterClient, - abortController: this.searchAbortController, - }), }, params, state: alertTypeState as State, @@ -545,7 +544,7 @@ export class TaskRunner< }; } - async validateAndExecuteRule( + private async validateAndExecuteRule( fakeRequest: KibanaRequest, apiKey: RawRule['apiKey'], rule: SanitizedAlert, @@ -571,7 +570,9 @@ export class TaskRunner< return this.executeAlerts(fakeRequest, rule, validatedParams, executionHandler, spaceId, event); } - async loadRuleAttributesAndRun(event: Event): Promise> { + private async loadRuleAttributesAndRun( + event: Event + ): Promise> { const { params: { alertId: ruleId, spaceId }, } = this.taskInstance; 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 d70b36ff48a8..add8d7a24912 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 @@ -25,6 +25,7 @@ import { executionContextServiceMock, savedObjectsServiceMock, elasticsearchServiceMock, + uiSettingsServiceMock, } from '../../../../../src/core/server/mocks'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; @@ -94,6 +95,7 @@ describe('Task Runner Cancel', () => { const ruleTypeRegistry = ruleTypeRegistryMock.create(); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); const elasticsearchService = elasticsearchServiceMock.createInternalStart(); + const uiSettingsService = uiSettingsServiceMock.createStartContract(); type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { actionsPlugin: jest.Mocked; @@ -103,6 +105,7 @@ describe('Task Runner Cancel', () => { const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = { savedObjects: savedObjectsService, + uiSettings: uiSettingsService, elasticsearch: elasticsearchService, actionsPlugin: actionsMock.createStart(), getRulesClientWithRequest: jest.fn().mockReturnValue(rulesClient), diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 6dea8df47550..d4e92015d411 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -16,6 +16,7 @@ import { httpServiceMock, savedObjectsServiceMock, elasticsearchServiceMock, + uiSettingsServiceMock, } from '../../../../../src/core/server/mocks'; import { actionsMock } from '../../../actions/server/mocks'; import { rulesClientMock } from '../mocks'; @@ -28,6 +29,7 @@ const executionContext = executionContextServiceMock.createSetupContract(); const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); +const uiSettingsService = uiSettingsServiceMock.createStartContract(); const elasticsearchService = elasticsearchServiceMock.createInternalStart(); const ruleType: UntypedNormalizedRuleType = { id: 'test', @@ -77,6 +79,7 @@ describe('Task Runner Factory', () => { const taskRunnerFactoryInitializerParams: jest.Mocked = { savedObjects: savedObjectsService, + uiSettings: uiSettingsService, elasticsearch: elasticsearchService, getRulesClientWithRequest: jest.fn().mockReturnValue(rulesClient), actionsPlugin: actionsMock.createStart(), diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts index f60370dd7daf..0b8ffe2f93d7 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -15,6 +15,7 @@ import type { ExecutionContextStart, SavedObjectsServiceStart, ElasticsearchServiceStart, + UiSettingsServiceStart, } from '../../../../../src/core/server'; import { RunContext } from '../../../task_manager/server'; import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; @@ -35,6 +36,7 @@ import { NormalizedRuleType } from '../rule_type_registry'; export interface TaskRunnerContext { logger: Logger; savedObjects: SavedObjectsServiceStart; + uiSettings: UiSettingsServiceStart; elasticsearch: ElasticsearchServiceStart; getRulesClientWithRequest(request: KibanaRequest): PublicMethodsOf; actionsPlugin: ActionsPluginStartContract; diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 95c1a07e241b..ea7e12b320d1 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -5,12 +5,11 @@ * 2.0. */ -import { Client } from '@elastic/elasticsearch'; import type { IRouter, RequestHandlerContext, SavedObjectReference, - ElasticsearchClient, + IUiSettingsClient, } from 'src/core/server'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { AlertFactoryDoneUtils, PublicAlert } from './alert'; @@ -39,17 +38,13 @@ import { ActionVariable, SanitizedRuleConfig, RuleMonitoring, + MappedParams, } from '../common'; import { LicenseType } from '../../licensing/server'; -import { IAbortableClusterClient } from './lib/create_abortable_es_client_factory'; export type WithoutQueryAndParams = Pick>; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; -export interface ElasticsearchClientWithChild extends ElasticsearchClient { - child: Client['child']; -} - /** * @public */ @@ -78,6 +73,7 @@ export interface AlertServices< ActionGroupIds extends string = never > { savedObjectsClient: SavedObjectsClientContract; + uiSettingsClient: IUiSettingsClient; scopedClusterClient: IScopedClusterClient; alertFactory: { create: (id: string) => PublicAlert; @@ -85,7 +81,6 @@ export interface AlertServices< }; shouldWriteAlerts: () => boolean; shouldStopExecution: () => boolean; - search: IAbortableClusterClient; } export interface AlertExecutorOptions< @@ -234,6 +229,7 @@ export interface RawRule extends SavedObjectAttributes { schedule: SavedObjectAttributes; actions: RawAlertAction[]; params: SavedObjectAttributes; + mapped_params?: MappedParams; scheduledTaskId?: string | null; createdBy: string | null; updatedBy: string | null; @@ -248,6 +244,7 @@ export interface RawRule extends SavedObjectAttributes { meta?: AlertMeta; executionStatus: RawRuleExecutionStatus; monitoring?: RuleMonitoring; + snoozeEndTime?: string; } export type AlertInfoParams = Pick< diff --git a/x-pack/plugins/apm/common/environment_filter_values.ts b/x-pack/plugins/apm/common/environment_filter_values.ts index f0bd386f36de..ddd1ffd9b8d4 100644 --- a/x-pack/plugins/apm/common/environment_filter_values.ts +++ b/x-pack/plugins/apm/common/environment_filter_values.ts @@ -12,6 +12,13 @@ import { Environment } from './environment_rt'; const ENVIRONMENT_ALL_VALUE = 'ENVIRONMENT_ALL' as const; const ENVIRONMENT_NOT_DEFINED_VALUE = 'ENVIRONMENT_NOT_DEFINED' as const; +export const allOptionText = i18n.translate( + 'xpack.apm.filter.environment.allLabel', + { + defaultMessage: 'All', + } +); + export function getEnvironmentLabel(environment: string) { if (!environment || environment === ENVIRONMENT_NOT_DEFINED_VALUE) { return i18n.translate('xpack.apm.filter.environment.notDefinedLabel', { @@ -20,19 +27,24 @@ export function getEnvironmentLabel(environment: string) { } if (environment === ENVIRONMENT_ALL_VALUE) { - return i18n.translate('xpack.apm.filter.environment.allLabel', { - defaultMessage: 'All', - }); + return allOptionText; } return environment; } -export const ENVIRONMENT_ALL = { +// #TODO Once we replace the select dropdown we can remove it +// EuiSelect > EuiSelectOption accepts text attribute +export const ENVIRONMENT_ALL_SELECT_OPTION = { value: ENVIRONMENT_ALL_VALUE, text: getEnvironmentLabel(ENVIRONMENT_ALL_VALUE), }; +export const ENVIRONMENT_ALL = { + value: ENVIRONMENT_ALL_VALUE, + label: getEnvironmentLabel(ENVIRONMENT_ALL_VALUE), +}; + export const ENVIRONMENT_NOT_DEFINED = { value: ENVIRONMENT_NOT_DEFINED_VALUE, text: getEnvironmentLabel(ENVIRONMENT_NOT_DEFINED_VALUE), diff --git a/x-pack/plugins/apm/common/service_groups.ts b/x-pack/plugins/apm/common/service_groups.ts new file mode 100644 index 000000000000..d56acc846dc1 --- /dev/null +++ b/x-pack/plugins/apm/common/service_groups.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. + */ + +export const APM_SERVICE_GROUP_SAVED_OBJECT_TYPE = 'apm-service-group'; +export const SERVICE_GROUP_COLOR_DEFAULT = '#D1DAE7'; +export const MAX_NUMBER_OF_SERVICES_IN_GROUP = 500; + +export interface ServiceGroup { + groupName: string; + kuery: string; + description?: string; + serviceNames: string[]; + color?: string; +} + +export interface SavedServiceGroup extends ServiceGroup { + id: string; + updatedAt: number; +} diff --git a/x-pack/plugins/apm/common/service_inventory.ts b/x-pack/plugins/apm/common/service_inventory.ts new file mode 100644 index 000000000000..b7c8c0ea90a5 --- /dev/null +++ b/x-pack/plugins/apm/common/service_inventory.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AgentName } from '../typings/es_schemas/ui/fields/agent'; +import { ServiceHealthStatus } from './service_health_status'; + +export interface ServiceListItem { + serviceName: string; + healthStatus?: ServiceHealthStatus; + transactionType?: string; + agentName?: AgentName; + throughput?: number; + latency?: number | null; + transactionErrorRate?: number | null; + environments?: string[]; +} diff --git a/x-pack/plugins/apm/common/utils/service_group_query.ts b/x-pack/plugins/apm/common/utils/service_group_query.ts new file mode 100644 index 000000000000..06bf48452f47 --- /dev/null +++ b/x-pack/plugins/apm/common/utils/service_group_query.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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { SERVICE_NAME } from '../elasticsearch_fieldnames'; +import { ServiceGroup } from '../service_groups'; + +export function serviceGroupQuery( + serviceGroup?: ServiceGroup | null +): QueryDslQueryContainer[] { + if (!serviceGroup) { + return []; + } + + return serviceGroup?.serviceNames + ? [{ terms: { [SERVICE_NAME]: serviceGroup.serviceNames } }] + : []; +} diff --git a/x-pack/plugins/apm/dev_docs/local_setup.md b/x-pack/plugins/apm/dev_docs/local_setup.md index 19864abd795b..f021f41b17c8 100644 --- a/x-pack/plugins/apm/dev_docs/local_setup.md +++ b/x-pack/plugins/apm/dev_docs/local_setup.md @@ -21,7 +21,7 @@ yarn es snapshot **Create APM mappings** ``` -node ./scripts/es_archiver load "x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_mappings_only_8.0.0" --es-url=http://elastic:changeme@localhost:9200 --kibana-url=http://elastic:changeme@localhost:5601 --config=./test/functional/config.js +node ./scripts/es_archiver load "x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_mappings_only_8.0.0" --es-url=http://system_indices_superuser:changeme@localhost:9200 --kibana-url=http://elastic:changeme@localhost:5601 --config=./test/functional/config.js ``` *Note: Elasticsearch must be available before running the above command* @@ -32,6 +32,15 @@ node ./scripts/es_archiver load "x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_ node packages/elastic-apm-synthtrace/src/scripts/run packages/elastic-apm-synthtrace/src/scripts/examples/01_simple_trace.ts --target=http://elastic:changeme@localhost:9200 ``` +**Connect Kibana to ES** +Update `config/kibana.dev.yml` with: + +```yml +elasticsearch.hosts: http://localhost:9200 +elasticsearch.username: kibana_system +elasticsearch.password: changeme +``` + Documentation for [Synthtrace](https://github.com/elastic/kibana/blob/main/packages/elastic-apm-synthtrace/README.md) ## 2. Cloud-based ES Cluster (internal devs only) diff --git a/x-pack/plugins/apm/dev_docs/testing.md b/x-pack/plugins/apm/dev_docs/testing.md index f6a8298ef9d0..6c35979add78 100644 --- a/x-pack/plugins/apm/dev_docs/testing.md +++ b/x-pack/plugins/apm/dev_docs/testing.md @@ -37,6 +37,7 @@ Once the tests finish, the instances will be terminated. ``` node scripts/test/api --server ``` + Start Elasticsearch and Kibana instances. ### Run all tests @@ -44,6 +45,7 @@ Start Elasticsearch and Kibana instances. ``` node scripts/test/api --runner ``` + Run all tests. The test server needs to be running, see [Start Test Server](#start-test-server). ### Update snapshots (from Kibana root) @@ -53,6 +55,7 @@ To update snapshots append `--updateSnapshots` to the `functional_test_runner` c ``` node scripts/functional_test_runner --config x-pack/test/apm_api_integration/[basic | trial]/config.ts --quiet --updateSnapshots ``` + The test server needs to be running, see [Start Test Server](#start-test-server). The API tests are located in [`x-pack/test/apm_api_integration/`](/x-pack/test/apm_api_integration/). @@ -66,11 +69,23 @@ The API tests are located in [`x-pack/test/apm_api_integration/`](/x-pack/test/a ## E2E Tests (Cypress) +The E2E tests are located in [`x-pack/plugins/apm/ftr_e2e`](../ftr_e2e) + +### Start test server + ``` -node scripts/test/e2e [--trial] [--help] +node x-pack/plugins/apm/scripts/test/e2e.js --server ``` -The E2E tests are located in [`x-pack/plugins/apm/ftr_e2e`](../ftr_e2e) +### Run tests + +``` +node x-pack/plugins/apm/scripts/test/e2e.js --open +``` + +### A11y checks + +Accessibility tests are added on the e2e with `checkA11y()`, they will run together with cypress. --- diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/synthtrace/opbeans.ts b/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/synthtrace/opbeans.ts index b51874f951c0..6ef38a9f63ba 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/synthtrace/opbeans.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/synthtrace/opbeans.ts @@ -24,47 +24,43 @@ export function opbeans({ from, to }: { from: number; to: number }) { apm.getChromeUserAgentDefaults() ); - return [ - ...range - .interval('1s') - .rate(1) - .flatMap((timestamp) => [ - ...opbeansJava - .transaction('GET /api/product') - .timestamp(timestamp) - .duration(1000) - .success() - .errors( - opbeansJava - .error('[MockError] Foo', `Exception`) - .timestamp(timestamp) - ) - .children( - opbeansJava - .span('SELECT * FROM product', 'db', 'postgresql') - .timestamp(timestamp) - .duration(50) - .success() - .destination('postgresql') - ) - .serialize(), - ...opbeansNode - .transaction('GET /api/product/:id') - .timestamp(timestamp) - .duration(500) - .success() - .serialize(), - ...opbeansNode - .transaction('Worker job', 'Worker') - .timestamp(timestamp) - .duration(1000) - .success() - .serialize(), - ...opbeansRum - .transaction('/') - .timestamp(timestamp) - .duration(1000) - .serialize(), - ]), - ]; + return range + .interval('1s') + .rate(1) + .spans((timestamp) => [ + ...opbeansJava + .transaction('GET /api/product') + .timestamp(timestamp) + .duration(1000) + .success() + .errors( + opbeansJava.error('[MockError] Foo', `Exception`).timestamp(timestamp) + ) + .children( + opbeansJava + .span('SELECT * FROM product', 'db', 'postgresql') + .timestamp(timestamp) + .duration(50) + .success() + .destination('postgresql') + ) + .serialize(), + ...opbeansNode + .transaction('GET /api/product/:id') + .timestamp(timestamp) + .duration(500) + .success() + .serialize(), + ...opbeansNode + .transaction('Worker job', 'Worker') + .timestamp(timestamp) + .duration(1000) + .success() + .serialize(), + ...opbeansRum + .transaction('/') + .timestamp(timestamp) + .duration(1000) + .serialize(), + ]); } diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/dependencies.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/dependencies.spec.ts index 2c2e93d463c5..22ac5a72733e 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/dependencies.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/dependencies.spec.ts @@ -6,6 +6,7 @@ */ import { synthtrace } from '../../../synthtrace'; import { opbeans } from '../../fixtures/synthtrace/opbeans'; +import { checkA11y } from '../../support/commands'; const start = '2021-10-10T00:00:00.000Z'; const end = '2021-10-10T00:15:00.000Z'; @@ -43,6 +44,17 @@ describe('Dependencies', () => { cy.contains('h1', 'postgresql'); }); + + it('has no detectable a11y violations on load', () => { + cy.visit( + `/app/apm/services/opbeans-java/dependencies?${new URLSearchParams( + timeRange + )}` + ); + cy.contains('a[role="tab"]', 'Dependencies'); + // set skipFailures to true to not fail the test when there are accessibility failures + checkA11y({ skipFailures: true }); + }); }); describe('dependency overview page', () => { @@ -62,6 +74,18 @@ describe('Dependencies', () => { cy.contains('h1', 'opbeans-java'); }); + + it('has no detectable a11y violations on load', () => { + cy.visit( + `/app/apm/backends/overview?${new URLSearchParams({ + ...timeRange, + backendName: 'postgresql', + })}` + ); + cy.contains('h1', 'postgresql'); + // set skipFailures to true to not fail the test when there are accessibility failures + checkA11y({ skipFailures: true }); + }); }); describe('service overview page', () => { diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/errors/error_details.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/errors/error_details.spec.ts index f2479b740073..beaf1837c834 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/errors/error_details.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/errors/error_details.spec.ts @@ -7,6 +7,7 @@ import url from 'url'; import { synthtrace } from '../../../../synthtrace'; +import { checkA11y } from '../../../support/commands'; import { generateData } from './generate_data'; const start = '2021-10-10T00:00:00.000Z'; @@ -39,6 +40,13 @@ describe('Error details', () => { await synthtrace.clean(); }); + it('has no detectable a11y violations on load', () => { + cy.visit(errorDetailsPageHref); + cy.contains('Error group 00000'); + // set skipFailures to true to not fail the test when there are accessibility failures + checkA11y({ skipFailures: true }); + }); + describe('when error has no occurrences', () => { it('shows an empty message', () => { cy.visit( diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/errors/errors_page.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/errors/errors_page.spec.ts index d08e22092d59..6ff4795cbcb1 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/errors/errors_page.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/errors/errors_page.spec.ts @@ -7,6 +7,7 @@ import url from 'url'; import { synthtrace } from '../../../../synthtrace'; +import { checkA11y } from '../../../support/commands'; import { generateData } from './generate_data'; const start = '2021-10-10T00:00:00.000Z'; @@ -41,6 +42,13 @@ describe('Errors page', () => { await synthtrace.clean(); }); + it('has no detectable a11y violations on load', () => { + cy.visit(javaServiceErrorsPageHref); + cy.contains('Error occurrences'); + // set skipFailures to true to not fail the test when there are accessibility failures + checkA11y({ skipFailures: true }); + }); + describe('when service has no errors', () => { it('shows empty message', () => { cy.visit(nodeServiceErrorsPageHref); diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/errors/generate_data.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/errors/generate_data.ts index 7215d2f435e1..c2c02d899715 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/errors/generate_data.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/errors/generate_data.ts @@ -18,28 +18,26 @@ export function generateData({ from, to }: { from: number; to: number }) { .service('opbeans-node', 'production', 'nodejs') .instance('opbeans-node-prod-1'); - return [ - ...range - .interval('2m') - .rate(1) - .flatMap((timestamp, index) => [ - ...opbeansJava - .transaction('GET /apple 🍎 ') - .timestamp(timestamp) - .duration(1000) - .success() - .errors( - opbeansJava - .error(`Error ${index}`, `exception ${index}`) - .timestamp(timestamp) - ) - .serialize(), - ...opbeansNode - .transaction('GET /banana 🍌') - .timestamp(timestamp) - .duration(500) - .success() - .serialize(), - ]), - ]; + return range + .interval('2m') + .rate(1) + .spans((timestamp, index) => [ + ...opbeansJava + .transaction('GET /apple 🍎 ') + .timestamp(timestamp) + .duration(1000) + .success() + .errors( + opbeansJava + .error(`Error ${index}`, `exception ${index}`) + .timestamp(timestamp) + ) + .serialize(), + ...opbeansNode + .transaction('GET /banana 🍌') + .timestamp(timestamp) + .duration(500) + .success() + .serialize(), + ]); } diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_inventory/header_filters/generate_data.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_inventory/header_filters/generate_data.ts index d4a2cdf10302..1e1e6db5ccaf 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_inventory/header_filters/generate_data.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_inventory/header_filters/generate_data.ts @@ -26,23 +26,21 @@ export function generateData({ .service('opbeans-node', 'production', 'nodejs') .instance('opbeans-node-prod-1'); - return [ - ...range - .interval('2m') - .rate(1) - .flatMap((timestamp, index) => [ - ...service1 - .transaction('GET /apple 🍎 ') - .timestamp(timestamp) - .duration(1000) - .success() - .serialize(), - ...opbeansNode - .transaction('GET /banana 🍌') - .timestamp(timestamp) - .duration(500) - .success() - .serialize(), - ]), - ]; + return range + .interval('2m') + .rate(1) + .spans((timestamp, index) => [ + ...service1 + .transaction('GET /apple 🍎 ') + .timestamp(timestamp) + .duration(1000) + .success() + .serialize(), + ...opbeansNode + .transaction('GET /banana 🍌') + .timestamp(timestamp) + .duration(500) + .success() + .serialize(), + ]); } diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_inventory/service_inventory.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_inventory/service_inventory.spec.ts index f74a1d122e42..40afece0ce90 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_inventory/service_inventory.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_inventory/service_inventory.spec.ts @@ -7,6 +7,7 @@ import url from 'url'; import { synthtrace } from '../../../../synthtrace'; import { opbeans } from '../../../fixtures/synthtrace/opbeans'; +import { checkA11y } from '../../../support/commands'; const timeRange = { rangeFrom: '2021-10-10T00:00:00.000Z', @@ -53,6 +54,12 @@ describe('When navigating to the service inventory', () => { cy.visit(serviceInventoryHref); }); + it('has no detectable a11y violations on load', () => { + cy.contains('h1', 'Services'); + // set skipFailures to true to not fail the test when there are accessibility failures + checkA11y({ skipFailures: true }); + }); + it('has a list of services', () => { cy.contains('opbeans-node'); cy.contains('opbeans-java'); @@ -93,7 +100,7 @@ describe('When navigating to the service inventory', () => { cy.wait(aliasNames); }); - it.skip('when selecting a different time range and clicking the update button', () => { + it('when selecting a different time range and clicking the update button', () => { cy.wait(aliasNames); cy.selectAbsoluteTimeRange( diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/generate_data.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/generate_data.ts index 2dba10e8e517..fffa3e98a2c6 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/generate_data.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/generate_data.ts @@ -24,8 +24,8 @@ export function generateData({ start, end }: { start: number; end: number }) { const traceEvents = timerange(start, end) .interval('1m') .rate(rate) - .flatMap((timestamp) => [ - ...instance + .spans((timestamp) => + instance .transaction(transaction.name) .defaults({ 'service.runtime.name': 'AWS_Lambda_python3.8', @@ -34,8 +34,8 @@ export function generateData({ start, end }: { start: number; end: number }) { .timestamp(timestamp) .duration(transaction.duration) .success() - .serialize(), - ]); + .serialize() + ); return traceEvents; } diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts index 31586651cbb8..fcd9e472cc7e 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts @@ -8,6 +8,7 @@ import url from 'url'; import { synthtrace } from '../../../../synthtrace'; import { opbeans } from '../../../fixtures/synthtrace/opbeans'; +import { checkA11y } from '../../../support/commands'; const start = '2021-10-10T00:00:00.000Z'; const end = '2021-10-10T00:15:00.000Z'; @@ -102,6 +103,13 @@ describe('Service Overview', () => { cy.loginAsReadOnlyUser(); cy.visit(baseUrl); }); + + it('has no detectable a11y violations on load', () => { + cy.contains('opbeans-node'); + // set skipFailures to true to not fail the test when there are accessibility failures + checkA11y({ skipFailures: true }); + }); + it('transaction latency chart', () => { cy.get('[data-test-subj="latencyChart"]'); }); diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transactions_overview/transactions_overview.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transactions_overview/transactions_overview.spec.ts index 3deb4b8619f6..fb8468f42474 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transactions_overview/transactions_overview.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transactions_overview/transactions_overview.spec.ts @@ -8,11 +8,12 @@ import url from 'url'; import { synthtrace } from '../../../../synthtrace'; import { opbeans } from '../../../fixtures/synthtrace/opbeans'; +import { checkA11y } from '../../../support/commands'; const start = '2021-10-10T00:00:00.000Z'; const end = '2021-10-10T00:15:00.000Z'; -const serviceOverviewHref = url.format({ +const serviceTransactionsHref = url.format({ pathname: '/app/apm/services/opbeans-node/transactions', query: { rangeFrom: start, rangeTo: end }, }); @@ -35,8 +36,18 @@ describe('Transactions Overview', () => { cy.loginAsReadOnlyUser(); }); + it('has no detectable a11y violations on load', () => { + cy.visit(serviceTransactionsHref); + cy.contains('aria-selected="true"', 'Transactions').should( + 'have.class', + 'euiTab-isSelected' + ); + // set skipFailures to true to not fail the test when there are accessibility failures + checkA11y({ skipFailures: true }); + }); + it('persists transaction type selected when navigating to Overview tab', () => { - cy.visit(serviceOverviewHref); + cy.visit(serviceTransactionsHref); cy.get('[data-test-subj="headerFilterTransactionType"]').should( 'have.value', 'request' diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/plugins/index.ts b/x-pack/plugins/apm/ftr_e2e/cypress/plugins/index.ts index 0c924e70a1fd..6b6aff63976d 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/plugins/index.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/plugins/index.ts @@ -4,7 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { apm, createLogger, LogLevel } from '@elastic/apm-synthtrace'; +import { + apm, + createLogger, + LogLevel, + SpanIterable, +} from '@elastic/apm-synthtrace'; import { createEsClientForTesting } from '@kbn/test'; // *********************************************************** @@ -33,13 +38,15 @@ const plugin: Cypress.PluginConfig = (on, config) => { isCloud: !!config.env.TEST_CLOUD, }); + const forceDataStreams = false; const synthtraceEsClient = new apm.ApmSynthtraceEsClient( client, - createLogger(LogLevel.info) + createLogger(LogLevel.info), + forceDataStreams ); on('task', { - 'synthtrace:index': async (events) => { + 'synthtrace:index': async (events: SpanIterable) => { await synthtraceEsClient.index(events); return null; }, diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.ts b/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.ts index cb66d6db809f..91edae9046f6 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.ts @@ -6,6 +6,11 @@ */ import 'cypress-real-events/support'; import { Interception } from 'cypress/types/net-stubbing'; +import 'cypress-axe'; +import { + AXE_CONFIG, + AXE_OPTIONS, +} from 'test/accessibility/services/a11y/constants'; Cypress.Commands.add('loginAsReadOnlyUser', () => { cy.loginAs({ username: 'apm_read_user', password: 'changeme' }); @@ -78,3 +83,25 @@ Cypress.Commands.add( }); } ); + +// A11y configuration + +const axeConfig = { + ...AXE_CONFIG, +}; +const axeOptions = { + ...AXE_OPTIONS, + runOnly: [...AXE_OPTIONS.runOnly, 'best-practice'], +}; + +export const checkA11y = ({ skipFailures }: { skipFailures: boolean }) => { + // https://github.com/component-driven/cypress-axe#cychecka11y + cy.injectAxe(); + cy.configureAxe(axeConfig); + const context = '.kbnAppWrapper'; // Scopes a11y checks to only our app + /** + * We can get rid of the last two params when we don't need to add skipFailures + * params = (context, options, violationCallback, skipFailures) + */ + cy.checkA11y(context, axeOptions, undefined, skipFailures); +}; diff --git a/x-pack/plugins/apm/ftr_e2e/synthtrace.ts b/x-pack/plugins/apm/ftr_e2e/synthtrace.ts index 3c818b65200b..2409dded1778 100644 --- a/x-pack/plugins/apm/ftr_e2e/synthtrace.ts +++ b/x-pack/plugins/apm/ftr_e2e/synthtrace.ts @@ -4,9 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { SpanIterable } from '@elastic/apm-synthtrace'; export const synthtrace = { - index: (events: any[]) => + index: (events: SpanIterable) => new Promise((resolve) => { cy.task('synthtrace:index', events).then(resolve); }), diff --git a/x-pack/plugins/apm/public/components/alerting/fields.tsx b/x-pack/plugins/apm/public/components/alerting/fields.tsx index 171953ea522e..4abbd97d98db 100644 --- a/x-pack/plugins/apm/public/components/alerting/fields.tsx +++ b/x-pack/plugins/apm/public/components/alerting/fields.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiComboBoxOptionOption, EuiFieldNumber } from '@elastic/eui'; +import { EuiFieldNumber } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { @@ -16,22 +16,11 @@ import { import { ENVIRONMENT_ALL, getEnvironmentLabel, + allOptionText, } from '../../../common/environment_filter_values'; import { SuggestionsSelect } from '../shared/suggestions_select'; import { PopoverExpression } from './service_alert_trigger/popover_expression'; -const allOptionText = i18n.translate('xpack.apm.alerting.fields.allOption', { - defaultMessage: 'All', -}); -const allOption: EuiComboBoxOptionOption = { - label: allOptionText, - value: allOptionText, -}; -const environmentAllOption: EuiComboBoxOptionOption = { - label: ENVIRONMENT_ALL.text, - value: ENVIRONMENT_ALL.value, -}; - export function ServiceField({ allowAll = true, currentValue, @@ -43,13 +32,13 @@ export function ServiceField({ }) { return ( + ), diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_error_rate_chart.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_error_rate_chart.tsx index 9fb53ab15d37..5ecb41829f06 100644 --- a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_error_rate_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_error_rate_chart.tsx @@ -7,7 +7,6 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { asPercent } from '../../../../common/utils/formatters'; -import { useComparison } from '../../../hooks/use_comparison'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; import { Coordinate, TimeSeries } from '../../../../typings/timeseries'; @@ -17,6 +16,10 @@ import { ChartType, getTimeSeriesColor, } from '../../shared/charts/helper/get_timeseries_color'; +import { + getComparisonChartTheme, + getTimeRangeComparison, +} from '../../shared/time_comparison/get_time_range_comparison'; function yLabelFormat(y?: number | null) { return asPercent(y || 0, 1); @@ -28,12 +31,26 @@ export function BackendFailedTransactionRateChart({ height: number; }) { const { - query: { backendName, kuery, environment, rangeFrom, rangeTo }, + query: { + backendName, + kuery, + environment, + rangeFrom, + rangeTo, + comparisonEnabled, + comparisonType, + }, } = useApmParams('/backends/overview'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const { offset, comparisonChartTheme } = useComparison(); + const comparisonChartTheme = getComparisonChartTheme(); + const { offset } = getTimeRangeComparison({ + start, + end, + comparisonType, + comparisonEnabled, + }); const { data, status } = useFetcher( (callApmApi) => { diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_latency_chart.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_latency_chart.tsx index 9d95b58fe24d..8289ac01b7b2 100644 --- a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_latency_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_latency_chart.tsx @@ -7,7 +7,6 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { getDurationFormatter } from '../../../../common/utils/formatters'; -import { useComparison } from '../../../hooks/use_comparison'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; import { Coordinate, TimeSeries } from '../../../../typings/timeseries'; @@ -21,15 +20,33 @@ import { ChartType, getTimeSeriesColor, } from '../../shared/charts/helper/get_timeseries_color'; +import { + getComparisonChartTheme, + getTimeRangeComparison, +} from '../../shared/time_comparison/get_time_range_comparison'; export function BackendLatencyChart({ height }: { height: number }) { const { - query: { backendName, rangeFrom, rangeTo, kuery, environment }, + query: { + backendName, + rangeFrom, + rangeTo, + kuery, + environment, + comparisonEnabled, + comparisonType, + }, } = useApmParams('/backends/overview'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const { offset, comparisonChartTheme } = useComparison(); + const comparisonChartTheme = getComparisonChartTheme(); + const { offset } = getTimeRangeComparison({ + start, + end, + comparisonType, + comparisonEnabled, + }); const { data, status } = useFetcher( (callApmApi) => { diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_throughput_chart.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_throughput_chart.tsx index c293561f780b..c8a37146d60a 100644 --- a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_throughput_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_throughput_chart.tsx @@ -7,7 +7,6 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { asTransactionRate } from '../../../../common/utils/formatters'; -import { useComparison } from '../../../hooks/use_comparison'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; import { Coordinate, TimeSeries } from '../../../../typings/timeseries'; @@ -17,15 +16,33 @@ import { ChartType, getTimeSeriesColor, } from '../../shared/charts/helper/get_timeseries_color'; +import { + getComparisonChartTheme, + getTimeRangeComparison, +} from '../../shared/time_comparison/get_time_range_comparison'; export function BackendThroughputChart({ height }: { height: number }) { const { - query: { backendName, rangeFrom, rangeTo, kuery, environment }, + query: { + backendName, + rangeFrom, + rangeTo, + kuery, + environment, + comparisonEnabled, + comparisonType, + }, } = useApmParams('/backends/overview'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const { offset, comparisonChartTheme } = useComparison(); + const comparisonChartTheme = getComparisonChartTheme(); + const { offset } = getTimeRangeComparison({ + start, + end, + comparisonType, + comparisonEnabled, + }); const { data, status } = useFetcher( (callApmApi) => { diff --git a/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx index 71970e00f6d2..fa7cf4a3ba24 100644 --- a/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx @@ -10,7 +10,6 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { useUiTracker } from '../../../../../../observability/public'; import { getNodeName, NodeType } from '../../../../../common/connections'; -import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useFetcher } from '../../../../hooks/use_fetcher'; import { useTimeRange } from '../../../../hooks/use_time_range'; @@ -20,11 +19,14 @@ import { getTimeRangeComparison } from '../../../shared/time_comparison/get_time export function BackendInventoryDependenciesTable() { const { - urlParams: { comparisonEnabled, comparisonType }, - } = useLegacyUrlParams(); - - const { - query: { rangeFrom, rangeTo, environment, kuery }, + query: { + rangeFrom, + rangeTo, + environment, + kuery, + comparisonEnabled, + comparisonType, + }, } = useApmParams('/backends'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx index 94505b5c0386..e0fc999c578a 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx @@ -112,7 +112,7 @@ export function ErrorGroupDetails() { const { path: { groupId }, - query: { rangeFrom, rangeTo, environment, kuery }, + query: { rangeFrom, rangeTo, environment, kuery, serviceGroup }, } = useApmParams('/services/{serviceName}/errors/{groupId}'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); @@ -129,6 +129,7 @@ export function ErrorGroupDetails() { rangeTo, environment, kuery, + serviceGroup, }, }), }); diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx index 46b963d13e51..7d90ee6824de 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx @@ -68,7 +68,6 @@ export function ErrorGroupOverview() { comparisonType, comparisonEnabled, }); - const { errorDistributionData, status } = useErrorGroupDistributionFetcher({ serviceName, groupId: undefined, diff --git a/x-pack/plugins/apm/public/components/app/service_groups/index.tsx b/x-pack/plugins/apm/public/components/app/service_groups/index.tsx new file mode 100644 index 000000000000..d62ecd628119 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/index.tsx @@ -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 { ServiceGroupsList } from './service_groups_list/.'; +export { ServiceGroupSaveButton } from './service_group_save/.'; diff --git a/x-pack/plugins/apm/public/components/app/service_groups/refresh_service_groups_subscriber.tsx b/x-pack/plugins/apm/public/components/app/service_groups/refresh_service_groups_subscriber.tsx new file mode 100644 index 000000000000..c578f021bd17 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/refresh_service_groups_subscriber.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 React, { useEffect, useRef } from 'react'; +import { Subject, Subscription } from 'rxjs'; + +const refreshServiceGroupsSubject = new Subject(); + +export function refreshServiceGroups() { + refreshServiceGroupsSubject.next(); +} + +export function RefreshServiceGroupsSubscriber({ + onRefresh, + children, +}: { + onRefresh: () => void; + children?: React.ReactNode; +}) { + const subscription = useRef(null); + useEffect(() => { + if (!subscription.current) { + subscription.current = refreshServiceGroupsSubject.subscribe(() => + onRefresh() + ); + } + return () => { + if (!subscription.current) { + return; + } + subscription.current.unsubscribe(); + }; + }, [onRefresh]); + return <>{children}; +} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/group_details.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/group_details.tsx new file mode 100644 index 000000000000..5c32ce0b1cfb --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/group_details.tsx @@ -0,0 +1,216 @@ +/* + * Copyright 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 { + EuiButton, + EuiButtonEmpty, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiColorPicker, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + useColorPickerState, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useRef, useState } from 'react'; +import type { StagedServiceGroup } from './save_modal'; + +interface Props { + serviceGroup?: StagedServiceGroup; + isEdit?: boolean; + onCloseModal: () => void; + onClickNext: (serviceGroup: StagedServiceGroup) => void; + onDeleteGroup: () => void; + isLoading: boolean; +} + +export function GroupDetails({ + isEdit, + serviceGroup, + onCloseModal, + onClickNext, + onDeleteGroup, + isLoading, +}: Props) { + const [name, setName] = useState(serviceGroup?.groupName || ''); + const [color, setColor, colorPickerErrors] = useColorPickerState( + serviceGroup?.color || '#5094C4' + ); + const [description, setDescription] = useState( + serviceGroup?.description + ); + useEffect(() => { + if (serviceGroup) { + setName(serviceGroup.groupName); + if (serviceGroup.color) { + setColor(serviceGroup.color, { + hex: serviceGroup.color, + isValid: true, + }); + } + setDescription(serviceGroup.description); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [serviceGroup]); // setColor omitted: new reference each render + + const isInvalidColor = !!colorPickerErrors?.length; + const isInvalidName = !name; + const isInvalid = isInvalidName || isInvalidColor; + + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); // autofocus on initial render + }, []); + + return ( + <> + + +

+ {isEdit + ? i18n.translate( + 'xpack.apm.serviceGroups.groupDetailsForm.edit.title', + { defaultMessage: 'Edit group' } + ) + : i18n.translate( + 'xpack.apm.serviceGroups.groupDetailsForm.create.title', + { defaultMessage: 'Create group' } + )} +

+
+
+ + + + + + + { + setName(e.target.value); + }} + inputRef={inputRef} + /> + + + + + + + + + + + + {i18n.translate( + 'xpack.apm.serviceGroups.groupDetailsForm.description.optional', + { defaultMessage: 'Optional' } + )} + + } + > + { + setDescription(e.target.value); + }} + /> + + + + + + + {isEdit && ( + + { + onDeleteGroup(); + }} + color="danger" + isDisabled={isLoading} + > + {i18n.translate( + 'xpack.apm.serviceGroups.groupDetailsForm.deleteGroup', + { defaultMessage: 'Delete group' } + )} + + + )} + + + {i18n.translate( + 'xpack.apm.serviceGroups.groupDetailsForm.cancel', + { defaultMessage: 'Cancel' } + )} + + + + { + onClickNext({ + groupName: name, + color, + description, + kuery: serviceGroup?.kuery ?? '', + }); + }} + isDisabled={isInvalid || isLoading} + > + {i18n.translate( + 'xpack.apm.serviceGroups.groupDetailsForm.selectServices', + { defaultMessage: 'Select services' } + )} + + + + + + ); +} diff --git a/x-pack/test/performance/services.ts b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/index.tsx similarity index 81% rename from x-pack/test/performance/services.ts rename to x-pack/plugins/apm/public/components/app/service_groups/service_group_save/index.tsx index ecaac6362761..57d02268d9bc 100644 --- a/x-pack/test/performance/services.ts +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/index.tsx @@ -5,4 +5,4 @@ * 2.0. */ -export * from '../functional/services'; +export { ServiceGroupSaveButton } from './save_button'; diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_button.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_button.tsx new file mode 100644 index 000000000000..61828e240c20 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_button.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 { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { useAnyOfApmParams } from '../../../../hooks/use_apm_params'; +import { useFetcher } from '../../../../hooks/use_fetcher'; +import { SaveGroupModal } from './save_modal'; + +export function ServiceGroupSaveButton() { + const [isModalVisible, setIsModalVisible] = useState(false); + + const { + query: { serviceGroup }, + } = useAnyOfApmParams('/service-groups', '/services', '/service-map'); + + const isGroupEditMode = !!serviceGroup; + + const { data } = useFetcher( + (callApmApi) => { + if (isGroupEditMode) { + return callApmApi('GET /internal/apm/service-group', { + params: { query: { serviceGroup } }, + }); + } + }, + [serviceGroup, isGroupEditMode] + ); + const savedServiceGroup = data?.serviceGroup; + + return ( + <> + { + setIsModalVisible((state) => !state); + }} + > + {isGroupEditMode ? EDIT_GROUP_LABEL : CREATE_GROUP_LABEL} + + {isModalVisible && ( + { + setIsModalVisible(false); + }} + /> + )} + + ); +} + +const CREATE_GROUP_LABEL = i18n.translate( + 'xpack.apm.serviceGroups.createGroupLabel', + { defaultMessage: 'Create group' } +); +const EDIT_GROUP_LABEL = i18n.translate( + 'xpack.apm.serviceGroups.editGroupLabel', + { defaultMessage: 'Edit group' } +); diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_modal.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_modal.tsx new file mode 100644 index 000000000000..9ea40fdd38a3 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_modal.tsx @@ -0,0 +1,264 @@ +/* + * Copyright 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 datemath from '@elastic/datemath'; +import { EuiModal } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useHistory } from 'react-router-dom'; +import React, { useCallback, useEffect, useState } from 'react'; +import { callApmApi } from '../../../../services/rest/create_call_apm_api'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; +import { GroupDetails } from './group_details'; +import { SelectServices } from './select_services'; +import { + ServiceGroup, + SavedServiceGroup, +} from '../../../../../common/service_groups'; +import { refreshServiceGroups } from '../refresh_service_groups_subscriber'; + +interface Props { + onClose: () => void; + savedServiceGroup?: SavedServiceGroup; +} + +type ModalView = 'group_details' | 'select_service'; + +export type StagedServiceGroup = Pick< + ServiceGroup, + 'groupName' | 'color' | 'description' | 'kuery' +>; + +export function SaveGroupModal({ onClose, savedServiceGroup }: Props) { + const { + core: { notifications }, + } = useApmPluginContext(); + const [modalView, setModalView] = useState('group_details'); + const [stagedServiceGroup, setStagedServiceGroup] = useState< + StagedServiceGroup | undefined + >(savedServiceGroup); + const [isLoading, setIsLoading] = useState(false); + useEffect(() => { + setStagedServiceGroup(savedServiceGroup); + }, [savedServiceGroup]); + + const isEdit = !!savedServiceGroup; + + const history = useHistory(); + + const navigateToServiceGroups = useCallback(() => { + history.push({ + ...history.location, + pathname: '/service-groups', + search: '', + }); + }, [history]); + + const onSave = useCallback( + async function (newServiceGroup: StagedServiceGroup) { + setIsLoading(true); + try { + const start = datemath.parse('now-24h')?.toISOString(); + const end = datemath.parse('now', { roundUp: true })?.toISOString(); + if (!start || !end) { + throw new Error('Unable to determine start/end time range.'); + } + await callApmApi('POST /internal/apm/service-group', { + params: { + query: { start, end, serviceGroupId: savedServiceGroup?.id }, + body: { + groupName: newServiceGroup.groupName, + kuery: newServiceGroup.kuery, + description: newServiceGroup.description, + color: newServiceGroup.color, + }, + }, + signal: null, + }); + notifications.toasts.addSuccess( + isEdit + ? getEditSuccessToastLabels(newServiceGroup) + : getCreateSuccessToastLabels(newServiceGroup) + ); + refreshServiceGroups(); + navigateToServiceGroups(); + } catch (error) { + console.error(error); + notifications.toasts.addDanger( + isEdit + ? getEditFailureToastLabels(newServiceGroup, error) + : getCreateFailureToastLabels(newServiceGroup, error) + ); + } + onClose(); + setIsLoading(false); + }, + [ + savedServiceGroup?.id, + notifications.toasts, + onClose, + isEdit, + navigateToServiceGroups, + ] + ); + + const onDelete = useCallback( + async function () { + setIsLoading(true); + if (!savedServiceGroup) { + notifications.toasts.addDanger( + getDeleteFailureUnknownIdToastLabels(stagedServiceGroup!) + ); + return; + } + try { + await callApmApi('DELETE /internal/apm/service-group', { + params: { query: { serviceGroupId: savedServiceGroup.id } }, + signal: null, + }); + notifications.toasts.addSuccess( + getDeleteSuccessToastLabels(stagedServiceGroup!) + ); + refreshServiceGroups(); + navigateToServiceGroups(); + } catch (error) { + console.error(error); + notifications.toasts.addDanger( + getDeleteFailureToastLabels(stagedServiceGroup!, error) + ); + } + onClose(); + setIsLoading(false); + }, + [ + stagedServiceGroup, + notifications.toasts, + onClose, + navigateToServiceGroups, + savedServiceGroup, + ] + ); + + return ( + + {modalView === 'group_details' && ( + { + setStagedServiceGroup(_serviceGroup); + setModalView('select_service'); + }} + onDeleteGroup={onDelete} + isLoading={isLoading} + /> + )} + {modalView === 'select_service' && stagedServiceGroup && ( + { + setModalView('group_details'); + }} + isLoading={isLoading} + /> + )} + + ); +} + +function getCreateSuccessToastLabels({ groupName }: StagedServiceGroup) { + return { + title: i18n.translate('xpack.apm.serviceGroups.createSucess.toast.title', { + defaultMessage: 'Created "{groupName}" group', + values: { groupName }, + }), + text: i18n.translate('xpack.apm.serviceGroups.createSuccess.toast.text', { + defaultMessage: + 'Your group is now visible in the new Services view for groups.', + }), + }; +} + +function getEditSuccessToastLabels({ groupName }: StagedServiceGroup) { + return { + title: i18n.translate('xpack.apm.serviceGroups.editSucess.toast.title', { + defaultMessage: 'Edited "{groupName}" group', + values: { groupName }, + }), + text: i18n.translate('xpack.apm.serviceGroups.editSuccess.toast.text', { + defaultMessage: 'Saved new changes to service group.', + }), + }; +} + +function getCreateFailureToastLabels( + { groupName }: StagedServiceGroup, + error: Error & { body: { message: string } } +) { + return { + title: i18n.translate('xpack.apm.serviceGroups.createFailure.toast.title', { + defaultMessage: 'Error while creating "{groupName}" group', + values: { groupName }, + }), + text: error.body.message, + }; +} + +function getEditFailureToastLabels( + { groupName }: StagedServiceGroup, + error: Error & { body: { message: string } } +) { + return { + title: i18n.translate('xpack.apm.serviceGroups.editFailure.toast.title', { + defaultMessage: 'Error while editing "{groupName}" group', + values: { groupName }, + }), + text: error.body.message, + }; +} + +function getDeleteSuccessToastLabels({ groupName }: StagedServiceGroup) { + return { + title: i18n.translate('xpack.apm.serviceGroups.deleteSuccess.toast.title', { + defaultMessage: 'Deleted "{groupName}" group', + values: { groupName }, + }), + }; +} + +function getDeleteFailureUnknownIdToastLabels({ + groupName, +}: StagedServiceGroup) { + return { + title: i18n.translate( + 'xpack.apm.serviceGroups.deleteFailure.unknownId.toast.title', + { + defaultMessage: 'Error while deleting "{groupName}" group', + values: { groupName }, + } + ), + text: i18n.translate( + 'xpack.apm.serviceGroups.deleteFailure.unknownId.toast.text', + { defaultMessage: 'Unable to delete group: unknown service group id.' } + ), + }; +} + +function getDeleteFailureToastLabels( + { groupName }: StagedServiceGroup, + error: Error & { body: { message: string } } +) { + return { + title: i18n.translate('xpack.apm.serviceGroups.deleteFailure.toast.title', { + defaultMessage: 'Error while deleting "{groupName}" group', + values: { groupName }, + }), + text: error.body.message, + }; +} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/select_services.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/select_services.tsx new file mode 100644 index 000000000000..b0f802c976a2 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/select_services.tsx @@ -0,0 +1,256 @@ +/* + * Copyright 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 { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useState, useMemo } from 'react'; +import styled from 'styled-components'; +import { isEmpty } from 'lodash'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { KueryBar } from '../../../shared/kuery_bar'; +import { ServiceListPreview } from './service_list_preview'; +import type { StagedServiceGroup } from './save_modal'; +import { getDateRange } from '../../../../context/url_params_context/helpers'; + +const CentralizedContainer = styled.div` + display: flex; + height: 100%; + justify-content: center; + align-items: center; +`; + +const MAX_CONTAINER_HEIGHT = 600; +const MODAL_HEADER_HEIGHT = 122; +const MODAL_FOOTER_HEIGHT = 80; + +const Container = styled.div` + width: 600px; + height: ${MAX_CONTAINER_HEIGHT}px; +`; + +interface Props { + serviceGroup: StagedServiceGroup; + isEdit?: boolean; + onCloseModal: () => void; + onSaveClick: (serviceGroup: StagedServiceGroup) => void; + onEditGroupDetailsClick: () => void; + isLoading: boolean; +} + +export function SelectServices({ + serviceGroup, + isEdit, + onCloseModal, + onSaveClick, + onEditGroupDetailsClick, + isLoading, +}: Props) { + const [kuery, setKuery] = useState(serviceGroup?.kuery || ''); + const [stagedKuery, setStagedKuery] = useState(serviceGroup?.kuery || ''); + + useEffect(() => { + if (isEdit) { + setKuery(serviceGroup.kuery); + setStagedKuery(serviceGroup.kuery); + } + }, [isEdit, serviceGroup.kuery]); + + const { start, end } = useMemo( + () => + getDateRange({ + rangeFrom: 'now-24h', + rangeTo: 'now', + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [kuery] + ); + + const { data, status } = useFetcher( + (callApmApi) => { + if (start && end && !isEmpty(kuery)) { + return callApmApi('GET /internal/apm/service-group/services', { + params: { query: { kuery, start, end } }, + }); + } + }, + [kuery, start, end], + { preservePreviousData: true } + ); + + const isServiceListPreviewLoading = status === FETCH_STATUS.LOADING; + + return ( + + + +

+ {i18n.translate( + 'xpack.apm.serviceGroups.selectServicesForm.title', + { defaultMessage: 'Select services' } + )} +

+ + + {i18n.translate( + 'xpack.apm.serviceGroups.selectServicesForm.subtitle', + { + defaultMessage: + 'Use a query to select services for this group. Services that match this query within the last 24 hours will be assigned to the group.', + } + )} + +
+
+ + + + + + { + setKuery(value); + }} + onChange={(value) => { + setStagedKuery(value); + }} + value={kuery} + /> + + + { + setKuery(stagedKuery); + }} + iconType={!kuery ? 'search' : 'refresh'} + isDisabled={isServiceListPreviewLoading || !stagedKuery} + > + {!kuery + ? i18n.translate( + 'xpack.apm.serviceGroups.selectServicesForm.preview', + { defaultMessage: 'Preview' } + ) + : i18n.translate( + 'xpack.apm.serviceGroups.selectServicesForm.refresh', + { defaultMessage: 'Refresh' } + )} + + + + + {kuery && data?.items && ( + + + {i18n.translate( + 'xpack.apm.serviceGroups.selectServicesForm.matchingServiceCount', + { + defaultMessage: + '{servicesCount} {servicesCount, plural, =0 {services} one {service} other {services}} match the query', + values: { servicesCount: data?.items.length }, + } + )} + + + )} + + + {!kuery && ( + + + {i18n.translate( + 'xpack.apm.serviceGroups.selectServicesForm.panelLabel', + { defaultMessage: 'Enter a query to select services' } + )} + + + )} + {!data && isServiceListPreviewLoading && ( + + + + )} + {kuery && data && ( + + )} + + + + + + + +
+ + {i18n.translate( + 'xpack.apm.serviceGroups.selectServicesForm.editGroupDetails', + { defaultMessage: 'Edit group details' } + )} + +
+
+ + + {i18n.translate( + 'xpack.apm.serviceGroups.selectServicesForm.cancel', + { + defaultMessage: 'Cancel', + } + )} + + + + { + onSaveClick({ ...serviceGroup, kuery }); + }} + isDisabled={isLoading || !kuery} + > + {i18n.translate( + 'xpack.apm.serviceGroups.selectServicesForm.saveGroup', + { defaultMessage: 'Save group' } + )} + + +
+
+
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/service_list_preview.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/service_list_preview.tsx new file mode 100644 index 000000000000..eb366b84ceef --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/service_list_preview.tsx @@ -0,0 +1,135 @@ +/* + * Copyright 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 { + EuiBasicTable, + EuiBasicTableColumn, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { orderBy } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { ValuesType } from 'utility-types'; +import { AgentIcon } from '../../../shared/agent_icon'; +import { APIReturnType } from '../../../../services/rest/create_call_apm_api'; +import { unit } from '../../../../utils/style'; +import { EnvironmentBadge } from '../../../shared/environment_badge'; +import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; + +type ServiceListAPIResponse = + APIReturnType<'GET /internal/apm/service-group/services'>; +type Items = ServiceListAPIResponse['items']; +type ServiceListItem = ValuesType; + +interface Props { + items: Items; + isLoading: boolean; +} + +const DEFAULT_SORT_FIELD = 'serviceName'; +const DEFAULT_SORT_DIRECTION = 'asc'; +type DIRECTION = 'asc' | 'desc'; +type SORT_FIELD = 'serviceName' | 'environments' | 'agentName'; + +export function ServiceListPreview({ items, isLoading }: Props) { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [sortField, setSortField] = useState(DEFAULT_SORT_FIELD); + const [sortDirection, setSortDirection] = useState( + DEFAULT_SORT_DIRECTION + ); + + const onTableChange = useCallback( + (options: { + page: { index: number; size: number }; + sort?: { field: SORT_FIELD; direction: DIRECTION }; + }) => { + setPageIndex(options.page.index); + setPageSize(options.page.size); + setSortField(options.sort?.field || DEFAULT_SORT_FIELD); + setSortDirection(options.sort?.direction || DEFAULT_SORT_DIRECTION); + }, + [] + ); + + const sort = useMemo(() => { + return { + sort: { field: sortField, direction: sortDirection }, + }; + }, [sortField, sortDirection]); + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: items.length, + hidePerPageOptions: true, + }), + [pageIndex, pageSize, items.length] + ); + + const renderedItems = useMemo(() => { + const sortedItems = orderBy(items, sortField, sortDirection); + + return sortedItems.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize); + }, [pageIndex, pageSize, sortField, sortDirection, items]); + + const columns: Array> = [ + { + field: 'serviceName', + name: i18n.translate( + 'xpack.apm.serviceGroups.selectServicesList.nameColumnLabel', + { defaultMessage: 'Name' } + ), + sortable: true, + render: (_, { serviceName, agentName }) => ( + + + + + {serviceName} + + } + /> + ), + }, + { + field: 'environments', + name: i18n.translate( + 'xpack.apm.serviceGroups.selectServicesList.environmentColumnLabel', + { defaultMessage: 'Environments' } + ), + width: `${unit * 10}px`, + sortable: true, + render: (_, { environments }) => ( + + ), + }, + ]; + + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/index.tsx new file mode 100644 index 000000000000..5e7e376334d3 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + EuiEmptyPrompt, + EuiLoadingLogo, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormControlLayout, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isEmpty, sortBy } from 'lodash'; +import React, { useState, useCallback } from 'react'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { ServiceGroupsListItems } from './service_groups_list'; +import { Sort } from './sort'; +import { RefreshServiceGroupsSubscriber } from '../refresh_service_groups_subscriber'; + +export type ServiceGroupsSortType = 'recently_added' | 'alphabetical'; + +export function ServiceGroupsList() { + const [filter, setFilter] = useState(''); + + const [apmServiceGroupsSortType, setServiceGroupsSortType] = + useState('recently_added'); + + const { + data = { serviceGroups: [] }, + status, + refetch, + } = useFetcher( + (callApmApi) => callApmApi('GET /internal/apm/service-groups'), + [] + ); + + const { serviceGroups } = data; + + const isLoading = + status === FETCH_STATUS.NOT_INITIATED || status === FETCH_STATUS.LOADING; + + const filteredItems = isEmpty(filter) + ? serviceGroups + : serviceGroups.filter((item) => + item.groupName.toLowerCase().includes(filter.toLowerCase()) + ); + + const sortedItems = sortBy(filteredItems, (item) => + apmServiceGroupsSortType === 'alphabetical' + ? item.groupName + : item.updatedAt + ); + + const items = + apmServiceGroupsSortType === 'recently_added' + ? sortedItems.reverse() + : sortedItems; + + const clearFilterCallback = useCallback(() => { + setFilter(''); + }, []); + + if (isLoading) { + // return null; + return ( + } + title={ +

+ {i18n.translate('xpack.apm.servicesGroups.loadingServiceGroups', { + defaultMessage: 'Loading service groups', + })} +

+ } + /> + ); + } + + return ( + + + + + + + setFilter(e.target.value)} + placeholder={i18n.translate( + 'xpack.apm.servicesGroups.filter', + { + defaultMessage: 'Filter groups', + } + )} + /> + + + + + + + + + + + + + + {i18n.translate('xpack.apm.serviceGroups.groupsCount', { + defaultMessage: + '{servicesCount} {servicesCount, plural, =0 {group} one {group} other {groups}}', + values: { servicesCount: filteredItems.length + 1 }, + })} + + + + + + {items.length ? ( + + ) : ( + + {i18n.translate( + 'xpack.apm.serviceGroups.emptyPrompt.serviceGroups', + { defaultMessage: 'Service groups' } + )} + + } + body={ +

+ {i18n.translate( + 'xpack.apm.serviceGroups.emptyPrompt.message', + { defaultMessage: 'No groups found for this filter' } + )} +

+ } + /> + )} +
+
+
+
+
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx new file mode 100644 index 000000000000..0975bbb4ae30 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx @@ -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 { + EuiAvatar, + EuiCard, + EuiCardProps, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { + ServiceGroup, + SERVICE_GROUP_COLOR_DEFAULT, +} from '../../../../../common/service_groups'; + +interface Props { + serviceGroup: ServiceGroup; + hideServiceCount?: boolean; + onClick?: () => void; + href?: string; +} + +export function ServiceGroupsCard({ + serviceGroup, + hideServiceCount = false, + onClick, + href, +}: Props) { + const cardProps: EuiCardProps = { + style: { width: 286, height: 186 }, + icon: ( + + ), + title: serviceGroup.groupName, + description: ( + + + + {serviceGroup.description || + i18n.translate( + 'xpack.apm.serviceGroups.cardsList.emptyDescription', + { defaultMessage: 'No description available' } + )} + + + {!hideServiceCount && ( + + + {i18n.translate( + 'xpack.apm.serviceGroups.cardsList.serviceCount', + { + defaultMessage: + '{servicesCount} {servicesCount, plural, one {service} other {services}}', + values: { servicesCount: serviceGroup.serviceNames.length }, + } + )} + + + )} + + ), + onClick, + href, + }; + + return ( + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx new file mode 100644 index 000000000000..06c138f7f01c --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiFlexGrid } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { SavedServiceGroup } from '../../../../../common/service_groups'; +import { ServiceGroupsCard } from './service_group_card'; +import { SERVICE_GROUP_COLOR_DEFAULT } from '../../../../../common/service_groups'; +import { useApmRouter } from '../../../../hooks/use_apm_router'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values'; + +interface Props { + items: SavedServiceGroup[]; + isLoading: boolean; +} + +export function ServiceGroupsListItems({ items }: Props) { + const router = useApmRouter(); + const { query } = useApmParams('/service-groups'); + return ( + + {items.map((item) => ( + + ))} + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/sort.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/sort.tsx new file mode 100644 index 000000000000..f87a7b767c93 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/sort.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 { EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ServiceGroupsSortType } from '.'; + +interface Props { + type: ServiceGroupsSortType; + onChange: (type: ServiceGroupsSortType) => void; +} + +const options: Array<{ + value: ServiceGroupsSortType; + text: string; +}> = [ + { + value: 'recently_added', + text: i18n.translate('xpack.apm.serviceGroups.list.sort.recentlyAdded', { + defaultMessage: 'Recently added', + }), + }, + { + value: 'alphabetical', + text: i18n.translate('xpack.apm.serviceGroups.list.sort.alphabetical', { + defaultMessage: 'Alphabetical', + }), + }, +]; + +export function Sort({ type, onChange }: Props) { + return ( + onChange(e.target.value as ServiceGroupsSortType)} + prepend={i18n.translate('xpack.apm.serviceGroups.sortLabel', { + defaultMessage: 'Sort', + })} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index 1e736409a960..c26ae5a273b4 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -10,33 +10,35 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import uuid from 'uuid'; import { useAnomalyDetectionJobsContext } from '../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; -import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { useLocalStorage } from '../../../hooks/use_local_storage'; -import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; +import { useApmParams } from '../../../hooks/use_apm_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; import { SearchBar } from '../../shared/search_bar'; -import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison'; import { ServiceList } from './service_list'; import { MLCallout, shouldDisplayMlCallout } from '../../shared/ml_callout'; +import { joinByKey } from '../../../../common/utils/join_by_key'; +import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison'; const initialData = { requestId: '', - mainStatisticsData: { - items: [], - hasHistoricalData: true, - hasLegacyData: false, - }, + items: [], + hasHistoricalData: true, + hasLegacyData: false, }; function useServicesFetcher() { const { - urlParams: { comparisonEnabled, comparisonType }, - } = useLegacyUrlParams(); - - const { - query: { rangeFrom, rangeTo, environment, kuery }, - } = useAnyOfApmParams('/services/{serviceName}', '/services'); + query: { + rangeFrom, + rangeTo, + environment, + kuery, + serviceGroup, + comparisonEnabled, + comparisonType, + }, + } = useApmParams('/services'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); @@ -47,7 +49,24 @@ function useServicesFetcher() { comparisonType, }); - const { data = initialData, status: mainStatisticsStatus } = useFetcher( + const sortedAndFilteredServicesFetch = useFetcher( + (callApmApi) => { + return callApmApi('GET /internal/apm/sorted_and_filtered_services', { + params: { + query: { + start, + end, + environment, + kuery, + serviceGroup, + }, + }, + }); + }, + [start, end, environment, kuery, serviceGroup] + ); + + const mainStatisticsFetch = useFetcher( (callApmApi) => { if (start && end) { return callApmApi('GET /internal/apm/services', { @@ -57,22 +76,23 @@ function useServicesFetcher() { kuery, start, end, + serviceGroup, }, }, }).then((mainStatisticsData) => { return { requestId: uuid(), - mainStatisticsData, + ...mainStatisticsData, }; }); } }, - [environment, kuery, start, end] + [environment, kuery, start, end, serviceGroup] ); - const { mainStatisticsData, requestId } = data; + const { data: mainStatisticsData = initialData } = mainStatisticsFetch; - const { data: comparisonData } = useFetcher( + const comparisonFetch = useFetcher( (callApmApi) => { if (start && end && mainStatisticsData.items.length) { return callApmApi('GET /internal/apm/services/detailed_statistics', { @@ -96,20 +116,23 @@ function useServicesFetcher() { }, // only fetches detailed statistics when requestId is invalidated by main statistics api call or offset is changed // eslint-disable-next-line react-hooks/exhaustive-deps - [requestId, offset], + [mainStatisticsData.requestId, offset], { preservePreviousData: false } ); return { - mainStatisticsData, - mainStatisticsStatus, - comparisonData, + sortedAndFilteredServicesFetch, + mainStatisticsFetch, + comparisonFetch, }; } export function ServiceInventory() { - const { mainStatisticsData, mainStatisticsStatus, comparisonData } = - useServicesFetcher(); + const { + sortedAndFilteredServicesFetch, + mainStatisticsFetch, + comparisonFetch, + } = useServicesFetcher(); const { anomalyDetectionSetupState } = useAnomalyDetectionJobsContext(); @@ -122,8 +145,13 @@ export function ServiceInventory() { !userHasDismissedCallout && shouldDisplayMlCallout(anomalyDetectionSetupState); - const isLoading = mainStatisticsStatus === FETCH_STATUS.LOADING; - const isFailure = mainStatisticsStatus === FETCH_STATUS.FAILURE; + const isLoading = + sortedAndFilteredServicesFetch.status === FETCH_STATUS.LOADING || + (sortedAndFilteredServicesFetch.status === FETCH_STATUS.SUCCESS && + sortedAndFilteredServicesFetch.data?.services.length === 0 && + mainStatisticsFetch.status === FETCH_STATUS.LOADING); + + const isFailure = mainStatisticsFetch.status === FETCH_STATUS.FAILURE; const noItemsMessage = ( ); + const items = joinByKey( + [ + ...(sortedAndFilteredServicesFetch.data?.services ?? []), + ...(mainStatisticsFetch.data?.items ?? []), + ], + 'serviceName' + ); + return ( <> @@ -154,8 +190,8 @@ export function ServiceInventory() { diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.stories.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.stories.tsx index bececfb545ba..01430c93b4b5 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.stories.tsx @@ -30,6 +30,10 @@ const stories: Meta<{}> = { switch (endpoint) { case '/internal/apm/services': return { items: [] }; + + case '/internal/apm/sorted_and_filtered_services': + return { services: [] }; + default: return {}; } diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx index 760b84977542..2d01a11d9218 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx @@ -17,7 +17,6 @@ import { i18n } from '@kbn/i18n'; import { TypeOf } from '@kbn/typed-react-router-config'; import { orderBy } from 'lodash'; import React, { useMemo } from 'react'; -import { ValuesType } from 'utility-types'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; import { ServiceHealthStatus } from '../../../../../common/service_health_status'; import { @@ -46,14 +45,11 @@ import { getTimeSeriesColor, } from '../../../shared/charts/helper/get_timeseries_color'; import { HealthBadge } from './health_badge'; +import { ServiceListItem } from '../../../../../common/service_inventory'; -type ServiceListAPIResponse = APIReturnType<'GET /internal/apm/services'>; -type Items = ServiceListAPIResponse['items']; type ServicesDetailedStatisticsAPIResponse = APIReturnType<'GET /internal/apm/services/detailed_statistics'>; -type ServiceListItem = ValuesType; - function formatString(value?: string | null) { return value || NOT_AVAILABLE_LABEL; } @@ -239,7 +235,7 @@ export function getServiceColumns({ } interface Props { - items: Items; + items: ServiceListItem[]; comparisonData?: ServicesDetailedStatisticsAPIResponse; noItemsMessage?: React.ReactNode; isLoading: boolean; @@ -287,9 +283,8 @@ export function ServiceList({ ] ); - const initialSortField = displayHealthStatus - ? 'healthStatus' - : 'transactionsPerMinute'; + const initialSortField = displayHealthStatus ? 'healthStatus' : 'serviceName'; + const initialSortDirection = displayHealthStatus ? 'desc' : 'asc'; return ( @@ -336,9 +331,9 @@ export function ServiceList({ items={items} noItemsMessage={noItemsMessage} initialSortField={initialSortField} - initialSortDirection="desc" + initialSortDirection={initialSortDirection} sortFn={(itemsToSort, sortField, sortDirection) => { - // For healthStatus, sort items by healthStatus first, then by TPM + // For healthStatus, sort items by healthStatus first, then by name return sortField === 'healthStatus' ? orderBy( itemsToSort, @@ -348,9 +343,9 @@ export function ServiceList({ ? SERVICE_HEALTH_STATUS_ORDER.indexOf(item.healthStatus) : -1; }, - (item) => item.throughput ?? 0, + (item) => item.serviceName.toLowerCase(), ], - [sortDirection, sortDirection] + [sortDirection, sortDirection === 'asc' ? 'desc' : 'asc'] ) : orderBy( itemsToSort, diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.test.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.test.tsx index 4bcf0e475d85..7a81ce571f6f 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.test.tsx @@ -21,6 +21,7 @@ const query = { rangeTo: 'now', environment: ENVIRONMENT_ALL.value, kuery: '', + serviceGroup: '', }; const service: any = { diff --git a/x-pack/plugins/apm/public/components/app/service_map/index.tsx b/x-pack/plugins/apm/public/components/app/service_map/index.tsx index a63166331d8f..b7975d31afab 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/index.tsx @@ -67,7 +67,7 @@ function LoadingSpinner() { export function ServiceMapHome() { const { - query: { environment, kuery, rangeFrom, rangeTo }, + query: { environment, kuery, rangeFrom, rangeTo, serviceGroup }, } = useApmParams('/service-map'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); return ( @@ -76,6 +76,7 @@ export function ServiceMapHome() { kuery={kuery} start={start} end={end} + serviceGroupId={serviceGroup} /> ); } @@ -100,11 +101,13 @@ export function ServiceMap({ kuery, start, end, + serviceGroupId, }: { environment: Environment; kuery: string; start: string; end: string; + serviceGroupId?: string; }) { const theme = useTheme(); const license = useLicenseContext(); @@ -130,11 +133,12 @@ export function ServiceMap({ end, environment, serviceName, + serviceGroup: serviceGroupId, }, }, }); }, - [license, serviceName, environment, start, end] + [license, serviceName, environment, start, end, serviceGroupId] ); const { ref, height } = useRefDimensions(); diff --git a/x-pack/plugins/apm/public/components/app/service_map/popover/index.tsx b/x-pack/plugins/apm/public/components/app/service_map/popover/index.tsx index d3fabd9a045f..937ad89293a7 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/popover/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/popover/index.tsx @@ -169,6 +169,7 @@ export function Popover({ isOpen={isOpen} ref={popoverRef} style={popoverStyle} + initialFocus={false} > { @@ -85,12 +86,19 @@ export function ServiceContents({ const detailsUrl = apmRouter.link('/services/{serviceName}', { path: { serviceName }, - query: { rangeFrom, rangeTo, environment, kuery, comparisonEnabled }, + query: { + rangeFrom, + rangeTo, + environment, + kuery, + comparisonEnabled, + serviceGroup, + }, }); const focusUrl = apmRouter.link('/services/{serviceName}/service-map', { path: { serviceName }, - query: { rangeFrom, rangeTo, environment, kuery }, + query: { rangeFrom, rangeTo, environment, kuery, serviceGroup }, }); const { serviceAnomalyStats } = nodeData; diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index d9c1a0220d15..88498f418645 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -11,7 +11,6 @@ import React, { ReactNode } from 'react'; import { useUiTracker } from '../../../../../../observability/public'; import { getNodeName, NodeType } from '../../../../../common/connections'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; -import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useFetcher } from '../../../../hooks/use_fetcher'; import { useTimeRange } from '../../../../hooks/use_time_range'; @@ -34,11 +33,16 @@ export function ServiceOverviewDependenciesTable({ hidePerPageOptions = false, }: ServiceOverviewDependenciesTableProps) { const { - urlParams: { comparisonEnabled, comparisonType, latencyAggregationType }, - } = useLegacyUrlParams(); - - const { - query: { environment, kuery, rangeFrom, rangeTo }, + query: { + environment, + kuery, + rangeFrom, + rangeTo, + serviceGroup, + comparisonEnabled, + comparisonType, + latencyAggregationType, + }, } = useApmParams('/services/{serviceName}/*'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); @@ -112,6 +116,7 @@ export function ServiceOverviewDependenciesTable({ rangeTo, latencyAggregationType, transactionType, + serviceGroup, }} /> ); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx index 5e0aa95340e8..dfea13eaaf47 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx @@ -10,7 +10,6 @@ import { orderBy } from 'lodash'; import React, { useState } from 'react'; import uuid from 'uuid'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../hooks/use_apm_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; @@ -21,6 +20,7 @@ import { ServiceOverviewInstancesTable, TableOptions, } from './service_overview_instances_table'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; interface ServiceOverviewInstancesChartAndTableProps { chartHeight: number; @@ -73,13 +73,17 @@ export function ServiceOverviewInstancesChartAndTable({ const { direction, field } = sort; const { - query: { environment, kuery, rangeFrom, rangeTo }, + query: { + environment, + kuery, + rangeFrom, + rangeTo, + comparisonEnabled, + comparisonType, + latencyAggregationType, + }, } = useApmParams('/services/{serviceName}/overview'); - const { - urlParams: { latencyAggregationType, comparisonType, comparisonEnabled }, - } = useLegacyUrlParams(); - const { start, end } = useTimeRange({ rangeFrom, rangeTo }); const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ @@ -108,7 +112,8 @@ export function ServiceOverviewInstancesChartAndTable({ query: { environment, kuery, - latencyAggregationType, + latencyAggregationType: + latencyAggregationType as LatencyAggregationType, start, end, transactionType, @@ -190,7 +195,8 @@ export function ServiceOverviewInstancesChartAndTable({ query: { environment, kuery, - latencyAggregationType, + latencyAggregationType: + latencyAggregationType as LatencyAggregationType, start, end, numBuckets: 20, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx index c41ad329ea86..03f036e44b4c 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx @@ -14,7 +14,6 @@ import { import { i18n } from '@kbn/i18n'; import React, { ReactNode, useEffect, useState } from 'react'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; -import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { APIReturnType } from '../../../../services/rest/create_call_apm_api'; import { @@ -27,6 +26,7 @@ import { getColumns } from './get_columns'; import { InstanceDetails } from './intance_details'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useBreakpoints } from '../../../../hooks/use_breakpoints'; +import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; type ServiceInstanceMainStatistics = APIReturnType<'GET /internal/apm/services/{serviceName}/service_overview_instances/main_statistics'>; @@ -71,13 +71,9 @@ export function ServiceOverviewInstancesTable({ const { agentName } = useApmServiceContext(); const { - query: { kuery }, + query: { kuery, latencyAggregationType, comparisonEnabled }, } = useApmParams('/services/{serviceName}'); - const { - urlParams: { latencyAggregationType, comparisonEnabled }, - } = useLegacyUrlParams(); - const [itemIdToOpenActionMenuRowMap, setItemIdToOpenActionMenuRowMap] = useState>({}); @@ -127,7 +123,7 @@ export function ServiceOverviewInstancesTable({ agentName, serviceName, kuery, - latencyAggregationType, + latencyAggregationType: latencyAggregationType as LatencyAggregationType, detailedStatsData, comparisonEnabled, toggleRowDetails, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx index dbbb925fe634..a0a8f7babe64 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx @@ -18,7 +18,6 @@ import { ApmMlDetectorType } from '../../../../common/anomaly_detection/apm_ml_d import { asExactTransactionRate } from '../../../../common/utils/formatters'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useEnvironmentsContext } from '../../../context/environments_context/use_environments_context'; -import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useFetcher } from '../../../hooks/use_fetcher'; import { usePreferredServiceAnomalyTimeseries } from '../../../hooks/use_preferred_service_anomaly_timeseries'; @@ -48,11 +47,7 @@ export function ServiceOverviewThroughputChart({ transactionName?: string; }) { const { - urlParams: { comparisonEnabled, comparisonType }, - } = useLegacyUrlParams(); - - const { - query: { rangeFrom, rangeTo }, + query: { rangeFrom, rangeTo, comparisonEnabled, comparisonType }, } = useApmParams('/services/{serviceName}'); const { environment } = useEnvironmentsContext(); diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_select.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_select.tsx index b45a513bf9d6..be716042a63c 100644 --- a/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_select.tsx +++ b/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_select.tsx @@ -5,23 +5,23 @@ * 2.0. */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiDescribedFormGroup, - EuiSelectOption, + EuiComboBoxOptionOption, EuiFormRow, + EuiComboBox, } from '@elastic/eui'; -import { SelectWithPlaceholder } from '../../../../../shared/select_with_placeholder'; - interface Props { title: string; description: string; fieldLabel: string; isLoading: boolean; - options?: EuiSelectOption[]; + options?: Array>; + isDisabled: boolean; value?: string; - disabled: boolean; - onChange: (event: React.ChangeEvent) => void; + onChange: (value?: string) => void; } export function FormRowSelect({ @@ -30,10 +30,25 @@ export function FormRowSelect({ fieldLabel, isLoading, options, - value, - disabled, + isDisabled, onChange, }: Props) { + const [selectedOptions, setSelected] = useState< + Array> | undefined + >([]); + + const handleOnChange = ( + nextSelectedOptions: Array> + ) => { + const [selectedOption] = nextSelectedOptions; + setSelected(nextSelectedOptions); + onChange(selectedOption.value); + }; + + useEffect(() => { + setSelected(undefined); + }, [isLoading]); + return ( - diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_suggestions_select.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_suggestions_select.tsx new file mode 100644 index 000000000000..f3f680ff4a9f --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_suggestions_select.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { SuggestionsSelect } from '../../../../../shared/suggestions_select'; +import { ENVIRONMENT_ALL } from '../../../../../../../common/environment_filter_values'; + +interface Props { + title: string; + field: string; + description: string; + fieldLabel: string; + value?: string; + allowAll?: boolean; + onChange: (value?: string) => void; +} + +export function FormRowSuggestionsSelect({ + title, + field, + description, + fieldLabel, + value, + allowAll = true, + onChange, +}: Props) { + return ( + {title}} + description={description} + > + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/service_page.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/service_page.tsx index 6f141a0ad8d5..9f8d3ca1318b 100644 --- a/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/service_page.tsx +++ b/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/service_page.tsx @@ -18,7 +18,8 @@ import { import { useFetcher, FETCH_STATUS } from '../../../../../../hooks/use_fetcher'; import { FormRowSelect } from './form_row_select'; import { APMLink } from '../../../../../shared/links/apm/apm_link'; - +import { FormRowSuggestionsSelect } from './form_row_suggestions_select'; +import { SERVICE_NAME } from '../../../../../../../common/elasticsearch_fieldnames'; interface Props { newConfig: AgentConfigurationIntake; setNewConfig: React.Dispatch>; @@ -26,17 +27,6 @@ interface Props { } export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { - const { data: serviceNamesData, status: serviceNamesStatus } = useFetcher( - (callApmApi) => { - return callApmApi('GET /api/apm/settings/agent-configuration/services', { - isCachable: true, - }); - }, - [], - { preservePreviousData: false } - ); - const serviceNames = serviceNamesData?.serviceNames ?? []; - const { data: environmentsData, status: environmentsStatus } = useFetcher( (callApmApi) => { if (newConfig.service.name) { @@ -81,14 +71,10 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { { defaultMessage: 'already configured' } ); - const serviceNameOptions = serviceNames.map((name) => ({ - text: getOptionLabel(name), - value: name, - })); const environmentOptions = environments.map( ({ name, alreadyConfigured }) => ({ disabled: alreadyConfigured, - text: `${getOptionLabel(name)} ${ + label: `${getOptionLabel(name)} ${ alreadyConfigured ? `(${ALREADY_CONFIGURED_TRANSLATED})` : '' }`, value: name, @@ -98,7 +84,7 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { return ( <> {/* Service name options */} - { - e.preventDefault(); - const name = e.target.value; + onChange={(name) => { setNewConfig((prev) => ({ ...prev, service: { name, environment: '' }, })); }} /> - {/* Environment options */} { - e.preventDefault(); - const environment = e.target.value; + onChange={(environment) => { setNewConfig((prev) => ({ ...prev, service: { name: prev.service.name, environment }, })); }} /> - - {/* Cancel button */} diff --git a/x-pack/plugins/apm/public/components/app/settings/custom_link/create_edit_custom_link_flyout/filters_section.tsx b/x-pack/plugins/apm/public/components/app/settings/custom_link/create_edit_custom_link_flyout/filters_section.tsx index 3ef8697cde8d..3b1438b4dddb 100644 --- a/x-pack/plugins/apm/public/components/app/settings/custom_link/create_edit_custom_link_flyout/filters_section.tsx +++ b/x-pack/plugins/apm/public/components/app/settings/custom_link/create_edit_custom_link_flyout/filters_section.tsx @@ -7,7 +7,6 @@ import { EuiButtonEmpty, - EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiSelect, @@ -27,6 +26,7 @@ import { FILTER_SELECT_OPTIONS, getSelectOptions, } from './helper'; +import { SuggestionsSelect } from '../../../../shared/suggestions_select'; export function FiltersSection({ filters, @@ -117,15 +117,17 @@ export function FiltersSection({ /> - onChangeFilter(key, e.target.value, idx)} - value={value} + onChange={(selectedValue) => + onChangeFilter(key, selectedValue as string, idx) + } + defaultValue={value} isInvalid={!isEmpty(key) && isEmpty(value)} /> diff --git a/x-pack/plugins/apm/public/components/app/trace_overview/trace_list.tsx b/x-pack/plugins/apm/public/components/app/trace_overview/trace_list.tsx index ca9acc76f27b..5646a3c47231 100644 --- a/x-pack/plugins/apm/public/components/app/trace_overview/trace_list.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_overview/trace_list.tsx @@ -86,7 +86,7 @@ export function getTraceListColumns({ content={ } diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx index dfc89f78e4b3..855f5c037fdd 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx @@ -9,11 +9,11 @@ import { EuiButton, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { getNextEnvironmentUrlParam } from '../../../../../common/environment_filter_values'; -import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params'; import { Transaction as ITransaction } from '../../../../../typings/es_schemas/ui/transaction'; import { TransactionDetailLink } from '../../../shared/links/apm/transaction_detail_link'; import { IWaterfall } from './waterfall_container/waterfall/waterfall_helpers/waterfall_helpers'; import { Environment } from '../../../../../common/environment_rt'; +import { useApmParams } from '../../../../hooks/use_apm_params'; export function MaybeViewTraceLink({ transaction, @@ -25,8 +25,8 @@ export function MaybeViewTraceLink({ environment: Environment; }) { const { - urlParams: { latencyAggregationType, comparisonEnabled, comparisonType }, - } = useLegacyUrlParams(); + query: { latencyAggregationType, comparisonEnabled, comparisonType }, + } = useApmParams('/services/{serviceName}/transactions/view'); const viewFullTraceButtonLabel = i18n.translate( 'xpack.apm.transactionDetails.viewFullTraceButtonLabel', diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/flyout_top_level_properties.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/flyout_top_level_properties.tsx index 20e278000266..ead54b3e9d6d 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/flyout_top_level_properties.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/flyout_top_level_properties.tsx @@ -13,7 +13,6 @@ import { } from '../../../../../../../common/elasticsearch_fieldnames'; import { getNextEnvironmentUrlParam } from '../../../../../../../common/environment_filter_values'; import { Transaction } from '../../../../../../../typings/es_schemas/ui/transaction'; -import { useLegacyUrlParams } from '../../../../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../../../../hooks/use_apm_params'; import { TransactionDetailLink } from '../../../../../shared/links/apm/transaction_detail_link'; import { ServiceLink } from '../../../../../shared/service_link'; @@ -24,11 +23,10 @@ interface Props { } export function FlyoutTopLevelProperties({ transaction }: Props) { - const { - urlParams: { latencyAggregationType, comparisonEnabled, comparisonType }, - } = useLegacyUrlParams(); const { query } = useApmParams('/services/{serviceName}/transactions/view'); + const { latencyAggregationType, comparisonEnabled, comparisonType } = query; + if (!transaction) { return null; } diff --git a/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx index d89dd9aab686..ab211b6ee391 100644 --- a/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx +++ b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx @@ -5,15 +5,30 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { createRouter, Outlet } from '@kbn/typed-react-router-config'; import * as t from 'io-ts'; import React from 'react'; +import { toBooleanRt } from '@kbn/io-ts-utils'; import { Breadcrumb } from '../app/breadcrumb'; import { TraceLink } from '../app/trace_link'; import { TransactionLink } from '../app/transaction_link'; import { home } from './home'; import { serviceDetail } from './service_detail'; import { settings } from './settings'; +import { ApmMainTemplate } from './templates/apm_main_template'; +import { ServiceGroupsList } from '../app/service_groups'; +import { ServiceGroupsRedirect } from './service_groups_redirect'; +import { comparisonTypeRt } from '../../../common/runtime_types/comparison_type_rt'; + +const ServiceGroupsBreadcrumnbLabel = i18n.translate( + 'xpack.apm.views.serviceGroups.breadcrumbLabel', + { defaultMessage: 'Services' } +); +const ServiceGroupsTitle = i18n.translate( + 'xpack.apm.views.serviceGroups.title', + { defaultMessage: 'Service groups' } +); /** * The array of route definitions to be used when the application @@ -59,6 +74,42 @@ const apmRoutes = { ), children: { + // this route fails on navigation unless it's defined before home + '/service-groups': { + element: ( + + + + + + + + ), + params: t.type({ + query: t.intersection([ + t.type({ + rangeFrom: t.string, + rangeTo: t.string, + }), + t.partial({ + serviceGroup: t.string, + }), + t.partial({ + refreshPaused: t.union([t.literal('true'), t.literal('false')]), + refreshInterval: t.string, + comparisonEnabled: toBooleanRt, + comparisonType: comparisonTypeRt, + }), + ]), + }), + }, ...settings, ...serviceDetail, ...home, diff --git a/x-pack/plugins/apm/public/components/routing/home/index.tsx b/x-pack/plugins/apm/public/components/routing/home/index.tsx index efde391467df..6e89df2aea5d 100644 --- a/x-pack/plugins/apm/public/components/routing/home/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/home/index.tsx @@ -7,9 +7,8 @@ import { i18n } from '@kbn/i18n'; import { Outlet } from '@kbn/typed-react-router-config'; import * as t from 'io-ts'; -import React from 'react'; +import React, { ComponentProps } from 'react'; import { toBooleanRt } from '@kbn/io-ts-utils'; -import { RedirectTo } from '../redirect_to'; import { comparisonTypeRt } from '../../../../common/runtime_types/comparison_type_rt'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { environmentRt } from '../../../../common/environment_rt'; @@ -21,15 +20,20 @@ import { ServiceMapHome } from '../../app/service_map'; import { TraceOverview } from '../../app/trace_overview'; import { ApmMainTemplate } from '../templates/apm_main_template'; import { RedirectToBackendOverviewRouteView } from './redirect_to_backend_overview_route_view'; +import { ServiceGroupTemplate } from '../templates/service_group_template'; +import { ServiceGroupsRedirect } from '../service_groups_redirect'; +import { RedirectTo } from '../redirect_to'; function page({ path, element, title, + showServiceGroupSaveButton = false, }: { path: TPath; element: React.ReactElement; title: string; + showServiceGroupSaveButton?: boolean; }): Record< TPath, { @@ -40,14 +44,61 @@ function page({ [path]: { element: ( - {element} + + {element} + ), }, + } as Record }>; +} + +function serviceGroupPage({ + path, + element, + title, + serviceGroupContextTab, +}: { + path: TPath; + element: React.ReactElement; + title: string; + serviceGroupContextTab: ComponentProps< + typeof ServiceGroupTemplate + >['serviceGroupContextTab']; +}): Record< + TPath, + { + element: React.ReactElement; + params: t.TypeC<{ query: t.TypeC<{ serviceGroup: t.StringC }> }>; + defaults: { query: { serviceGroup: string } }; + } +> { + return { + [path]: { + element: ( + + + {element} + + + ), + params: t.type({ + query: t.type({ serviceGroup: t.string }), + }), + defaults: { query: { serviceGroup: '' } }, + }, } as Record< TPath, { element: React.ReactElement; + params: t.TypeC<{ query: t.TypeC<{ serviceGroup: t.StringC }> }>; + defaults: { query: { serviceGroup: string } }; } >; } @@ -58,6 +109,12 @@ export const ServiceInventoryTitle = i18n.translate( defaultMessage: 'Services', } ); +export const ServiceMapTitle = i18n.translate( + 'xpack.apm.views.serviceMap.title', + { + defaultMessage: 'Service Map', + } +); export const DependenciesInventoryTitle = i18n.translate( 'xpack.apm.views.dependenciesInventory.title', @@ -92,10 +149,11 @@ export const home = { }, }, children: { - ...page({ + ...serviceGroupPage({ path: '/services', title: ServiceInventoryTitle, element: , + serviceGroupContextTab: 'service-inventory', }), ...page({ path: '/traces', @@ -104,12 +162,11 @@ export const home = { }), element: , }), - ...page({ + ...serviceGroupPage({ path: '/service-map', - title: i18n.translate('xpack.apm.views.serviceMap.title', { - defaultMessage: 'Service Map', - }), + title: ServiceMapTitle, element: , + serviceGroupContextTab: 'service-map', }), '/backends': { element: , @@ -144,7 +201,11 @@ export const home = { }, }, '/': { - element: , + element: ( + + + + ), }, }, }, diff --git a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx index 6514c8288be0..a4c2b84d57b3 100644 --- a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx @@ -28,6 +28,7 @@ import { ServiceProfiling } from '../../app/service_profiling'; import { ServiceDependencies } from '../../app/service_dependencies'; import { ServiceLogs } from '../../app/service_logs'; import { InfraOverview } from '../../app/infra_overview'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; function page({ title, @@ -76,6 +77,7 @@ export const serviceDetail = { rangeFrom: t.string, rangeTo: t.string, kuery: t.string, + serviceGroup: t.string, }), t.partial({ comparisonEnabled: toBooleanRt, @@ -92,6 +94,8 @@ export const serviceDetail = { query: { kuery: '', environment: ENVIRONMENT_ALL.value, + serviceGroup: '', + latencyAggregationType: LatencyAggregationType.avg, }, }, children: { diff --git a/x-pack/plugins/apm/public/components/routing/service_groups_redirect.tsx b/x-pack/plugins/apm/public/components/routing/service_groups_redirect.tsx new file mode 100644 index 000000000000..6776acebf9f2 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/service_groups_redirect.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 React from 'react'; +import { RedirectTo } from './redirect_to'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { enableServiceGroups } from '../../../../observability/public'; +import { useFetcher, FETCH_STATUS } from '../../hooks/use_fetcher'; + +export function ServiceGroupsRedirect({ + children, +}: { + children?: React.ReactNode; +}) { + const { data = { serviceGroups: [] }, status } = useFetcher( + (callApmApi) => callApmApi('GET /internal/apm/service-groups'), + [] + ); + const { serviceGroups } = data; + const isLoading = + status === FETCH_STATUS.NOT_INITIATED || status === FETCH_STATUS.LOADING; + const { + services: { uiSettings }, + } = useKibana(); + const isServiceGroupsEnabled = uiSettings?.get(enableServiceGroups); + + if (isLoading) { + return null; + } + if (!isServiceGroupsEnabled || serviceGroups.length === 0) { + return ; + } + return <>{children}; +} diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx index 6b5dcbfbdd34..e138027d0555 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx @@ -17,6 +17,8 @@ import { useFetcher, FETCH_STATUS } from '../../../hooks/use_fetcher'; import { ApmPluginStartDeps } from '../../../plugin'; import { ApmEnvironmentFilter } from '../../shared/environment_filter'; import { getNoDataConfig } from './no_data_config'; +import { enableServiceGroups } from '../../../../../observability/public'; +import { ServiceGroupSaveButton } from '../../app/service_groups'; // Paths that must skip the no data screen const bypassNoDataScreenPaths = ['/settings']; @@ -29,18 +31,21 @@ const bypassNoDataScreenPaths = ['/settings']; * * Optionally: * - EnvironmentFilter + * - ServiceGroupSaveButton */ export function ApmMainTemplate({ pageTitle, pageHeader, children, environmentFilter = true, + showServiceGroupSaveButton = false, ...pageTemplateProps }: { pageTitle?: React.ReactNode; pageHeader?: EuiPageHeaderProps; children: React.ReactNode; environmentFilter?: boolean; + showServiceGroupSaveButton?: boolean; } & KibanaPageTemplateProps) { const location = useLocation(); @@ -79,7 +84,16 @@ export function ApmMainTemplate({ fleetApmPoliciesStatus === FETCH_STATUS.LOADING, }); - const rightSideItems = environmentFilter ? [] : []; + const { + services: { uiSettings }, + } = useKibana(); + const isServiceGroupsEnabled = uiSettings?.get(enableServiceGroups); + const renderServiceGroupSaveButton = + showServiceGroupSaveButton && isServiceGroupsEnabled; + const rightSideItems = [ + ...(renderServiceGroupSaveButton ? [] : []), + ...(environmentFilter ? [] : []), + ]; const pageTemplate = ( (); + const isServiceGroupsEnabled = uiSettings?.get(enableServiceGroups); + + const router = useApmRouter(); + const { + query, + query: { serviceGroup: serviceGroupId }, + } = useAnyOfApmParams('/services', '/service-map'); + + const { data } = useFetcher((callApmApi) => { + if (serviceGroupId) { + return callApmApi('GET /internal/apm/service-group', { + params: { query: { serviceGroup: serviceGroupId } }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const serviceGroupName = data?.serviceGroup.groupName; + const loadingServiceGroupName = !!serviceGroupId && !serviceGroupName; + const serviceGroupsLink = router.link('/service-groups', { + query: { ...query, serviceGroup: '' }, + }); + + const serviceGroupsPageTitle = ( + + + + + + {loadingServiceGroupName ? ( + + ) : ( + serviceGroupName || + i18n.translate('xpack.apm.serviceGroup.allServices.title', { + defaultMessage: 'Services', + }) + )} + + + ); + + const tabs = useTabs(serviceGroupContextTab); + const selectedTab = tabs?.find(({ isSelected }) => isSelected); + useBreadcrumb([ + { + title: i18n.translate('xpack.apm.serviceGroups.breadcrumb.title', { + defaultMessage: 'Services', + }), + href: serviceGroupsLink, + }, + ...(selectedTab + ? [ + ...(serviceGroupName + ? [ + { + title: serviceGroupName, + href: router.link('/services', { query }), + }, + ] + : []), + { + title: selectedTab.label, + href: selectedTab.href, + } as { title: string; href: string }, + ] + : []), + ]); + return ( + + {children} + + ); +} + +type ServiceGroupContextTab = NonNullable[0] & { + key: 'service-inventory' | 'service-map'; +}; + +function useTabs(selectedTab: ServiceGroupContextTab['key']) { + const router = useApmRouter(); + const { query } = useAnyOfApmParams('/services', '/service-map'); + + const tabs: ServiceGroupContextTab[] = [ + { + key: 'service-inventory', + label: i18n.translate('xpack.apm.serviceGroup.serviceInventory', { + defaultMessage: 'Inventory', + }), + href: router.link('/services', { query }), + }, + { + key: 'service-map', + label: i18n.translate('xpack.apm.serviceGroup.serviceMap', { + defaultMessage: 'Service map', + }), + href: router.link('/service-map', { query }), + }, + ]; + + return tabs + .filter((t) => !t.hidden) + .map(({ href, key, label }) => ({ + href, + label, + isSelected: key === selectedTab, + })); +} diff --git a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx index 4c593e80df0c..f988917515fb 100644 --- a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx @@ -16,6 +16,7 @@ import React, { useState } from 'react'; import { IBasePath } from '../../../../../../../src/core/public'; import { AlertType } from '../../../../common/alert_types'; import { AlertingFlyout } from '../../alerting/alerting_flyout'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; const alertLabel = i18n.translate('xpack.apm.home.alertsMenu.alerts', { defaultMessage: 'Alerts and rules', @@ -63,7 +64,9 @@ export function AlertingPopoverAndFlyout({ }: Props) { const [popoverOpen, setPopoverOpen] = useState(false); const [alertType, setAlertType] = useState(null); - + const { + plugins: { observability }, + } = useApmPluginContext(); const button = ( diff --git a/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx index 6991a7aa7e20..880879119be9 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx @@ -15,7 +15,6 @@ import { useApmServiceContext } from '../../../../context/apm_service/use_apm_se import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; import { getDurationFormatter } from '../../../../../common/utils/formatters'; import { useLicenseContext } from '../../../../context/license/use_license_context'; -import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params'; import { useTransactionLatencyChartsFetcher } from '../../../../hooks/use_transaction_latency_chart_fetcher'; import { TimeseriesChart } from '../../../shared/charts/timeseries_chart'; import { @@ -28,6 +27,7 @@ import { getComparisonChartTheme } from '../../time_comparison/get_time_range_co import { useEnvironmentsContext } from '../../../../context/environments_context/use_environments_context'; import { ApmMlDetectorType } from '../../../../../common/anomaly_detection/apm_ml_detectors'; import { usePreferredServiceAnomalyTimeseries } from '../../../../hooks/use_preferred_service_anomaly_timeseries'; +import { useAnyOfApmParams } from '../../../../hooks/use_apm_params'; interface Props { height?: number; @@ -48,10 +48,16 @@ export function LatencyChart({ height, kuery }: Props) { const history = useHistory(); const comparisonChartTheme = getComparisonChartTheme(); - const { urlParams } = useLegacyUrlParams(); - const { latencyAggregationType, comparisonEnabled } = urlParams; const license = useLicenseContext(); + const { + query: { comparisonEnabled, latencyAggregationType }, + } = useAnyOfApmParams( + '/services/{serviceName}/overview', + '/services/{serviceName}/transactions', + '/services/{serviceName}/transactions/view' + ); + const { environment } = useEnvironmentsContext(); const { latencyChartsData, latencyChartsStatus } = diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx index 2b99562b6717..b6558bea79d3 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx @@ -74,7 +74,7 @@ export function TransactionColdstartRateChart({ const { start, end } = useTimeRange({ rangeFrom, rangeTo }); const { serviceName, transactionType } = useApmServiceContext(); - const comparisonChartThem = getComparisonChartTheme(); + const comparisonChartTheme = getComparisonChartTheme(); const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ start, end, @@ -177,7 +177,7 @@ export function TransactionColdstartRateChart({ timeseries={timeseries} yLabelFormat={yLabelFormat} yDomain={{ min: 0, max: 1 }} - customTheme={comparisonChartThem} + customTheme={comparisonChartTheme} /> ); diff --git a/x-pack/plugins/apm/public/components/shared/environment_filter/index.tsx b/x-pack/plugins/apm/public/components/shared/environment_filter/index.tsx index 64d137cae0c2..9a3d677b3f07 100644 --- a/x-pack/plugins/apm/public/components/shared/environment_filter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/environment_filter/index.tsx @@ -11,7 +11,7 @@ import { History } from 'history'; import React from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { - ENVIRONMENT_ALL, + ENVIRONMENT_ALL_SELECT_OPTION, ENVIRONMENT_NOT_DEFINED, } from '../../../../common/environment_filter_values'; import { fromQuery, toQuery } from '../links/url_helpers'; @@ -51,7 +51,7 @@ function getOptions(environments: string[]) { })); return [ - ENVIRONMENT_ALL, + ENVIRONMENT_ALL_SELECT_OPTION, ...(environments.includes(ENVIRONMENT_NOT_DEFINED.value) ? [ENVIRONMENT_NOT_DEFINED] : []), diff --git a/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx b/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx index a04d3218f9ff..0fb84aa7164b 100644 --- a/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx @@ -39,13 +39,17 @@ export function KueryBar(props: { placeholder?: string; boolFilter?: QueryDslQueryContainer[]; prepend?: React.ReactNode | string; + onSubmit?: (value: string) => void; + onChange?: (value: string) => void; + value?: string; }) { const { path, query } = useApmParams('/*'); const serviceName = 'serviceName' in path ? path.serviceName : undefined; const groupId = 'groupId' in path ? path.groupId : undefined; const environment = 'environment' in query ? query.environment : undefined; - const kuery = 'kuery' in query ? query.kuery : undefined; + const _kuery = 'kuery' in query ? query.kuery : undefined; + const kuery = props.value || _kuery; const history = useHistory(); const [state, setState] = useState({ @@ -88,6 +92,9 @@ export function KueryBar(props: { }); async function onChange(inputValue: string, selectionStart: number) { + if (typeof props.onChange === 'function') { + props.onChange(inputValue); + } if (dataView == null) { return; } @@ -140,6 +147,11 @@ export function KueryBar(props: { return; } + if (typeof props.onSubmit === 'function') { + props.onSubmit(inputValue.trim()); + return; + } + history.push({ ...location, search: fromQuery({ diff --git a/x-pack/plugins/apm/public/components/shared/kuery_bar/typeahead/index.js b/x-pack/plugins/apm/public/components/shared/kuery_bar/typeahead/index.js index d06bfcceab98..005a06b9cdc9 100644 --- a/x-pack/plugins/apm/public/components/shared/kuery_bar/typeahead/index.js +++ b/x-pack/plugins/apm/public/components/shared/kuery_bar/typeahead/index.js @@ -108,6 +108,16 @@ export class Typeahead extends Component { } }; + onBlur = () => { + const { isSuggestionsVisible, index, value } = this.state; + if (isSuggestionsVisible && this.props.suggestions[index]) { + this.selectSuggestion(this.props.suggestions[index]); + } else { + this.setState({ isSuggestionsVisible: false }); + this.props.onSubmit(value); + } + }; + selectSuggestion = (suggestion) => { const nextInputValue = this.state.value.substr(0, suggestion.start) + @@ -184,6 +194,7 @@ export class Typeahead extends Component { onKeyUp={this.onKeyUp} onChange={this.onChangeInputValue} onClick={this.onClickInput} + onBlur={this.onBlur} autoComplete="off" spellCheck={false} prepend={prepend} diff --git a/x-pack/plugins/apm/public/components/shared/links/apm/apm_link.tsx b/x-pack/plugins/apm/public/components/shared/links/apm/apm_link.tsx index bcb305bd38ec..10e101d6af2b 100644 --- a/x-pack/plugins/apm/public/components/shared/links/apm/apm_link.tsx +++ b/x-pack/plugins/apm/public/components/shared/links/apm/apm_link.tsx @@ -32,6 +32,7 @@ export const PERSISTENT_APM_PARAMS: Array = [ 'refreshPaused', 'refreshInterval', 'environment', + 'serviceGroup', ]; /** diff --git a/x-pack/plugins/apm/public/components/shared/links/url_helpers.ts b/x-pack/plugins/apm/public/components/shared/links/url_helpers.ts index bbde45ba9cd6..ae42abaf4ac7 100644 --- a/x-pack/plugins/apm/public/components/shared/links/url_helpers.ts +++ b/x-pack/plugins/apm/public/components/shared/links/url_helpers.ts @@ -95,6 +95,7 @@ export interface APMQueryParams { podName?: string; agentName?: string; serviceVersion?: string; + serviceGroup?: string; } // forces every value of T[K] to be type: string diff --git a/x-pack/plugins/apm/public/components/shared/redirect_with_default_date_range/index.tsx b/x-pack/plugins/apm/public/components/shared/redirect_with_default_date_range/index.tsx index 368125d7a6fd..f0056ca6e42e 100644 --- a/x-pack/plugins/apm/public/components/shared/redirect_with_default_date_range/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/redirect_with_default_date_range/index.tsx @@ -38,6 +38,7 @@ export function RedirectWithDefaultDateRange({ route.path === '/service-map' || route.path === '/backends' || route.path === '/services/{serviceName}' || + route.path === '/service-groups' || location.pathname === '/' || location.pathname === '' ); diff --git a/x-pack/plugins/apm/public/components/shared/service_link.stories.tsx b/x-pack/plugins/apm/public/components/shared/service_link.stories.tsx index c50c1911afe7..55320bfd94bd 100644 --- a/x-pack/plugins/apm/public/components/shared/service_link.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_link.stories.tsx @@ -36,6 +36,7 @@ Example.args = { kuery: '', rangeFrom: 'now-15m', rangeTo: 'now', + serviceGroup: '', }, serviceName: 'opbeans-java', }; diff --git a/x-pack/plugins/apm/public/components/shared/suggestions_select/index.tsx b/x-pack/plugins/apm/public/components/shared/suggestions_select/index.tsx index 2d735ec4ea70..8b8907af4bc2 100644 --- a/x-pack/plugins/apm/public/components/shared/suggestions_select/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/suggestions_select/index.tsx @@ -6,17 +6,20 @@ */ import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { debounce } from 'lodash'; +import { throttle } from 'lodash'; import React, { useCallback, useState } from 'react'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; interface SuggestionsSelectProps { allOption?: EuiComboBoxOptionOption; - customOptionText: string; + customOptionText?: string; defaultValue?: string; field: string; onChange: (value?: string) => void; + isClearable?: boolean; + isInvalid?: boolean; placeholder: string; + dataTestSubj?: string; } export function SuggestionsSelect({ @@ -26,13 +29,12 @@ export function SuggestionsSelect({ field, onChange, placeholder, + isInvalid, + dataTestSubj, + isClearable = true, }: SuggestionsSelectProps) { - const allowAll = !!allOption; let defaultOption: EuiComboBoxOptionOption | undefined; - if (allowAll && !defaultValue) { - defaultOption = allOption; - } if (defaultValue) { defaultOption = { label: defaultValue, value: defaultValue }; } @@ -57,6 +59,11 @@ export function SuggestionsSelect({ const handleChange = useCallback( (changedOptions: Array>) => { setSelectedOptions(changedOptions); + + if (changedOptions.length === 0) { + onChange(''); + } + if (changedOptions.length === 1) { onChange( changedOptions[0].value @@ -91,17 +98,19 @@ export function SuggestionsSelect({ return ( ); } diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/comparison.test.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/comparison.test.ts new file mode 100644 index 000000000000..30da5078f5de --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/comparison.test.ts @@ -0,0 +1,477 @@ +/* + * Copyright 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 { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; +import { getTimeRangeComparison } from './get_time_range_comparison'; +import { getDateRange } from '../../../context/url_params_context/helpers'; +import { getComparisonOptions } from './get_comparison_options'; +import moment from 'moment'; + +function getExpectedTimesAndComparisons({ + rangeFrom, + rangeTo, +}: { + rangeFrom: string; + rangeTo: string; +}) { + const { start, end } = getDateRange({ rangeFrom, rangeTo }); + const comparisonOptions = getComparisonOptions({ start, end }); + + const comparisons = comparisonOptions.map(({ value, text }) => { + const { comparisonStart, comparisonEnd, offset } = getTimeRangeComparison({ + comparisonEnabled: true, + comparisonType: value, + start, + end, + }); + + return { + value, + text, + comparisonStart, + comparisonEnd, + offset, + }; + }); + + return { + start, + end, + comparisons, + }; +} + +describe('Comparison test suite', () => { + let dateNowSpy: jest.SpyInstance; + + beforeAll(() => { + const mockDateNow = '2022-01-14T18:30:15.500Z'; + dateNowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(new Date(mockDateNow).getTime()); + }); + + afterAll(() => { + dateNowSpy.mockRestore(); + }); + + describe('When the time difference is less than 25 hours', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: '2022-01-15T18:00:00.000Z', + rangeTo: '2022-01-16T18:30:00.000Z', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-15T18:00:00.000Z'); + expect(expectation.end).toBe('2022-01-16T18:30:00.000Z'); + }); + + it('should return comparison by day and week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.DayBefore, + text: 'Day before', + comparisonStart: '2022-01-14T18:00:00.000Z', + comparisonEnd: '2022-01-15T18:30:00.000Z', + offset: '1d', + }, + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-08T18:00:00.000Z', + comparisonEnd: '2022-01-09T18:30:00.000Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When the time difference is more than 25 hours', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: '2022-01-15T18:00:00.000Z', + rangeTo: '2022-01-16T19:00:00.000Z', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-15T18:00:00.000Z'); + expect(expectation.end).toBe('2022-01-16T19:00:00.000Z'); + }); + + it('should only return comparison by week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-08T18:00:00.000Z', + comparisonEnd: '2022-01-09T19:00:00.000Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When the time difference is more than 25 hours and less than 8 days', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: '2022-01-15T18:00:00.000Z', + rangeTo: '2022-01-22T21:00:00.000Z', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-15T18:00:00.000Z'); + expect(expectation.end).toBe('2022-01-22T21:00:00.000Z'); + }); + + it('should only return comparison by week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-08T18:00:00.000Z', + comparisonEnd: '2022-01-15T21:00:00.000Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When the time difference is 8 days', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: '2022-01-15T18:00:00.000Z', + rangeTo: '2022-01-23T18:00:00.000Z', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-15T18:00:00.000Z'); + expect(expectation.end).toBe('2022-01-23T18:00:00.000Z'); + }); + + it('should only return comparison by period and format text as DD/MM HH:mm when range years are the same', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.PeriodBefore, + text: '07/01 18:00 - 15/01 18:00', + comparisonStart: '2022-01-07T18:00:00.000Z', + comparisonEnd: '2022-01-15T18:00:00.000Z', + offset: '691200000ms', + }, + ]); + }); + + it('should have the same offset for start / end and comparisonStart / comparisonEnd', () => { + const { start, end, comparisons } = expectation; + const diffInMs = moment(end).diff(moment(start)); + expect(`${diffInMs}ms`).toBe(comparisons[0].offset); + }); + }); + + describe('When the time difference is more than 8 days', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: '2022-01-15T18:00:00.000Z||/d', + rangeTo: '2022-01-23T18:00:00.000Z', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-15T00:00:00.000Z'); + expect(expectation.end).toBe('2022-01-23T18:00:00.000Z'); + }); + + it('should only return comparison by period and format text as DD/MM HH:mm when range years are the same', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.PeriodBefore, + text: '06/01 06:00 - 15/01 00:00', + comparisonStart: '2022-01-06T06:00:00.000Z', + comparisonEnd: '2022-01-15T00:00:00.000Z', + offset: '756000000ms', + }, + ]); + }); + + it('should have the same offset for start / end and comparisonStart / comparisonEnd', () => { + const { start, end, comparisons } = expectation; + const diffInMs = moment(end).diff(moment(start)); + expect(`${diffInMs}ms`).toBe(comparisons[0].offset); + }); + }); + + describe('When "Today" is selected', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now/d', + rangeTo: 'now/d', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-14T00:00:00.000Z'); + expect(expectation.end).toBe('2022-01-14T23:59:59.999Z'); + }); + + it('should return comparison by day and week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.DayBefore, + text: 'Day before', + comparisonStart: '2022-01-13T00:00:00.000Z', + comparisonEnd: '2022-01-13T23:59:59.999Z', + offset: '1d', + }, + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-07T00:00:00.000Z', + comparisonEnd: '2022-01-07T23:59:59.999Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When "This week" is selected', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now/w', + rangeTo: 'now/w', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-09T00:00:00.000Z'); + expect(expectation.end).toBe('2022-01-15T23:59:59.999Z'); + }); + + it('should only return comparison by week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-02T00:00:00.000Z', + comparisonEnd: '2022-01-08T23:59:59.999Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When "Last 24 hours" is selected with no rounding', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now-24h', + rangeTo: 'now', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-13T18:30:15.500Z'); + expect(expectation.end).toBe('2022-01-14T18:30:15.500Z'); + }); + + it('should return comparison by day and week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.DayBefore, + text: 'Day before', + comparisonStart: '2022-01-12T18:30:15.500Z', + comparisonEnd: '2022-01-13T18:30:15.500Z', + offset: '1d', + }, + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-06T18:30:15.500Z', + comparisonEnd: '2022-01-07T18:30:15.500Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When "Last 24 hours" is selected with rounding', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now-24h/h', + rangeTo: 'now', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-13T18:00:00.000Z'); + expect(expectation.end).toBe('2022-01-14T18:30:15.500Z'); + }); + + it('should return comparison by day and week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.DayBefore, + text: 'Day before', + comparisonStart: '2022-01-12T18:00:00.000Z', + comparisonEnd: '2022-01-13T18:30:15.500Z', + offset: '1d', + }, + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-06T18:00:00.000Z', + comparisonEnd: '2022-01-07T18:30:15.500Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When "Last 7 days" is selected with no rounding', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now-7d', + rangeTo: 'now', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-07T18:30:15.500Z'); + expect(expectation.end).toBe('2022-01-14T18:30:15.500Z'); + }); + + it('should only return comparison by week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2021-12-31T18:30:15.500Z', + comparisonEnd: '2022-01-07T18:30:15.500Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When "Last 7 days" is selected with rounding', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now-7d/d', + rangeTo: 'now', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-07T00:00:00.000Z'); + expect(expectation.end).toBe('2022-01-14T18:30:15.500Z'); + }); + + it('should only return comparison by week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2021-12-31T00:00:00.000Z', + comparisonEnd: '2022-01-07T18:30:15.500Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When "Last 30 days" is selected with no rounding', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now-30d', + rangeTo: 'now', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2021-12-15T18:30:15.500Z'); + expect(expectation.end).toBe('2022-01-14T18:30:15.500Z'); + }); + + it('should only return comparison by period and format text as DD/MM/YY HH:mm when range years are different', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.PeriodBefore, + text: '15/11/21 18:30 - 15/12/21 18:30', + comparisonStart: '2021-11-15T18:30:15.500Z', + comparisonEnd: '2021-12-15T18:30:15.500Z', + offset: '2592000000ms', + }, + ]); + }); + + it('should have the same offset for start / end and comparisonStart / comparisonEnd', () => { + const { start, end, comparisons } = expectation; + const diffInMs = moment(end).diff(moment(start)); + expect(`${diffInMs}ms`).toBe(comparisons[0].offset); + }); + }); + + describe('When "Last 30 days" is selected with rounding', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now-30d/d', + rangeTo: 'now', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2021-12-15T00:00:00.000Z'); + expect(expectation.end).toBe('2022-01-14T18:30:15.500Z'); + }); + + it('should only return comparison by period and format text as DD/MM/YY HH:mm when range years are different', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.PeriodBefore, + text: '14/11/21 05:29 - 15/12/21 00:00', + comparisonStart: '2021-11-14T05:29:44.500Z', + comparisonEnd: '2021-12-15T00:00:00.000Z', + offset: '2658615500ms', + }, + ]); + }); + + it('should have the same offset for start / end and comparisonStart / comparisonEnd', () => { + const { start, end, comparisons } = expectation; + const diffInMs = moment(end).diff(moment(start)); + expect(`${diffInMs}ms`).toBe(comparisons[0].offset); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_options.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_options.ts new file mode 100644 index 000000000000..9400f668a18f --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_options.ts @@ -0,0 +1,126 @@ +/* + * Copyright 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 moment from 'moment'; +import { i18n } from '@kbn/i18n'; +import { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; +import { getTimeRangeComparison } from './get_time_range_comparison'; + +const eightDaysInHours = moment.duration(8, 'd').asHours(); + +function getDateFormat({ + previousPeriodStart, + currentPeriodEnd, +}: { + previousPeriodStart?: string; + currentPeriodEnd?: string; +}) { + const momentPreviousPeriodStart = moment(previousPeriodStart); + const momentCurrentPeriodEnd = moment(currentPeriodEnd); + const isDifferentYears = + momentPreviousPeriodStart.get('year') !== + momentCurrentPeriodEnd.get('year'); + return isDifferentYears ? 'DD/MM/YY HH:mm' : 'DD/MM HH:mm'; +} + +function formatDate({ + dateFormat, + previousPeriodStart, + previousPeriodEnd, +}: { + dateFormat: string; + previousPeriodStart?: string; + previousPeriodEnd?: string; +}) { + const momentStart = moment(previousPeriodStart); + const momentEnd = moment(previousPeriodEnd); + return `${momentStart.format(dateFormat)} - ${momentEnd.format(dateFormat)}`; +} + +function getSelectOptions({ + comparisonTypes, + start, + end, +}: { + comparisonTypes: TimeRangeComparisonEnum[]; + start?: string; + end?: string; +}) { + return comparisonTypes.map((value) => { + switch (value) { + case TimeRangeComparisonEnum.DayBefore: { + return { + value, + text: i18n.translate('xpack.apm.timeComparison.select.dayBefore', { + defaultMessage: 'Day before', + }), + }; + } + case TimeRangeComparisonEnum.WeekBefore: { + return { + value, + text: i18n.translate('xpack.apm.timeComparison.select.weekBefore', { + defaultMessage: 'Week before', + }), + }; + } + case TimeRangeComparisonEnum.PeriodBefore: { + const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonEnum.PeriodBefore, + start, + end, + comparisonEnabled: true, + }); + + const dateFormat = getDateFormat({ + previousPeriodStart: comparisonStart, + currentPeriodEnd: end, + }); + + return { + value, + text: formatDate({ + dateFormat, + previousPeriodStart: comparisonStart, + previousPeriodEnd: comparisonEnd, + }), + }; + } + } + }); +} + +export function getComparisonOptions({ + start, + end, +}: { + start?: string; + end?: string; +}) { + const momentStart = moment(start); + const momentEnd = moment(end); + const hourDiff = momentEnd.diff(momentStart, 'h', true); + + let comparisonTypes: TimeRangeComparisonEnum[]; + + if (hourDiff < 25) { + // Less than 25 hours. This is because relative times may be rounded when + // asking for a day, which can result in a duration > 24h. (e.g. rangeFrom: 'now-24h/h, rangeTo: 'now') + comparisonTypes = [ + TimeRangeComparisonEnum.DayBefore, + TimeRangeComparisonEnum.WeekBefore, + ]; + } else if (hourDiff < eightDaysInHours) { + // Less than 8 days. This is because relative times may be rounded when + // asking for a week, which can result in a duration > 7d. (e.g. rangeFrom: 'now-7d/d, rangeTo: 'now') + comparisonTypes = [TimeRangeComparisonEnum.WeekBefore]; + } else { + comparisonTypes = [TimeRangeComparisonEnum.PeriodBefore]; + } + + return getSelectOptions({ comparisonTypes, start, end }); +} diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_types.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_types.ts deleted file mode 100644 index 97754cd91fd3..000000000000 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_types.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import moment from 'moment'; -import { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; -import { getDateDifference } from '../../../../common/utils/formatters'; - -export function getComparisonTypes({ - start, - end, -}: { - start?: string; - end?: string; -}) { - const momentStart = moment(start).startOf('second'); - const momentEnd = moment(end).startOf('second'); - - const dateDiff = getDateDifference({ - start: momentStart, - end: momentEnd, - precise: true, - unitOfTime: 'days', - }); - - // Less than or equals to one day - if (dateDiff <= 1) { - return [ - TimeRangeComparisonEnum.DayBefore, - TimeRangeComparisonEnum.WeekBefore, - ]; - } - - // Less than or equals to one week - if (dateDiff <= 7) { - return [TimeRangeComparisonEnum.WeekBefore]; - } - // } - - // above one week or when rangeTo is not "now" - return [TimeRangeComparisonEnum.PeriodBefore]; -} diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts index 7e67d76c2ada..c6619ad6d35f 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts @@ -41,102 +41,4 @@ describe('getTimeRangeComparison', () => { expect(result).toEqual({}); }); }); - - describe('Time range is between 0 - 24 hours', () => { - describe('when day before is selected', () => { - it('returns the correct time range - 15 min', () => { - const start = '2021-01-28T14:45:00.000Z'; - const end = '2021-01-28T15:00:00.000Z'; - const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonEnum.DayBefore, - comparisonEnabled: true, - start, - end, - }); - expect(result.comparisonStart).toEqual('2021-01-27T14:45:00.000Z'); - expect(result.comparisonEnd).toEqual('2021-01-27T15:00:00.000Z'); - expect(result.offset).toEqual('1d'); - }); - }); - describe('when a week before is selected', () => { - it('returns the correct time range - 15 min', () => { - const start = '2021-01-28T14:45:00.000Z'; - const end = '2021-01-28T15:00:00.000Z'; - const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonEnum.WeekBefore, - comparisonEnabled: true, - start, - end, - }); - expect(result.comparisonStart).toEqual('2021-01-21T14:45:00.000Z'); - expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); - expect(result.offset).toEqual('1w'); - }); - }); - describe('when previous period is selected', () => { - it('returns the correct time range - 15 min', () => { - const start = '2021-02-09T14:40:01.087Z'; - const end = '2021-02-09T14:56:00.000Z'; - const result = getTimeRangeComparison({ - start, - end, - comparisonType: TimeRangeComparisonEnum.PeriodBefore, - comparisonEnabled: true, - }); - expect(result).toEqual({ - comparisonStart: '2021-02-09T14:24:02.174Z', - comparisonEnd: '2021-02-09T14:40:01.087Z', - offset: '958913ms', - }); - }); - }); - }); - - describe('Time range is between 24 hours - 1 week', () => { - describe('when a week before is selected', () => { - it('returns the correct time range - 2 days', () => { - const start = '2021-01-26T15:00:00.000Z'; - const end = '2021-01-28T15:00:00.000Z'; - const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonEnum.WeekBefore, - comparisonEnabled: true, - start, - end, - }); - expect(result.comparisonStart).toEqual('2021-01-19T15:00:00.000Z'); - expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); - expect(result.offset).toEqual('1w'); - }); - }); - }); - - describe('Time range is greater than 7 days', () => { - it('uses the date difference to calculate the time range - 8 days', () => { - const start = '2021-01-10T15:00:00.000Z'; - const end = '2021-01-18T15:00:00.000Z'; - const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonEnum.PeriodBefore, - comparisonEnabled: true, - start, - end, - }); - expect(result.comparisonStart).toEqual('2021-01-02T15:00:00.000Z'); - expect(result.comparisonEnd).toEqual('2021-01-10T15:00:00.000Z'); - expect(result.offset).toEqual('691200000ms'); - }); - - it('uses the date difference to calculate the time range - 30 days', () => { - const start = '2021-01-01T15:00:00.000Z'; - const end = '2021-01-31T15:00:00.000Z'; - const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonEnum.PeriodBefore, - comparisonEnabled: true, - start, - end, - }); - expect(result.comparisonStart).toEqual('2020-12-02T15:00:00.000Z'); - expect(result.comparisonEnd).toEqual('2021-01-01T15:00:00.000Z'); - expect(result.offset).toEqual('2592000000ms'); - }); - }); }); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts index 92611d88aa0c..6a1f7b1978ca 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts @@ -11,7 +11,6 @@ import { TimeRangeComparisonType, TimeRangeComparisonEnum, } from '../../../../common/runtime_types/comparison_type_rt'; -import { getDateDifference } from '../../../../common/utils/formatters'; export function getComparisonChartTheme(): PartialTheme { return { @@ -48,13 +47,9 @@ export function getTimeRangeComparison({ if (!comparisonEnabled || !comparisonType || !start || !end) { return {}; } - const startMoment = moment(start); const endMoment = moment(end); - const startEpoch = startMoment.valueOf(); - const endEpoch = endMoment.valueOf(); - let diff: number; let offset: string; @@ -63,29 +58,21 @@ export function getTimeRangeComparison({ diff = oneDayInMilliseconds; offset = '1d'; break; - case TimeRangeComparisonEnum.WeekBefore: diff = oneWeekInMilliseconds; offset = '1w'; break; - case TimeRangeComparisonEnum.PeriodBefore: - diff = getDateDifference({ - start: startMoment, - end: endMoment, - unitOfTime: 'milliseconds', - precise: true, - }); + diff = endMoment.diff(startMoment); offset = `${diff}ms`; break; - default: throw new Error('Unknown comparisonType'); } return { - comparisonStart: new Date(startEpoch - diff).toISOString(), - comparisonEnd: new Date(endEpoch - diff).toISOString(), + comparisonStart: startMoment.subtract(diff, 'ms').toISOString(), + comparisonEnd: endMoment.subtract(diff, 'ms').toISOString(), offset, }; } diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx index d811fbb5d035..83d2962316aa 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx @@ -13,10 +13,9 @@ import { expectTextsInDocument, expectTextsNotInDocument, } from '../../../utils/test_helpers'; -import { getSelectOptions, TimeComparison } from './'; +import { TimeComparison } from './'; import * as urlHelpers from '../../shared/links/url_helpers'; import moment from 'moment'; -import { getComparisonTypes } from './get_comparison_types'; import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { @@ -26,14 +25,14 @@ import { import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; function getWrapper({ - exactStart, - exactEnd, + rangeFrom, + rangeTo, comparisonType, comparisonEnabled, environment = ENVIRONMENT_ALL.value, }: { - exactStart: string; - exactEnd: string; + rangeFrom: string; + rangeTo: string; comparisonType?: TimeRangeComparisonType; comparisonEnabled?: boolean; environment?: string; @@ -42,7 +41,7 @@ function getWrapper({ return ( { +describe('TimeComparison component', () => { beforeAll(() => { moment.tz.setDefault('Europe/Amsterdam'); }); afterAll(() => moment.tz.setDefault('')); - describe('getComparisonTypes', () => { - it('shows week and day before when 15 minutes is selected', () => { - expect( - getComparisonTypes({ - start: '2021-06-04T16:17:02.335Z', - end: '2021-06-04T16:32:02.335Z', - }) - ).toEqual([ - TimeRangeComparisonEnum.DayBefore.valueOf(), - TimeRangeComparisonEnum.WeekBefore.valueOf(), - ]); - }); - - it('shows week and day before when Today is selected', () => { - expect( - getComparisonTypes({ - start: '2021-06-04T04:00:00.000Z', - end: '2021-06-05T03:59:59.999Z', - }) - ).toEqual([ - TimeRangeComparisonEnum.DayBefore.valueOf(), - TimeRangeComparisonEnum.WeekBefore.valueOf(), - ]); - }); - - it('shows week and day before when 24 hours is selected', () => { - expect( - getComparisonTypes({ - start: '2021-06-03T16:31:35.748Z', - end: '2021-06-04T16:31:35.748Z', - }) - ).toEqual([ - TimeRangeComparisonEnum.DayBefore.valueOf(), - TimeRangeComparisonEnum.WeekBefore.valueOf(), - ]); - }); + const spy = jest.spyOn(urlHelpers, 'replace'); + beforeEach(() => { + jest.resetAllMocks(); + }); - it('shows week and day before when 24 hours is selected but milliseconds are different', () => { - expect( - getComparisonTypes({ - start: '2021-10-15T00:52:59.554Z', - end: '2021-10-14T00:52:59.553Z', - }) - ).toEqual([ - TimeRangeComparisonEnum.DayBefore.valueOf(), - TimeRangeComparisonEnum.WeekBefore.valueOf(), - ]); + describe('Time range is between 0 - 25 hours', () => { + it('sets default values', () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-06-04T16:17:02.335Z', + rangeTo: '2021-06-04T16:32:02.335Z', + }); + render(, { wrapper: Wrapper }); + expect(spy).toHaveBeenCalledWith(expect.anything(), { + query: { + comparisonEnabled: 'true', + comparisonType: TimeRangeComparisonEnum.DayBefore, + }, + }); }); - it('shows week before when 25 hours is selected', () => { + it('selects day before and enables comparison', () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-06-04T16:17:02.335Z', + rangeTo: '2021-06-04T16:32:02.335Z', + comparisonEnabled: true, + comparisonType: TimeRangeComparisonEnum.DayBefore, + }); + const component = render(, { wrapper: Wrapper }); + expectTextsInDocument(component, ['Day before', 'Week before']); expect( - getComparisonTypes({ - start: '2021-06-02T12:32:00.000Z', - end: '2021-06-03T13:32:09.079Z', - }) - ).toEqual([TimeRangeComparisonEnum.WeekBefore.valueOf()]); + (component.getByTestId('comparisonSelect') as HTMLSelectElement) + .selectedIndex + ).toEqual(0); }); - it('shows week before when 7 days is selected', () => { - expect( - getComparisonTypes({ - start: '2021-05-28T16:32:17.520Z', - end: '2021-06-04T16:32:17.520Z', - }) - ).toEqual([TimeRangeComparisonEnum.WeekBefore.valueOf()]); - }); - it('shows period before when 8 days is selected', () => { + it('enables day before option when date difference is equal to 24 hours', () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-06-03T16:31:35.748Z', + rangeTo: '2021-06-04T16:31:35.748Z', + comparisonEnabled: true, + comparisonType: TimeRangeComparisonEnum.DayBefore, + }); + const component = render(, { wrapper: Wrapper }); + expectTextsInDocument(component, ['Day before', 'Week before']); expect( - getComparisonTypes({ - start: '2021-05-27T16:32:46.747Z', - end: '2021-06-04T16:32:46.747Z', - }) - ).toEqual([TimeRangeComparisonEnum.PeriodBefore.valueOf()]); + (component.getByTestId('comparisonSelect') as HTMLSelectElement) + .selectedIndex + ).toEqual(0); }); }); - describe('getSelectOptions', () => { - it('returns formatted text based on comparison type', () => { - expect( - getSelectOptions({ - comparisonTypes: [ - TimeRangeComparisonEnum.DayBefore, - TimeRangeComparisonEnum.WeekBefore, - TimeRangeComparisonEnum.PeriodBefore, - ], - start: '2021-05-27T16:32:46.747Z', - end: '2021-06-04T16:32:46.747Z', - }) - ).toEqual([ - { - value: TimeRangeComparisonEnum.DayBefore.valueOf(), - text: 'Day before', - }, - { - value: TimeRangeComparisonEnum.WeekBefore.valueOf(), - text: 'Week before', - }, - { - value: TimeRangeComparisonEnum.PeriodBefore.valueOf(), - text: '19/05 18:32 - 27/05 18:32', - }, - ]); + describe('Time range is between 25 hours - 8 days', () => { + it("doesn't show day before option when date difference is greater than 25 hours", () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-06-02T12:32:00.000Z', + rangeTo: '2021-06-03T13:32:09.079Z', + comparisonEnabled: true, + comparisonType: TimeRangeComparisonEnum.WeekBefore, + }); + const component = render(, { + wrapper: Wrapper, + }); + expectTextsNotInDocument(component, ['Day before']); + expectTextsInDocument(component, ['Week before']); }); - it('formats period before as DD/MM/YY HH:mm when range years are different', () => { - expect( - getSelectOptions({ - comparisonTypes: [TimeRangeComparisonEnum.PeriodBefore], - start: '2020-05-27T16:32:46.747Z', - end: '2021-06-04T16:32:46.747Z', - }) - ).toEqual([ - { - value: TimeRangeComparisonEnum.PeriodBefore.valueOf(), - text: '20/05/19 18:32 - 27/05/20 18:32', + it('sets default values', () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-06-02T12:32:00.000Z', + rangeTo: '2021-06-03T13:32:09.079Z', + }); + render(, { + wrapper: Wrapper, + }); + expect(spy).toHaveBeenCalledWith(expect.anything(), { + query: { + comparisonEnabled: 'true', + comparisonType: TimeRangeComparisonEnum.WeekBefore, }, - ]); + }); }); - }); - describe('TimeComparison component', () => { - const spy = jest.spyOn(urlHelpers, 'replace'); - beforeEach(() => { - jest.resetAllMocks(); - }); - describe('Time range is between 0 - 24 hours', () => { - it('sets default values', () => { - const Wrapper = getWrapper({ - exactStart: '2021-06-04T16:17:02.335Z', - exactEnd: '2021-06-04T16:32:02.335Z', - }); - render(, { wrapper: Wrapper }); - expect(spy).toHaveBeenCalledWith(expect.anything(), { - query: { - comparisonEnabled: 'true', - comparisonType: TimeRangeComparisonEnum.DayBefore, - }, - }); + it('selects week before and enables comparison', () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-06-02T12:32:00.000Z', + rangeTo: '2021-06-03T13:32:09.079Z', + comparisonEnabled: true, + comparisonType: TimeRangeComparisonEnum.WeekBefore, }); - it('selects day before and enables comparison', () => { - const Wrapper = getWrapper({ - exactStart: '2021-06-04T16:17:02.335Z', - exactEnd: '2021-06-04T16:32:02.335Z', - comparisonEnabled: true, - comparisonType: TimeRangeComparisonEnum.DayBefore, - }); - const component = render(, { wrapper: Wrapper }); - expectTextsInDocument(component, ['Day before', 'Week before']); - expect( - (component.getByTestId('comparisonSelect') as HTMLSelectElement) - .selectedIndex - ).toEqual(0); - }); - - it('enables day before option when date difference is equal to 24 hours', () => { - const Wrapper = getWrapper({ - exactStart: '2021-06-03T16:31:35.748Z', - exactEnd: '2021-06-04T16:31:35.748Z', - comparisonEnabled: true, - comparisonType: TimeRangeComparisonEnum.DayBefore, - }); - const component = render(, { wrapper: Wrapper }); - expectTextsInDocument(component, ['Day before', 'Week before']); - expect( - (component.getByTestId('comparisonSelect') as HTMLSelectElement) - .selectedIndex - ).toEqual(0); + const component = render(, { + wrapper: Wrapper, }); + expectTextsNotInDocument(component, ['Day before']); + expectTextsInDocument(component, ['Week before']); + expect( + (component.getByTestId('comparisonSelect') as HTMLSelectElement) + .selectedIndex + ).toEqual(0); }); + }); - describe('Time range is between 24 hours - 1 week', () => { - it("doesn't show day before option when date difference is greater than 24 hours", () => { - const Wrapper = getWrapper({ - exactStart: '2021-06-02T12:32:00.000Z', - exactEnd: '2021-06-03T13:32:09.079Z', - comparisonEnabled: true, - comparisonType: TimeRangeComparisonEnum.WeekBefore, - }); - const component = render(, { - wrapper: Wrapper, - }); - expectTextsNotInDocument(component, ['Day before']); - expectTextsInDocument(component, ['Week before']); + describe('Time range is greater than 8 days', () => { + it('Shows absolute times without year when within the same year', () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-05-27T16:32:46.747Z', + rangeTo: '2021-06-04T16:32:46.747Z', + comparisonEnabled: true, + comparisonType: TimeRangeComparisonEnum.PeriodBefore, }); - it('sets default values', () => { - const Wrapper = getWrapper({ - exactStart: '2021-06-02T12:32:00.000Z', - exactEnd: '2021-06-03T13:32:09.079Z', - }); - render(, { - wrapper: Wrapper, - }); - expect(spy).toHaveBeenCalledWith(expect.anything(), { - query: { - comparisonEnabled: 'true', - comparisonType: TimeRangeComparisonEnum.WeekBefore, - }, - }); - }); - it('selects week before and enables comparison', () => { - const Wrapper = getWrapper({ - exactStart: '2021-06-02T12:32:00.000Z', - exactEnd: '2021-06-03T13:32:09.079Z', - comparisonEnabled: true, - comparisonType: TimeRangeComparisonEnum.WeekBefore, - }); - const component = render(, { - wrapper: Wrapper, - }); - expectTextsNotInDocument(component, ['Day before']); - expectTextsInDocument(component, ['Week before']); - expect( - (component.getByTestId('comparisonSelect') as HTMLSelectElement) - .selectedIndex - ).toEqual(0); + const component = render(, { + wrapper: Wrapper, }); + expect(spy).not.toHaveBeenCalled(); + expectTextsInDocument(component, ['19/05 18:32 - 27/05 18:32']); + expect( + (component.getByTestId('comparisonSelect') as HTMLSelectElement) + .selectedIndex + ).toEqual(0); }); - describe('Time range is greater than 7 days', () => { - it('Shows absolute times without year when within the same year', () => { - const Wrapper = getWrapper({ - exactStart: '2021-05-27T16:32:46.747Z', - exactEnd: '2021-06-04T16:32:46.747Z', - comparisonEnabled: true, - comparisonType: TimeRangeComparisonEnum.PeriodBefore, - }); - const component = render(, { - wrapper: Wrapper, - }); - expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['19/05 18:32 - 27/05 18:32']); - expect( - (component.getByTestId('comparisonSelect') as HTMLSelectElement) - .selectedIndex - ).toEqual(0); + it('Shows absolute times with year when on different year', () => { + const Wrapper = getWrapper({ + rangeFrom: '2020-05-27T16:32:46.747Z', + rangeTo: '2021-06-04T16:32:46.747Z', + comparisonEnabled: true, + comparisonType: TimeRangeComparisonEnum.PeriodBefore, }); - - it('Shows absolute times with year when on different year', () => { - const Wrapper = getWrapper({ - exactStart: '2020-05-27T16:32:46.747Z', - exactEnd: '2021-06-04T16:32:46.747Z', - comparisonEnabled: true, - comparisonType: TimeRangeComparisonEnum.PeriodBefore, - }); - const component = render(, { - wrapper: Wrapper, - }); - expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['20/05/19 18:32 - 27/05/20 18:32']); - expect( - (component.getByTestId('comparisonSelect') as HTMLSelectElement) - .selectedIndex - ).toEqual(0); + const component = render(, { + wrapper: Wrapper, }); + expect(spy).not.toHaveBeenCalled(); + expectTextsInDocument(component, ['20/05/19 18:32 - 27/05/20 18:32']); + expect( + (component.getByTestId('comparisonSelect') as HTMLSelectElement) + .selectedIndex + ).toEqual(0); }); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx index e61ffbbbc5ba..cb0bc870354c 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx @@ -7,12 +7,10 @@ import { EuiCheckbox, EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import moment from 'moment'; import React from 'react'; import { useHistory } from 'react-router-dom'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useUiTracker } from '../../../../../observability/public'; -import { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; @@ -20,8 +18,7 @@ import { useBreakpoints } from '../../../hooks/use_breakpoints'; import { useTimeRange } from '../../../hooks/use_time_range'; import * as urlHelpers from '../../shared/links/url_helpers'; import { getComparisonEnabled } from './get_comparison_enabled'; -import { getComparisonTypes } from './get_comparison_types'; -import { getTimeRangeComparison } from './get_time_range_comparison'; +import { getComparisonOptions } from './get_comparison_options'; const PrependContainer = euiStyled.div` display: flex; @@ -32,88 +29,6 @@ const PrependContainer = euiStyled.div` padding: 0 ${({ theme }) => theme.eui.paddingSizes.m}; `; -function getDateFormat({ - previousPeriodStart, - currentPeriodEnd, -}: { - previousPeriodStart?: string; - currentPeriodEnd?: string; -}) { - const momentPreviousPeriodStart = moment(previousPeriodStart); - const momentCurrentPeriodEnd = moment(currentPeriodEnd); - const isDifferentYears = - momentPreviousPeriodStart.get('year') !== - momentCurrentPeriodEnd.get('year'); - return isDifferentYears ? 'DD/MM/YY HH:mm' : 'DD/MM HH:mm'; -} - -function formatDate({ - dateFormat, - previousPeriodStart, - previousPeriodEnd, -}: { - dateFormat: string; - previousPeriodStart?: string; - previousPeriodEnd?: string; -}) { - const momentStart = moment(previousPeriodStart); - const momentEnd = moment(previousPeriodEnd); - return `${momentStart.format(dateFormat)} - ${momentEnd.format(dateFormat)}`; -} - -export function getSelectOptions({ - comparisonTypes, - start, - end, -}: { - comparisonTypes: TimeRangeComparisonEnum[]; - start?: string; - end?: string; -}) { - return comparisonTypes.map((value) => { - switch (value) { - case TimeRangeComparisonEnum.DayBefore: { - return { - value, - text: i18n.translate('xpack.apm.timeComparison.select.dayBefore', { - defaultMessage: 'Day before', - }), - }; - } - case TimeRangeComparisonEnum.WeekBefore: { - return { - value, - text: i18n.translate('xpack.apm.timeComparison.select.weekBefore', { - defaultMessage: 'Week before', - }), - }; - } - case TimeRangeComparisonEnum.PeriodBefore: { - const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonEnum.PeriodBefore, - start, - end, - comparisonEnabled: true, - }); - - const dateFormat = getDateFormat({ - previousPeriodStart: comparisonStart, - currentPeriodEnd: end, - }); - - return { - value, - text: formatDate({ - dateFormat, - previousPeriodStart: comparisonStart, - previousPeriodEnd: comparisonEnd, - }), - }; - } - } - }); -} - export function TimeComparison() { const { core } = useApmPluginContext(); const trackApmEvent = useUiTracker({ app: 'apm' }); @@ -123,19 +38,13 @@ export function TimeComparison() { query: { rangeFrom, rangeTo }, } = useAnyOfApmParams('/services', '/backends/*', '/services/{serviceName}'); - const { exactStart, exactEnd } = useTimeRange({ - rangeFrom, - rangeTo, - }); + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); const { urlParams: { comparisonEnabled, comparisonType }, } = useLegacyUrlParams(); - const comparisonTypes = getComparisonTypes({ - start: exactStart, - end: exactEnd, - }); + const comparisonOptions = getComparisonOptions({ start, end }); // Sets default values if (comparisonEnabled === undefined || comparisonType === undefined) { @@ -148,26 +57,22 @@ export function TimeComparison() { }) === false ? 'false' : 'true', - comparisonType: comparisonType ? comparisonType : comparisonTypes[0], + comparisonType: comparisonType + ? comparisonType + : comparisonOptions[0].value, }, }); return null; } - const selectOptions = getSelectOptions({ - comparisonTypes, - start: exactStart, - end: exactEnd, - }); - - const isSelectedComparisonTypeAvailable = selectOptions.some( + const isSelectedComparisonTypeAvailable = comparisonOptions.some( ({ value }) => value === comparisonType ); // Replaces type when current one is no longer available in the select options - if (selectOptions.length !== 0 && !isSelectedComparisonTypeAvailable) { + if (comparisonOptions.length !== 0 && !isSelectedComparisonTypeAvailable) { urlHelpers.replace(history, { - query: { comparisonType: selectOptions[0].value }, + query: { comparisonType: comparisonOptions[0].value }, }); return null; } @@ -177,7 +82,7 @@ export function TimeComparison() { fullWidth={isSmall} data-test-subj="comparisonSelect" disabled={!comparisonEnabled} - options={selectOptions} + options={comparisonOptions} value={comparisonType} prepend={ diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.test.tsx b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.test.tsx index 851472cfedab..f2919fc12cad 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.test.tsx @@ -375,8 +375,12 @@ describe('TransactionActionMenu component', () => { const getFilterKeyValue = (key: string) => { return { [(component.getAllByText(key)[0] as HTMLOptionElement).text]: ( - component.getAllByTestId(`${key}.value`)[0] as HTMLInputElement - ).value, + component + .getByTestId(`${key}.value`) + .querySelector( + '[data-test-subj="comboBoxInput"] span' + ) as HTMLSpanElement + ).textContent, }; }; expect(getFilterKeyValue('service.name')).toEqual({ diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index 4c1063173d92..bf6e2b70d390 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -15,7 +15,6 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCode } from '@elastic/eui'; import { APIReturnType } from '../../../services/rest/create_call_apm_api'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { TransactionOverviewLink } from '../links/apm/transaction_overview_link'; import { getTimeRangeComparison } from '../time_comparison/get_time_range_comparison'; @@ -24,6 +23,8 @@ import { getColumns } from './get_columns'; import { ElasticDocsLink } from '../links/elastic_docs_link'; import { useBreakpoints } from '../../../hooks/use_breakpoints'; import { ManagedTable } from '../managed_table'; +import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; type ApiResponse = APIReturnType<'GET /internal/apm/services/{serviceName}/transactions/groups/main_statistics'>; @@ -97,8 +98,11 @@ export function TransactionsTable({ const { transactionType, serviceName } = useApmServiceContext(); const { - urlParams: { latencyAggregationType, comparisonType, comparisonEnabled }, - } = useLegacyUrlParams(); + query: { comparisonEnabled, comparisonType, latencyAggregationType }, + } = useAnyOfApmParams( + '/services/{serviceName}/transactions', + '/services/{serviceName}/overview' + ); const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ start, @@ -123,7 +127,8 @@ export function TransactionsTable({ start, end, transactionType, - latencyAggregationType, + latencyAggregationType: + latencyAggregationType as LatencyAggregationType, }, }, } @@ -198,7 +203,8 @@ export function TransactionsTable({ end, numBuckets: 20, transactionType, - latencyAggregationType, + latencyAggregationType: + latencyAggregationType as LatencyAggregationType, transactionNames: JSON.stringify( transactionGroups.map(({ name }) => name).sort() ), @@ -218,7 +224,7 @@ export function TransactionsTable({ const columns = getColumns({ serviceName, - latencyAggregationType, + latencyAggregationType: latencyAggregationType as LatencyAggregationType, transactionGroupDetailedStatistics, comparisonEnabled, shouldShowSparkPlots, diff --git a/x-pack/plugins/apm/public/context/url_params_context/helpers.test.ts b/x-pack/plugins/apm/public/context/url_params_context/helpers.test.ts index 784b10b3f3ee..9ab0948fd75a 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/helpers.test.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/helpers.test.ts @@ -14,58 +14,6 @@ describe('url_params_context helpers', () => { jest.restoreAllMocks(); }); describe('getDateRange', () => { - describe('with non-rounded dates', () => { - describe('one minute', () => { - it('rounds the start value to minute', () => { - expect( - helpers.getDateRange({ - state: {}, - rangeFrom: '2021-01-28T05:47:52.134Z', - rangeTo: '2021-01-28T05:48:55.304Z', - }) - ).toEqual({ - start: '2021-01-28T05:47:00.000Z', - end: '2021-01-28T05:48:55.304Z', - exactStart: '2021-01-28T05:47:52.134Z', - exactEnd: '2021-01-28T05:48:55.304Z', - }); - }); - }); - describe('one day', () => { - it('rounds the start value to minute', () => { - expect( - helpers.getDateRange({ - state: {}, - rangeFrom: '2021-01-27T05:46:07.377Z', - rangeTo: '2021-01-28T05:46:13.367Z', - }) - ).toEqual({ - start: '2021-01-27T05:46:00.000Z', - end: '2021-01-28T05:46:13.367Z', - exactStart: '2021-01-27T05:46:07.377Z', - exactEnd: '2021-01-28T05:46:13.367Z', - }); - }); - }); - - describe('one year', () => { - it('rounds the start value to minute', () => { - expect( - helpers.getDateRange({ - state: {}, - rangeFrom: '2020-01-28T05:52:36.290Z', - rangeTo: '2021-01-28T05:52:39.741Z', - }) - ).toEqual({ - start: '2020-01-28T05:52:00.000Z', - end: '2021-01-28T05:52:39.741Z', - exactStart: '2020-01-28T05:52:36.290Z', - exactEnd: '2021-01-28T05:52:39.741Z', - }); - }); - }); - }); - describe('when rangeFrom and rangeTo are not changed', () => { it('returns the previous state', () => { expect( @@ -75,8 +23,6 @@ describe('url_params_context helpers', () => { rangeTo: 'now', start: '1970-01-01T00:00:00.000Z', end: '1971-01-01T00:00:00.000Z', - exactStart: '1970-01-01T00:00:00.000Z', - exactEnd: '1971-01-01T00:00:00.000Z', }, rangeFrom: 'now-1m', rangeTo: 'now', @@ -84,8 +30,6 @@ describe('url_params_context helpers', () => { ).toEqual({ start: '1970-01-01T00:00:00.000Z', end: '1971-01-01T00:00:00.000Z', - exactStart: '1970-01-01T00:00:00.000Z', - exactEnd: '1971-01-01T00:00:00.000Z', }); }); }); @@ -107,8 +51,6 @@ describe('url_params_context helpers', () => { ).toEqual({ start: '1972-01-01T00:00:00.000Z', end: '1973-01-01T00:00:00.000Z', - exactStart: undefined, - exactEnd: undefined, }); }); }); @@ -119,32 +61,27 @@ describe('url_params_context helpers', () => { jest .spyOn(datemath, 'parse') .mockReturnValueOnce(undefined) - .mockReturnValueOnce(endDate) - .mockReturnValueOnce(undefined) .mockReturnValueOnce(endDate); + expect( helpers.getDateRange({ state: { start: '1972-01-01T00:00:00.000Z', end: '1973-01-01T00:00:00.000Z', - exactStart: '1972-01-01T00:00:00.000Z', - exactEnd: '1973-01-01T00:00:00.000Z', }, rangeFrom: 'nope', rangeTo: 'now', }) ).toEqual({ start: '1972-01-01T00:00:00.000Z', - exactStart: '1972-01-01T00:00:00.000Z', end: '1973-01-01T00:00:00.000Z', - exactEnd: '1973-01-01T00:00:00.000Z', }); }); }); describe('when rangeFrom or rangeTo have changed', () => { it('returns new state', () => { - jest.spyOn(datemath, 'parse').mockReturnValue(moment(0).utc()); + jest.spyOn(Date, 'now').mockReturnValue(moment(0).unix()); expect( helpers.getDateRange({ @@ -158,40 +95,10 @@ describe('url_params_context helpers', () => { rangeTo: 'now', }) ).toEqual({ - start: '1970-01-01T00:00:00.000Z', + start: '1969-12-31T23:58:00.000Z', end: '1970-01-01T00:00:00.000Z', - exactStart: '1970-01-01T00:00:00.000Z', - exactEnd: '1970-01-01T00:00:00.000Z', }); }); }); }); - - describe('getExactDate', () => { - it('returns date when it is not not relative', () => { - expect(helpers.getExactDate('2021-01-28T05:47:52.134Z')).toEqual( - new Date('2021-01-28T05:47:52.134Z') - ); - }); - - ['s', 'm', 'h', 'd', 'w'].map((roundingOption) => - it(`removes /${roundingOption} rounding option from relative time`, () => { - const spy = jest.spyOn(datemath, 'parse'); - helpers.getExactDate(`now/${roundingOption}`); - expect(spy).toHaveBeenCalledWith('now', {}); - }) - ); - - it('removes rounding option but keeps subtracting time', () => { - const spy = jest.spyOn(datemath, 'parse'); - helpers.getExactDate('now-24h/h'); - expect(spy).toHaveBeenCalledWith('now-24h', {}); - }); - - it('removes rounding option but keeps adding time', () => { - const spy = jest.spyOn(datemath, 'parse'); - helpers.getExactDate('now+15m/h'); - expect(spy).toHaveBeenCalledWith('now+15m', {}); - }); - }); }); diff --git a/x-pack/plugins/apm/public/context/url_params_context/helpers.ts b/x-pack/plugins/apm/public/context/url_params_context/helpers.ts index ee6ac43c1aea..6856c0e7d250 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/helpers.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/helpers.ts @@ -6,8 +6,7 @@ */ import datemath from '@elastic/datemath'; -import { compact, pickBy } from 'lodash'; -import moment from 'moment'; +import { pickBy } from 'lodash'; import { UrlParams } from './types'; function getParsedDate(rawDate?: string, options = {}) { @@ -19,25 +18,12 @@ function getParsedDate(rawDate?: string, options = {}) { } } -export function getExactDate(rawDate: string) { - const isRelativeDate = rawDate.startsWith('now'); - if (isRelativeDate) { - // remove rounding from relative dates "Today" (now/d) and "This week" (now/w) - const rawDateWithouRounding = rawDate.replace(/\/([smhdw])$/, ''); - return getParsedDate(rawDateWithouRounding); - } - return getParsedDate(rawDate); -} - export function getDateRange({ state = {}, rangeFrom, rangeTo, }: { - state?: Pick< - UrlParams, - 'rangeFrom' | 'rangeTo' | 'start' | 'end' | 'exactStart' | 'exactEnd' - >; + state?: Pick; rangeFrom?: string; rangeTo?: string; }) { @@ -46,35 +32,23 @@ export function getDateRange({ return { start: state.start, end: state.end, - exactStart: state.exactStart, - exactEnd: state.exactEnd, }; } const start = getParsedDate(rangeFrom); const end = getParsedDate(rangeTo, { roundUp: true }); - const exactStart = rangeFrom ? getExactDate(rangeFrom) : undefined; - const exactEnd = rangeTo ? getExactDate(rangeTo) : undefined; - // `getParsedDate` will return undefined for invalid or empty dates. We return // the previous state if either date is undefined. if (!start || !end) { return { start: state.start, end: state.end, - exactStart: state.exactStart, - exactEnd: state.exactEnd, }; } - // rounds down start to minute - const roundedStart = moment(start).startOf('minute'); - return { - start: roundedStart.toISOString(), + start: start.toISOString(), end: end.toISOString(), - exactStart: exactStart?.toISOString(), - exactEnd: exactEnd?.toISOString(), }; } @@ -95,10 +69,6 @@ export function toBoolean(value?: string) { return value === 'true'; } -export function getPathAsArray(pathname: string = '') { - return compact(pathname.split('/')); -} - export function removeUndefinedProps(obj: T): Partial { return pickBy(obj, (value) => value !== undefined); } diff --git a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts index eb231741ad77..5bb3a46c3aea 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts @@ -19,10 +19,7 @@ import { } from './helpers'; import { UrlParams } from './types'; -type TimeUrlParams = Pick< - UrlParams, - 'start' | 'end' | 'rangeFrom' | 'rangeTo' | 'exactStart' | 'exactEnd' ->; +type TimeUrlParams = Pick; export function resolveUrlParams(location: Location, state: TimeUrlParams) { const query = toQuery(location.search); diff --git a/x-pack/plugins/apm/public/context/url_params_context/types.ts b/x-pack/plugins/apm/public/context/url_params_context/types.ts index aaad2fac2da2..6c0f10f78e7c 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/types.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/types.ts @@ -16,8 +16,6 @@ export interface UrlParams { environment?: string; rangeFrom?: string; rangeTo?: string; - exactStart?: string; - exactEnd?: string; refreshInterval?: number; refreshPaused?: boolean; sortDirection?: string; diff --git a/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx b/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx index a128db6c2cd7..80a99d1a4274 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx +++ b/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx @@ -34,8 +34,7 @@ const UrlParamsProvider: React.ComponentClass<{}> = withRouter( ({ location, children }) => { const refUrlParams = useRef(resolveUrlParams(location, {})); - const { start, end, rangeFrom, rangeTo, exactStart, exactEnd } = - refUrlParams.current; + const { start, end, rangeFrom, rangeTo } = refUrlParams.current; // Counter to force an update in useFetcher when the refresh button is clicked. const [rangeId, setRangeId] = useState(0); @@ -47,10 +46,8 @@ const UrlParamsProvider: React.ComponentClass<{}> = withRouter( end, rangeFrom, rangeTo, - exactStart, - exactEnd, }), - [location, start, end, rangeFrom, rangeTo, exactStart, exactEnd] + [location, start, end, rangeFrom, rangeTo] ); refUrlParams.current = urlParams; diff --git a/x-pack/plugins/apm/public/hooks/use_apm_params.ts b/x-pack/plugins/apm/public/hooks/use_apm_params.ts index 89aca1e4bf1a..2a835e7e6989 100644 --- a/x-pack/plugins/apm/public/hooks/use_apm_params.ts +++ b/x-pack/plugins/apm/public/hooks/use_apm_params.ts @@ -13,10 +13,9 @@ import { ApmRoutes } from '../components/routing/apm_route_config'; // union type that is created. export function useMaybeApmParams>( - path: TPath, - optional: true + path: TPath ): TypeOf | undefined { - return useParams(path, optional) as TypeOf | undefined; + return useParams(path, true) as TypeOf | undefined; } export function useApmParams>( diff --git a/x-pack/plugins/apm/public/hooks/use_comparison.ts b/x-pack/plugins/apm/public/hooks/use_comparison.ts deleted file mode 100644 index 93d0e31969c5..000000000000 --- a/x-pack/plugins/apm/public/hooks/use_comparison.ts +++ /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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - getComparisonChartTheme, - getTimeRangeComparison, -} from '../components/shared/time_comparison/get_time_range_comparison'; -import { useLegacyUrlParams } from '../context/url_params_context/use_url_params'; -import { useApmParams } from './use_apm_params'; -import { useTimeRange } from './use_time_range'; - -export function useComparison() { - const comparisonChartTheme = getComparisonChartTheme(); - const { query } = useApmParams('/*'); - - if (!('rangeFrom' in query && 'rangeTo' in query)) { - throw new Error('rangeFrom or rangeTo not defined in query'); - } - - const { start, end } = useTimeRange({ - rangeFrom: query.rangeFrom, - rangeTo: query.rangeTo, - }); - - const { - urlParams: { comparisonType, comparisonEnabled }, - } = useLegacyUrlParams(); - - const { offset } = getTimeRangeComparison({ - start, - end, - comparisonType, - comparisonEnabled, - }); - - return { - offset, - comparisonChartTheme, - }; -} diff --git a/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx b/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx index d843067b48e9..62e2ab92c4fc 100644 --- a/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx +++ b/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx @@ -31,7 +31,6 @@ export function useErrorGroupDistributionFetcher({ comparisonType, comparisonEnabled, }); - const { data, status } = useFetcher( (callApmApi) => { if (start && end) { diff --git a/x-pack/plugins/apm/public/hooks/use_time_range.ts b/x-pack/plugins/apm/public/hooks/use_time_range.ts index 79ca70130a44..26333062dfa3 100644 --- a/x-pack/plugins/apm/public/hooks/use_time_range.ts +++ b/x-pack/plugins/apm/public/hooks/use_time_range.ts @@ -12,14 +12,12 @@ import { getDateRange } from '../context/url_params_context/helpers'; interface TimeRange { start: string; end: string; - exactStart: string; - exactEnd: string; refreshTimeRange: () => void; timeRangeId: number; } type PartialTimeRange = Pick & - Pick, 'start' | 'end' | 'exactStart' | 'exactEnd'>; + Pick, 'start' | 'end'>; export function useTimeRange(range: { rangeFrom?: string; @@ -43,7 +41,7 @@ export function useTimeRange({ }): TimeRange | PartialTimeRange { const { incrementTimeRangeId, timeRangeId } = useTimeRangeId(); - const { start, end, exactStart, exactEnd } = useMemo(() => { + const { start, end } = useMemo(() => { return getDateRange({ state: {}, rangeFrom, @@ -52,15 +50,13 @@ export function useTimeRange({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [rangeFrom, rangeTo, timeRangeId]); - if ((!start || !end || !exactStart || !exactEnd) && !optional) { + if ((!start || !end) && !optional) { throw new Error('start and/or end were unexpectedly not set'); } return { start, end, - exactStart, - exactEnd, refreshTimeRange: incrementTimeRangeId, timeRangeId, }; diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts index 33bb9095665d..8dbc1f3a4750 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts @@ -23,16 +23,11 @@ export function useTransactionLatencyChartsFetcher({ }) { const { transactionType, serviceName } = useApmServiceContext(); const { - urlParams: { - transactionName, - latencyAggregationType, - comparisonType, - comparisonEnabled, - }, + urlParams: { transactionName, latencyAggregationType }, } = useLegacyUrlParams(); const { - query: { rangeFrom, rangeTo }, + query: { rangeFrom, rangeTo, comparisonType, comparisonEnabled }, } = useApmParams('/services/{serviceName}'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); @@ -43,7 +38,6 @@ export function useTransactionLatencyChartsFetcher({ comparisonType, comparisonEnabled, }); - const { data, error, status } = useFetcher( (callApmApi) => { if ( diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 75c3c290512d..1968f35791f4 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -54,6 +54,7 @@ import { getLazyAPMPolicyEditExtension } from './components/fleet_integration/la import { featureCatalogueEntry } from './feature_catalogue_entry'; import type { SecurityPluginStart } from '../../security/public'; import { SpacesPluginStart } from '../../spaces/public'; +import { enableServiceGroups } from '../../observability/public'; export type ApmPluginSetup = ReturnType; @@ -118,6 +119,11 @@ export class ApmPlugin implements Plugin { pluginSetupDeps.home.featureCatalogue.register(featureCatalogueEntry); } + const serviceGroupsEnabled = core.uiSettings.get( + enableServiceGroups, + false + ); + // register observability nav if user has access to plugin plugins.observability.navigation.registerSections( from(core.getStartServices()).pipe( @@ -129,7 +135,26 @@ export class ApmPlugin implements Plugin { label: 'APM', sortKey: 400, entries: [ - { label: servicesTitle, app: 'apm', path: '/services' }, + serviceGroupsEnabled + ? { + label: servicesTitle, + app: 'apm', + path: '/service-groups', + matchPath(currentPath: string) { + return [ + '/service-groups', + '/services', + '/service-map', + ].some((testPath) => + currentPath.startsWith(testPath) + ); + }, + } + : { + label: servicesTitle, + app: 'apm', + path: '/services', + }, { label: tracesTitle, app: 'apm', path: '/traces' }, { label: dependenciesTitle, @@ -149,7 +174,15 @@ export class ApmPlugin implements Plugin { } }, }, - { label: serviceMapTitle, app: 'apm', path: '/service-map' }, + ...(serviceGroupsEnabled + ? [] + : [ + { + label: serviceMapTitle, + app: 'apm', + path: '/service-map', + }, + ]), ], }, ]; @@ -230,7 +263,30 @@ export class ApmPlugin implements Plugin { icon: 'plugins/apm/public/icon.svg', category: DEFAULT_APP_CATEGORIES.observability, deepLinks: [ - { id: 'services', title: servicesTitle, path: '/services' }, + { + id: 'services', + title: servicesTitle, + // path: serviceGroupsEnabled ? '/service-groups' : '/services', + deepLinks: serviceGroupsEnabled + ? [ + { + id: 'service-groups-list', + title: 'Service groups', + path: '/service-groups', + }, + { + id: 'service-groups-services', + title: servicesTitle, + path: '/services', + }, + { + id: 'service-groups-service-map', + title: serviceMapTitle, + path: '/service-map', + }, + ] + : [], + }, { id: 'traces', title: tracesTitle, path: '/traces' }, { id: 'service-map', title: serviceMapTitle, path: '/service-map' }, { id: 'backends', title: dependenciesTitle, path: '/backends' }, diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 34a09fe57a05..2dda29019239 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -29,7 +29,12 @@ import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_ import { createApmAgentConfigurationIndex } from './routes/settings/agent_configuration/create_agent_config_index'; import { getApmIndices } from './routes/settings/apm_indices/get_apm_indices'; import { createApmCustomLinkIndex } from './routes/settings/custom_link/create_custom_link_index'; -import { apmIndices, apmTelemetry, apmServerSettings } from './saved_objects'; +import { + apmIndices, + apmTelemetry, + apmServerSettings, + apmServiceGroups, +} from './saved_objects'; import type { ApmPluginRequestHandlerContext, APMRouteHandlerResources, @@ -75,6 +80,7 @@ export class APMPlugin core.savedObjects.registerType(apmIndices); core.savedObjects.registerType(apmTelemetry); core.savedObjects.registerType(apmServerSettings); + core.savedObjects.registerType(apmServiceGroups); const currentConfig = this.initContext.config.get(); this.currentConfig = currentConfig; diff --git a/x-pack/plugins/apm/server/routes/agent_keys/invalidate_agent_key.ts b/x-pack/plugins/apm/server/routes/agent_keys/invalidate_agent_key.ts index 99c500871840..ec244f8e9bee 100644 --- a/x-pack/plugins/apm/server/routes/agent_keys/invalidate_agent_key.ts +++ b/x-pack/plugins/apm/server/routes/agent_keys/invalidate_agent_key.ts @@ -9,17 +9,16 @@ import { ApmPluginRequestHandlerContext } from '../typings'; export async function invalidateAgentKey({ context, id, + isAdmin, }: { context: ApmPluginRequestHandlerContext; id: string; + isAdmin: boolean; }) { const { invalidated_api_keys: invalidatedAgentKeys } = await context.core.elasticsearch.client.asCurrentUser.security.invalidateApiKey( { - body: { - ids: [id], - owner: true, - }, + body: { ids: [id], owner: !isAdmin }, } ); diff --git a/x-pack/plugins/apm/server/routes/agent_keys/route.ts b/x-pack/plugins/apm/server/routes/agent_keys/route.ts index 90ffbbf8a656..9b01153f7391 100644 --- a/x-pack/plugins/apm/server/routes/agent_keys/route.ts +++ b/x-pack/plugins/apm/server/routes/agent_keys/route.ts @@ -70,15 +70,29 @@ const invalidateAgentKeyRoute = createApmServerRoute({ body: t.type({ id: t.string }), }), handler: async (resources): Promise<{ invalidatedAgentKeys: string[] }> => { - const { context, params } = resources; - + const { + context, + params, + plugins: { security }, + } = resources; const { body: { id }, } = params; + if (!security) { + throw Boom.internal(SECURITY_REQUIRED_MESSAGE); + } + + const securityPluginStart = await security.start(); + const { isAdmin } = await getAgentKeysPrivileges({ + context, + securityPluginStart, + }); + const invalidatedKeys = await invalidateAgentKey({ context, id, + isAdmin, }); return invalidatedKeys; diff --git a/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts index 0af279b276d6..b0869b7abcbc 100644 --- a/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts +++ b/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts @@ -23,6 +23,7 @@ import { observabilityOverviewRouteRepository } from '../observability_overview/ import { rumRouteRepository } from '../rum_client/route'; import { fallbackToTransactionsRouteRepository } from '../fallback_to_transactions/route'; import { serviceRouteRepository } from '../services/route'; +import { serviceGroupRouteRepository } from '../service_groups/route'; import { serviceMapRouteRepository } from '../service_map/route'; import { serviceNodeRouteRepository } from '../service_nodes/route'; import { agentConfigurationRouteRepository } from '../settings/agent_configuration/route'; @@ -49,6 +50,7 @@ const getTypedGlobalApmServerRouteRepository = () => { ...serviceMapRouteRepository, ...serviceNodeRouteRepository, ...serviceRouteRepository, + ...serviceGroupRouteRepository, ...suggestionsRouteRepository, ...traceRouteRepository, ...transactionRouteRepository, diff --git a/x-pack/plugins/apm/server/routes/service_groups/delete_service_group.ts b/x-pack/plugins/apm/server/routes/service_groups/delete_service_group.ts new file mode 100644 index 000000000000..6ef5ada473a7 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/service_groups/delete_service_group.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 { SavedObjectsClientContract } from 'kibana/server'; +import { APM_SERVICE_GROUP_SAVED_OBJECT_TYPE } from '../../../common/service_groups'; + +interface Options { + savedObjectsClient: SavedObjectsClientContract; + serviceGroupId: string; +} +export async function deleteServiceGroup({ + savedObjectsClient, + serviceGroupId, +}: Options) { + return savedObjectsClient.delete( + APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, + serviceGroupId + ); +} diff --git a/x-pack/plugins/apm/server/routes/service_groups/get_service_group.ts b/x-pack/plugins/apm/server/routes/service_groups/get_service_group.ts new file mode 100644 index 000000000000..d9202e2548fc --- /dev/null +++ b/x-pack/plugins/apm/server/routes/service_groups/get_service_group.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import { + ServiceGroup, + SavedServiceGroup, + APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, +} from '../../../common/service_groups'; + +export async function getServiceGroup({ + savedObjectsClient, + serviceGroupId, +}: { + savedObjectsClient: SavedObjectsClientContract; + serviceGroupId: string; +}): Promise { + const { + id, + updated_at: updatedAt, + attributes, + } = await savedObjectsClient.get( + APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, + serviceGroupId + ); + return { + id, + updatedAt: updatedAt ? Date.parse(updatedAt) : 0, + ...attributes, + }; +} diff --git a/x-pack/plugins/apm/server/routes/service_groups/get_service_groups.ts b/x-pack/plugins/apm/server/routes/service_groups/get_service_groups.ts new file mode 100644 index 000000000000..2c4668ac9e91 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/service_groups/get_service_groups.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import { + ServiceGroup, + SavedServiceGroup, + APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, + MAX_NUMBER_OF_SERVICES_IN_GROUP, +} from '../../../common/service_groups'; + +export async function getServiceGroups({ + savedObjectsClient, +}: { + savedObjectsClient: SavedObjectsClientContract; +}): Promise { + const result = await savedObjectsClient.find({ + type: APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, + page: 1, + perPage: MAX_NUMBER_OF_SERVICES_IN_GROUP, + }); + return result.saved_objects.map( + ({ id, attributes, updated_at: upatedAt }) => ({ + id, + updatedAt: upatedAt ? Date.parse(upatedAt) : 0, + ...attributes, + }) + ); +} diff --git a/x-pack/plugins/apm/server/routes/service_groups/lookup_services.ts b/x-pack/plugins/apm/server/routes/service_groups/lookup_services.ts new file mode 100644 index 000000000000..d3fe63dde47e --- /dev/null +++ b/x-pack/plugins/apm/server/routes/service_groups/lookup_services.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; +import { + AGENT_NAME, + SERVICE_ENVIRONMENT, + SERVICE_NAME, +} from '../../../common/elasticsearch_fieldnames'; +import { kqlQuery, rangeQuery } from '../../../../observability/server'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { Setup } from '../../lib/helpers/setup_request'; +import { MAX_NUMBER_OF_SERVICES_IN_GROUP } from '../../../common/service_groups'; + +export async function lookupServices({ + setup, + kuery, + start, + end, +}: { + setup: Setup; + kuery: string; + start: number; + end: number; +}) { + const { apmEventClient } = setup; + + const response = await apmEventClient.search('lookup_services', { + apm: { + events: [ + ProcessorEvent.metric, + ProcessorEvent.transaction, + ProcessorEvent.span, + ProcessorEvent.error, + ], + }, + body: { + size: 0, + query: { + bool: { + filter: [...rangeQuery(start, end), ...kqlQuery(kuery)], + }, + }, + aggs: { + services: { + terms: { + field: SERVICE_NAME, + size: MAX_NUMBER_OF_SERVICES_IN_GROUP, + }, + aggs: { + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + }, + }, + latest: { + top_metrics: { + metrics: [{ field: AGENT_NAME } as const], + sort: { '@timestamp': 'desc' }, + }, + }, + }, + }, + }, + }, + }); + + return ( + response.aggregations?.services.buckets.map((bucket) => { + return { + serviceName: bucket.key as string, + environments: bucket.environments.buckets.map( + (envBucket) => envBucket.key as string + ), + agentName: bucket.latest.top[0].metrics[AGENT_NAME] as AgentName, + }; + }) ?? [] + ); +} diff --git a/x-pack/plugins/apm/server/routes/service_groups/route.ts b/x-pack/plugins/apm/server/routes/service_groups/route.ts new file mode 100644 index 000000000000..347c76b67644 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/service_groups/route.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { setupRequest } from '../../lib/helpers/setup_request'; +import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; +import { kueryRt, rangeRt } from '../default_api_types'; +import { getServiceGroups } from '../service_groups/get_service_groups'; +import { getServiceGroup } from '../service_groups/get_service_group'; +import { saveServiceGroup } from '../service_groups/save_service_group'; +import { deleteServiceGroup } from '../service_groups/delete_service_group'; +import { lookupServices } from '../service_groups/lookup_services'; +import { + ServiceGroup, + SavedServiceGroup, +} from '../../../common/service_groups'; + +const serviceGroupsRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/service-groups', + options: { + tags: ['access:apm'], + }, + handler: async ( + resources + ): Promise<{ serviceGroups: SavedServiceGroup[] }> => { + const { context } = resources; + const savedObjectsClient = context.core.savedObjects.client; + const serviceGroups = await getServiceGroups({ savedObjectsClient }); + return { serviceGroups }; + }, +}); + +const serviceGroupRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/service-group', + params: t.type({ + query: t.type({ + serviceGroup: t.string, + }), + }), + options: { + tags: ['access:apm'], + }, + handler: async (resources): Promise<{ serviceGroup: SavedServiceGroup }> => { + const { context, params } = resources; + const savedObjectsClient = context.core.savedObjects.client; + const serviceGroup = await getServiceGroup({ + savedObjectsClient, + serviceGroupId: params.query.serviceGroup, + }); + return { serviceGroup }; + }, +}); + +const serviceGroupSaveRoute = createApmServerRoute({ + endpoint: 'POST /internal/apm/service-group', + params: t.type({ + query: t.intersection([ + rangeRt, + t.partial({ + serviceGroupId: t.string, + }), + ]), + body: t.type({ + groupName: t.string, + kuery: t.string, + description: t.union([t.string, t.undefined]), + color: t.union([t.string, t.undefined]), + }), + }), + options: { tags: ['access:apm', 'access:apm_write'] }, + handler: async (resources): Promise => { + const { context, params } = resources; + const { start, end, serviceGroupId } = params.query; + const savedObjectsClient = context.core.savedObjects.client; + const setup = await setupRequest(resources); + const items = await lookupServices({ + setup, + kuery: params.body.kuery, + start, + end, + }); + const serviceNames = items.map(({ serviceName }): string => serviceName); + const serviceGroup: ServiceGroup = { + ...params.body, + serviceNames, + }; + await saveServiceGroup({ + savedObjectsClient, + serviceGroupId, + serviceGroup, + }); + }, +}); + +const serviceGroupDeleteRoute = createApmServerRoute({ + endpoint: 'DELETE /internal/apm/service-group', + params: t.type({ + query: t.type({ + serviceGroupId: t.string, + }), + }), + options: { tags: ['access:apm', 'access:apm_write'] }, + handler: async (resources): Promise => { + const { context, params } = resources; + const { serviceGroupId } = params.query; + const savedObjectsClient = context.core.savedObjects.client; + await deleteServiceGroup({ + savedObjectsClient, + serviceGroupId, + }); + }, +}); + +const serviceGroupServicesRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/service-group/services', + params: t.type({ + query: t.intersection([rangeRt, t.partial(kueryRt.props)]), + }), + options: { + tags: ['access:apm'], + }, + handler: async ( + resources + ): Promise<{ items: Awaited> }> => { + const { params } = resources; + const { kuery = '', start, end } = params.query; + const setup = await setupRequest(resources); + const items = await lookupServices({ + setup, + kuery, + start, + end, + }); + return { items }; + }, +}); + +export const serviceGroupRouteRepository = { + ...serviceGroupsRoute, + ...serviceGroupRoute, + ...serviceGroupSaveRoute, + ...serviceGroupDeleteRoute, + ...serviceGroupServicesRoute, +}; diff --git a/x-pack/plugins/apm/server/routes/service_groups/save_service_group.ts b/x-pack/plugins/apm/server/routes/service_groups/save_service_group.ts new file mode 100644 index 000000000000..5dda1cca11c5 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/service_groups/save_service_group.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 { SavedObjectsClientContract } from 'kibana/server'; +import { + APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, + ServiceGroup, +} from '../../../common/service_groups'; + +interface Options { + savedObjectsClient: SavedObjectsClientContract; + serviceGroupId?: string; + serviceGroup: ServiceGroup; +} +export async function saveServiceGroup({ + savedObjectsClient, + serviceGroupId, + serviceGroup, +}: Options) { + // update existing service group + if (serviceGroupId) { + return await savedObjectsClient.update( + APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, + serviceGroupId, + serviceGroup + ); + } + + // create new saved object + return await savedObjectsClient.create( + APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, + serviceGroup + ); +} diff --git a/x-pack/plugins/apm/server/routes/service_map/get_service_map.ts b/x-pack/plugins/apm/server/routes/service_map/get_service_map.ts index 51703428bf50..837be066133b 100644 --- a/x-pack/plugins/apm/server/routes/service_map/get_service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map/get_service_map.ts @@ -8,7 +8,7 @@ import { Logger } from 'kibana/server'; import { chunk } from 'lodash'; import { ProcessorEvent } from '../../../common/processor_event'; -import { rangeQuery, termQuery } from '../../../../observability/server'; +import { rangeQuery, termsQuery } from '../../../../observability/server'; import { AGENT_NAME, SERVICE_ENVIRONMENT, @@ -29,7 +29,7 @@ import { getProcessorEventForTransactions } from '../../lib/helpers/transactions export interface IEnvOptions { setup: Setup; - serviceName?: string; + serviceNames?: string[]; environment: string; searchAggregatedTransactions: boolean; logger: Logger; @@ -39,7 +39,7 @@ export interface IEnvOptions { async function getConnectionData({ setup, - serviceName, + serviceNames, environment, start, end, @@ -47,7 +47,7 @@ async function getConnectionData({ return withApmSpan('get_service_map_connections', async () => { const { traceIds } = await getTraceSampleIds({ setup, - serviceName, + serviceNames, environment, start, end, @@ -109,7 +109,7 @@ async function getServicesData(options: IEnvOptions) { filter: [ ...rangeQuery(start, end), ...environmentQuery(environment), - ...termQuery(SERVICE_NAME, options.serviceName), + ...termsQuery(SERVICE_NAME, ...(options.serviceNames ?? [])), ], }, }, diff --git a/x-pack/plugins/apm/server/routes/service_map/get_trace_sample_ids.ts b/x-pack/plugins/apm/server/routes/service_map/get_trace_sample_ids.ts index af24953833a2..6f6c6a440152 100644 --- a/x-pack/plugins/apm/server/routes/service_map/get_trace_sample_ids.ts +++ b/x-pack/plugins/apm/server/routes/service_map/get_trace_sample_ids.ts @@ -23,13 +23,13 @@ import { Setup } from '../../lib/helpers/setup_request'; const MAX_TRACES_TO_INSPECT = 1000; export async function getTraceSampleIds({ - serviceName, + serviceNames, environment, setup, start, end, }: { - serviceName?: string; + serviceNames?: string[]; environment: string; setup: Setup; start: number; @@ -45,8 +45,12 @@ export async function getTraceSampleIds({ let events: ProcessorEvent[]; - if (serviceName) { - query.bool.filter.push({ term: { [SERVICE_NAME]: serviceName } }); + const hasServiceNamesFilter = (serviceNames?.length ?? 0) > 0; + + if (hasServiceNamesFilter) { + query.bool.filter.push({ + terms: { [SERVICE_NAME]: serviceNames as string[] }, + }); events = [ProcessorEvent.span, ProcessorEvent.transaction]; } else { events = [ProcessorEvent.span]; @@ -59,10 +63,10 @@ export async function getTraceSampleIds({ query.bool.filter.push(...environmentQuery(environment)); - const fingerprintBucketSize = serviceName + const fingerprintBucketSize = hasServiceNamesFilter ? config.serviceMapFingerprintBucketSize : config.serviceMapFingerprintGlobalBucketSize; - const traceIdBucketSize = serviceName + const traceIdBucketSize = hasServiceNamesFilter ? config.serviceMapTraceIdBucketSize : config.serviceMapTraceIdGlobalBucketSize; const samplerShardSize = traceIdBucketSize * 10; diff --git a/x-pack/plugins/apm/server/routes/service_map/route.ts b/x-pack/plugins/apm/server/routes/service_map/route.ts index e3a6bb91441a..b00a0da8e55b 100644 --- a/x-pack/plugins/apm/server/routes/service_map/route.ts +++ b/x-pack/plugins/apm/server/routes/service_map/route.ts @@ -17,6 +17,7 @@ import { getServiceMapBackendNodeInfo } from './get_service_map_backend_node_inf import { getServiceMapServiceNodeInfo } from './get_service_map_service_node_info'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { environmentRt, offsetRt, rangeRt } from '../default_api_types'; +import { getServiceGroup } from '../service_groups/get_service_group'; const serviceMapRoute = createApmServerRoute({ endpoint: 'GET /internal/apm/service-map', @@ -24,6 +25,7 @@ const serviceMapRoute = createApmServerRoute({ query: t.intersection([ t.partial({ serviceName: t.string, + serviceGroup: t.string, }), environmentRt, rangeRt, @@ -94,11 +96,32 @@ const serviceMapRoute = createApmServerRoute({ featureName: 'serviceMaps', }); - const setup = await setupRequest(resources); const { - query: { serviceName, environment, start, end }, + query: { + serviceName, + serviceGroup: serviceGroupId, + environment, + start, + end, + }, } = params; + const savedObjectsClient = context.core.savedObjects.client; + const [setup, serviceGroup] = await Promise.all([ + setupRequest(resources), + serviceGroupId + ? getServiceGroup({ + savedObjectsClient, + serviceGroupId, + }) + : Promise.resolve(null), + ]); + + const serviceNames = [ + ...(serviceName ? [serviceName] : []), + ...(serviceGroup?.serviceNames ?? []), + ]; + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ apmEventClient: setup.apmEventClient, config: setup.config, @@ -108,7 +131,7 @@ const serviceMapRoute = createApmServerRoute({ }); return getServiceMap({ setup, - serviceName, + serviceNames, environment, searchAggregatedTransactions, logger, diff --git a/x-pack/plugins/apm/server/routes/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/routes/services/__snapshots__/queries.test.ts.snap index 00ec21b4ef3d..4014f2a4a2ac 100644 --- a/x-pack/plugins/apm/server/routes/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/routes/services/__snapshots__/queries.test.ts.snap @@ -124,7 +124,7 @@ Array [ }, "terms": Object { "field": "service.name", - "size": 500, + "size": 50, }, }, }, @@ -177,7 +177,7 @@ Array [ }, "terms": Object { "field": "service.name", - "size": 500, + "size": 50, }, }, }, diff --git a/x-pack/plugins/apm/server/routes/services/get_services/get_health_statuses.ts b/x-pack/plugins/apm/server/routes/services/get_services/get_health_statuses.ts index 65fb04b821ff..4e8795aacc22 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services/get_health_statuses.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services/get_health_statuses.ts @@ -6,14 +6,16 @@ */ import { getSeverity } from '../../../../common/anomaly_detection'; -import { getServiceHealthStatus } from '../../../../common/service_health_status'; +import { + getServiceHealthStatus, + ServiceHealthStatus, +} from '../../../../common/service_health_status'; import { getServiceAnomalies } from '../../../routes/service_map/get_service_anomalies'; import { ServicesItemsSetup } from './get_services_items'; interface AggregationParams { environment: string; setup: ServicesItemsSetup; - searchAggregatedTransactions: boolean; start: number; end: number; } @@ -23,7 +25,9 @@ export const getHealthStatuses = async ({ setup, start, end, -}: AggregationParams) => { +}: AggregationParams): Promise< + Array<{ serviceName: string; healthStatus: ServiceHealthStatus }> +> => { if (!setup.ml) { return []; } diff --git a/x-pack/plugins/apm/server/routes/services/get_services/get_service_transaction_stats.ts b/x-pack/plugins/apm/server/routes/services/get_services/get_service_transaction_stats.ts index 9576c018c1c2..10f235063f9c 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services/get_service_transaction_stats.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services/get_service_transaction_stats.ts @@ -29,6 +29,8 @@ import { getOutcomeAggregation, } from '../../../lib/helpers/transaction_error_rate'; import { ServicesItemsSetup } from './get_services_items'; +import { serviceGroupQuery } from '../../../../common/utils/service_group_query'; +import { ServiceGroup } from '../../../../common/service_groups'; interface AggregationParams { environment: string; @@ -38,6 +40,7 @@ interface AggregationParams { maxNumServices: number; start: number; end: number; + serviceGroup: ServiceGroup | null; } export async function getServiceTransactionStats({ @@ -48,6 +51,7 @@ export async function getServiceTransactionStats({ maxNumServices, start, end, + serviceGroup, }: AggregationParams) { const { apmEventClient } = setup; @@ -81,6 +85,7 @@ export async function getServiceTransactionStats({ ...rangeQuery(start, end), ...environmentQuery(environment), ...kqlQuery(kuery), + ...serviceGroupQuery(serviceGroup), ], }, }, diff --git a/x-pack/plugins/apm/server/routes/services/get_services/get_services_from_error_and_metric_documents.ts b/x-pack/plugins/apm/server/routes/services/get_services/get_services_from_error_and_metric_documents.ts index f57c02c6be80..e70813308084 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services/get_services_from_error_and_metric_documents.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services/get_services_from_error_and_metric_documents.ts @@ -15,6 +15,8 @@ import { kqlQuery, rangeQuery } from '../../../../../observability/server'; import { environmentQuery } from '../../../../common/utils/environment_query'; import { ProcessorEvent } from '../../../../common/processor_event'; import { Setup } from '../../../lib/helpers/setup_request'; +import { serviceGroupQuery } from '../../../../common/utils/service_group_query'; +import { ServiceGroup } from '../../../../common/service_groups'; export async function getServicesFromErrorAndMetricDocuments({ environment, @@ -23,6 +25,7 @@ export async function getServicesFromErrorAndMetricDocuments({ kuery, start, end, + serviceGroup, }: { setup: Setup; environment: string; @@ -30,6 +33,7 @@ export async function getServicesFromErrorAndMetricDocuments({ kuery: string; start: number; end: number; + serviceGroup: ServiceGroup | null; }) { const { apmEventClient } = setup; @@ -47,6 +51,7 @@ export async function getServicesFromErrorAndMetricDocuments({ ...rangeQuery(start, end), ...environmentQuery(environment), ...kqlQuery(kuery), + ...serviceGroupQuery(serviceGroup), ], }, }, diff --git a/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts b/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts index 716fd82aefd4..1235af756b76 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts @@ -12,10 +12,11 @@ import { getHealthStatuses } from './get_health_statuses'; import { getServicesFromErrorAndMetricDocuments } from './get_services_from_error_and_metric_documents'; import { getServiceTransactionStats } from './get_service_transaction_stats'; import { mergeServiceStats } from './merge_service_stats'; +import { ServiceGroup } from '../../../../common/service_groups'; export type ServicesItemsSetup = Setup; -const MAX_NUMBER_OF_SERVICES = 500; +const MAX_NUMBER_OF_SERVICES = 50; export async function getServicesItems({ environment, @@ -25,6 +26,7 @@ export async function getServicesItems({ logger, start, end, + serviceGroup, }: { environment: string; kuery: string; @@ -33,6 +35,7 @@ export async function getServicesItems({ logger: Logger; start: number; end: number; + serviceGroup: ServiceGroup | null; }) { return withApmSpan('get_services_items', async () => { const params = { @@ -43,6 +46,7 @@ export async function getServicesItems({ maxNumServices: MAX_NUMBER_OF_SERVICES, start, end, + serviceGroup, }; const [ diff --git a/x-pack/plugins/apm/server/routes/services/get_services/get_sorted_and_filtered_services.ts b/x-pack/plugins/apm/server/routes/services/get_services/get_sorted_and_filtered_services.ts new file mode 100644 index 000000000000..4fd1974687c0 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/services/get_services/get_sorted_and_filtered_services.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from '@kbn/logging'; +import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; +import { Environment } from '../../../../common/environment_rt'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { joinByKey } from '../../../../common/utils/join_by_key'; +import { ServiceGroup } from '../../../../common/service_groups'; +import { Setup } from '../../../lib/helpers/setup_request'; +import { getHealthStatuses } from './get_health_statuses'; + +export async function getSortedAndFilteredServices({ + setup, + start, + end, + environment, + logger, + serviceGroup, +}: { + setup: Setup; + start: number; + end: number; + environment: Environment; + logger: Logger; + serviceGroup: ServiceGroup | null; +}) { + const { apmEventClient } = setup; + + async function getServiceNamesFromTermsEnum() { + if (environment !== ENVIRONMENT_ALL.value) { + return []; + } + const response = await apmEventClient.termsEnum( + 'get_services_from_terms_enum', + { + apm: { + events: [ + ProcessorEvent.transaction, + ProcessorEvent.span, + ProcessorEvent.metric, + ProcessorEvent.error, + ], + }, + body: { + size: 500, + field: SERVICE_NAME, + }, + } + ); + + return response.terms; + } + + const [servicesWithHealthStatuses, selectedServices] = await Promise.all([ + getHealthStatuses({ + setup, + start, + end, + environment, + }).catch((error) => { + logger.error(error); + return []; + }), + serviceGroup + ? getServiceNamesFromServiceGroup(serviceGroup) + : getServiceNamesFromTermsEnum(), + ]); + + const services = joinByKey( + [ + ...servicesWithHealthStatuses, + ...selectedServices.map((serviceName) => ({ serviceName })), + ], + 'serviceName' + ); + + return services; +} + +async function getServiceNamesFromServiceGroup(serviceGroup: ServiceGroup) { + return serviceGroup.serviceNames; +} diff --git a/x-pack/plugins/apm/server/routes/services/get_services/index.ts b/x-pack/plugins/apm/server/routes/services/get_services/index.ts index f46e53736c3e..223c0b3f613e 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services/index.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services/index.ts @@ -9,6 +9,7 @@ import { Logger } from '@kbn/logging'; import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup } from '../../../lib/helpers/setup_request'; import { getServicesItems } from './get_services_items'; +import { ServiceGroup } from '../../../../common/service_groups'; export async function getServices({ environment, @@ -18,6 +19,7 @@ export async function getServices({ logger, start, end, + serviceGroup, }: { environment: string; kuery: string; @@ -26,6 +28,7 @@ export async function getServices({ logger: Logger; start: number; end: number; + serviceGroup: ServiceGroup | null; }) { return withApmSpan('get_services', async () => { const items = await getServicesItems({ @@ -36,6 +39,7 @@ export async function getServices({ logger, start, end, + serviceGroup, }); return { diff --git a/x-pack/plugins/apm/server/routes/services/queries.test.ts b/x-pack/plugins/apm/server/routes/services/queries.test.ts index c6e1a971c823..9b2b54a9a11b 100644 --- a/x-pack/plugins/apm/server/routes/services/queries.test.ts +++ b/x-pack/plugins/apm/server/routes/services/queries.test.ts @@ -59,6 +59,7 @@ describe('services queries', () => { kuery: '', start: 0, end: 50000, + serviceGroup: null, }) ); diff --git a/x-pack/plugins/apm/server/routes/services/route.ts b/x-pack/plugins/apm/server/routes/services/route.ts index 55f7b4f14b7b..807f20ebccb2 100644 --- a/x-pack/plugins/apm/server/routes/services/route.ts +++ b/x-pack/plugins/apm/server/routes/services/route.ts @@ -52,11 +52,19 @@ import { ML_ERRORS } from '../../../common/anomaly_detection'; import { ScopedAnnotationsClient } from '../../../../observability/server'; import { Annotation } from './../../../../observability/common/annotations'; import { ConnectionStatsItemWithImpact } from './../../../common/connections'; +import { getSortedAndFilteredServices } from './get_services/get_sorted_and_filtered_services'; +import { ServiceHealthStatus } from './../../../common/service_health_status'; +import { getServiceGroup } from '../service_groups/get_service_group'; const servicesRoute = createApmServerRoute({ endpoint: 'GET /internal/apm/services', params: t.type({ - query: t.intersection([environmentRt, kueryRt, rangeRt]), + query: t.intersection([ + environmentRt, + kueryRt, + rangeRt, + t.partial({ serviceGroup: t.string }), + ]), }), options: { tags: ['access:apm'] }, async handler(resources): Promise<{ @@ -97,16 +105,28 @@ const servicesRoute = createApmServerRoute({ } >; }> { - const setup = await setupRequest(resources); - const { params, logger } = resources; - const { environment, kuery, start, end } = params.query; + const { context, params, logger } = resources; + const { + environment, + kuery, + start, + end, + serviceGroup: serviceGroupId, + } = params.query; + const savedObjectsClient = context.core.savedObjects.client; + + const [setup, serviceGroup] = await Promise.all([ + setupRequest(resources), + serviceGroupId + ? getServiceGroup({ savedObjectsClient, serviceGroupId }) + : Promise.resolve(null), + ]); const searchAggregatedTransactions = await getSearchAggregatedTransactions({ ...setup, kuery, start, end, }); - return getServices({ environment, kuery, @@ -115,6 +135,7 @@ const servicesRoute = createApmServerRoute({ logger, start, end, + serviceGroup, }); }, }); @@ -1224,6 +1245,58 @@ const serviceAnomalyChartsRoute = createApmServerRoute({ }, }); +const sortedAndFilteredServicesRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/sorted_and_filtered_services', + options: { + tags: ['access:apm'], + }, + params: t.type({ + query: t.intersection([ + rangeRt, + environmentRt, + kueryRt, + t.partial({ serviceGroup: t.string }), + ]), + }), + handler: async ( + resources + ): Promise<{ + services: Array<{ + serviceName: string; + healthStatus?: ServiceHealthStatus; + }>; + }> => { + const { + query: { start, end, environment, kuery, serviceGroup: serviceGroupId }, + } = resources.params; + + if (kuery) { + return { + services: [], + }; + } + + const savedObjectsClient = resources.context.core.savedObjects.client; + + const [setup, serviceGroup] = await Promise.all([ + setupRequest(resources), + serviceGroupId + ? getServiceGroup({ savedObjectsClient, serviceGroupId }) + : Promise.resolve(null), + ]); + return { + services: await getSortedAndFilteredServices({ + setup, + start, + end, + environment, + logger: resources.logger, + serviceGroup, + }), + }; + }, +}); + export const serviceRouteRepository = { ...servicesRoute, ...servicesDetailedStatisticsRoute, @@ -1245,4 +1318,5 @@ export const serviceRouteRepository = { ...serviceAlertsRoute, ...serviceInfrastructureRoute, ...serviceAnomalyChartsRoute, + ...sortedAndFilteredServicesRoute, }; diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/routes/settings/agent_configuration/__snapshots__/queries.test.ts.snap index b6b4f2208d04..6009dd3ad7b9 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration/__snapshots__/queries.test.ts.snap @@ -148,31 +148,6 @@ Object { } `; -exports[`agent configuration queries getServiceNames fetches service names 1`] = ` -Object { - "apm": Object { - "events": Array [ - "transaction", - "error", - "metric", - ], - }, - "body": Object { - "aggs": Object { - "services": Object { - "terms": Object { - "field": "service.name", - "min_doc_count": 0, - "size": 50, - }, - }, - }, - "size": 0, - "timeout": "1ms", - }, -} -`; - exports[`agent configuration queries listConfigurations fetches configurations 1`] = ` Object { "index": "myIndex", diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration/get_service_names.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration/get_service_names.ts deleted file mode 100644 index 18e359c5b942..000000000000 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration/get_service_names.ts +++ /dev/null @@ -1,57 +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 { ProcessorEvent } from '../../../../common/processor_event'; -import { Setup } from '../../../lib/helpers/setup_request'; -import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; -import { ALL_OPTION_VALUE } from '../../../../common/agent_configuration/all_option'; -import { getProcessorEventForTransactions } from '../../../lib/helpers/transactions'; - -export async function getServiceNames({ - setup, - searchAggregatedTransactions, - size, -}: { - setup: Setup; - searchAggregatedTransactions: boolean; - size: number; -}) { - const { apmEventClient } = setup; - - const params = { - apm: { - events: [ - getProcessorEventForTransactions(searchAggregatedTransactions), - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - timeout: '1ms', - size: 0, - aggs: { - services: { - terms: { - field: SERVICE_NAME, - min_doc_count: 0, - size, - }, - }, - }, - }, - }; - - const resp = await apmEventClient.search( - 'get_service_names_for_agent_config', - params - ); - const serviceNames = - resp.aggregations?.services.buckets - .map((bucket) => bucket.key as string) - .sort() || []; - return [ALL_OPTION_VALUE, ...serviceNames]; -} diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration/queries.test.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration/queries.test.ts index 4ffc8ed98184..49a97c1ca4f7 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration/queries.test.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration/queries.test.ts @@ -6,7 +6,6 @@ */ import { getExistingEnvironmentsForService } from './get_environments/get_existing_environments_for_service'; -import { getServiceNames } from './get_service_names'; import { listConfigurations } from './list_configurations'; import { searchConfigurations } from './search_configurations'; import { @@ -52,20 +51,6 @@ describe('agent configuration queries', () => { }); }); - describe('getServiceNames', () => { - it('fetches service names', async () => { - mock = await inspectSearchParams((setup) => - getServiceNames({ - setup, - searchAggregatedTransactions: false, - size: 50, - }) - ); - - expect(mock.params).toMatchSnapshot(); - }); - }); - describe('listConfigurations', () => { it('fetches configurations', async () => { mock = await inspectSearchParams((setup) => diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration/route.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration/route.ts index 53a55cc1b99b..f2cfbe857ba4 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration/route.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration/route.ts @@ -10,7 +10,6 @@ import Boom from '@hapi/boom'; import { toBooleanRt } from '@kbn/io-ts-utils'; import { maxSuggestions } from '../../../../../observability/common'; import { setupRequest } from '../../../lib/helpers/setup_request'; -import { getServiceNames } from './get_service_names'; import { createOrUpdateConfiguration } from './create_or_update_configuration'; import { searchConfigurations } from './search_configurations'; import { findExactConfiguration } from './find_exact_configuration'; @@ -256,33 +255,6 @@ const agentConfigurationSearchRoute = createApmServerRoute({ * Utility endpoints (not documented as part of the public API) */ -// get list of services -const listAgentConfigurationServicesRoute = createApmServerRoute({ - endpoint: 'GET /api/apm/settings/agent-configuration/services', - options: { tags: ['access:apm'] }, - handler: async (resources): Promise<{ serviceNames: string[] }> => { - const setup = await setupRequest(resources); - const { start, end } = resources.params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions({ - apmEventClient: setup.apmEventClient, - config: setup.config, - kuery: '', - start, - end, - }); - const size = await resources.context.core.uiSettings.client.get( - maxSuggestions - ); - const serviceNames = await getServiceNames({ - searchAggregatedTransactions, - setup, - size, - }); - - return { serviceNames }; - }, -}); - // get environments for service const listAgentConfigurationEnvironmentsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration/environments', @@ -342,7 +314,6 @@ export const agentConfigurationRouteRepository = { ...deleteAgentConfigurationRoute, ...createOrUpdateAgentConfigurationRoute, ...agentConfigurationSearchRoute, - ...listAgentConfigurationServicesRoute, ...listAgentConfigurationEnvironmentsRoute, ...agentConfigurationAgentNameRoute, }; diff --git a/x-pack/plugins/apm/server/saved_objects/apm_service_groups.ts b/x-pack/plugins/apm/server/saved_objects/apm_service_groups.ts new file mode 100644 index 000000000000..114386d630c9 --- /dev/null +++ b/x-pack/plugins/apm/server/saved_objects/apm_service_groups.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsType } from 'src/core/server'; +import { i18n } from '@kbn/i18n'; +import { APM_SERVICE_GROUP_SAVED_OBJECT_TYPE } from '../../common/service_groups'; + +export const apmServiceGroups: SavedObjectsType = { + name: APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'multiple', + mappings: { + properties: { + groupName: { type: 'keyword' }, + kuery: { type: 'text' }, + description: { type: 'text' }, + serviceNames: { type: 'keyword' }, + color: { type: 'text' }, + }, + }, + management: { + importableAndExportable: false, + icon: 'apmApp', + getTitle: () => + i18n.translate('xpack.apm.apmServiceGroups.index', { + defaultMessage: 'APM Service Groups - Index', + }), + }, +}; diff --git a/x-pack/plugins/apm/server/saved_objects/index.ts b/x-pack/plugins/apm/server/saved_objects/index.ts index ba4285a23896..048db493cb2f 100644 --- a/x-pack/plugins/apm/server/saved_objects/index.ts +++ b/x-pack/plugins/apm/server/saved_objects/index.ts @@ -8,3 +8,4 @@ export { apmIndices } from './apm_indices'; export { apmTelemetry } from './apm_telemetry'; export { apmServerSettings } from './apm_server_settings'; +export { apmServiceGroups } from './apm_service_groups'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/vis_dimension.tsx b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/vis_dimension.tsx index 94831be2e003..5b9764160b11 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/vis_dimension.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/vis_dimension.tsx @@ -53,27 +53,30 @@ const VisDimensionArgInput: React.FC = ({ ], }; - onChangeFn(astObj); + onChangeFn(typeof value === 'string' ? ev.target.value : astObj); }, - [confirm, onValueChange] + [confirm, onValueChange, value] ); const options = [ { value: '', text: strings.getDefaultOptionName(), disabled: true }, - ...columns.map((column: DatatableColumn) => ({ value: column.name, text: column.name })), + ...columns.map((column: DatatableColumn) => ({ value: column.id, text: column.name })), ]; - const selectedValue = value.chain[0].arguments._?.[0]; + const selectedValue = + typeof value === 'string' + ? value + : value.chain[0].arguments._?.[0] ?? value.chain[0].arguments.accessor?.[0]; - const column = - columns - .map((col: DatatableColumn) => col.name) - .find((colName: string) => colName === selectedValue) || ''; + const columnId = + typeof selectedValue === 'number' + ? columns[selectedValue]?.id || '' + : columns.find(({ id }) => id === selectedValue)?.id || ''; return ( - + {confirm && ( diff --git a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx index db0b8a680e86..7ba216358596 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx @@ -169,13 +169,13 @@ export const WorkpadHeader: FC = ({ {{ primaryActionButton: , quickButtonGroup: , - addFromLibraryButton: ( + extraButtons: [ - ), - extraButtons: [], + />, + , + ], }} diff --git a/x-pack/plugins/cases/README.md b/x-pack/plugins/cases/README.md index 2895d7d37666..908428673c30 100644 --- a/x-pack/plugins/cases/README.md +++ b/x-pack/plugins/cases/README.md @@ -18,7 +18,6 @@ This plugin provides cases management in Kibana - [Cases API](#cases-api) - [Cases Client API](#cases-client-api) - [Cases UI](#cases-ui) -- [Case Action Type](#case-action-type) _feature in development, disabled by default_ ## Cases API @@ -30,7 +29,7 @@ This plugin provides cases management in Kibana ## Cases UI -#### Embed Cases UI components in any Kibana plugin +### Embed Cases UI components in any Kibana plugin - Add `CasesUiStart` to Kibana plugin `StartServices` dependencies: @@ -38,9 +37,51 @@ This plugin provides cases management in Kibana cases: CasesUiStart; ``` -#### Cases UI Methods +### CasesContext setup -- From the UI component, get the component from the `useKibana` hook start services +To use any of the Cases UI hooks you must first initialize `CasesContext` in your plugin. + +Without a `CasesContext` the hooks won't work and won't be able to render. + +`CasesContext` works a bridge between your plugin and the Cases UI. It effectively renders +the Cases UI. + +To initialize the `CasesContext` you can use this code: + + +```ts +// somewhere high on your plugin render tree + + {/* or something similar */} + +``` + +props: + +| prop | type | description | +| --------------------- | --------------- | -------------------------------------------------------------- | +| PLUGIN_CASES_OWNER_ID | `string` | The owner string for your plugin. e.g: securitySolution | +| CASES_USER_CAN_CRUD | `boolean` | Defines if the user has access to cases to CRUD | +| CASES_FEATURES | `CasesFeatures` | `CasesFeatures` object defining the features to enable/disable | + + +### Cases UI client + +The cases UI client exports the following contract: + +| Property | Description | Type | +| -------- | -------------------------------- | ------ | +| api | Methods related to the Cases API | object | +| ui | Cases UI components | object | +| hooks | Cases React hooks | object | +| helpers | Cases helpers | object | + + +You can get the cases UI client from the `useKibana` hook start services. Example: ```tsx const { cases } = useKibana().services; @@ -63,55 +104,84 @@ cases.getCases({ }); ``` -##### Methods: +### api -### `getCases` +#### `getRelatedCases` + +Returns all cases where the alert is attached to. + +Arguments + +| Property | Description | Type | +| -------- | ------------ | ------ | +| alertId | The alert ID | string | +| query | The alert ID | object | + +`query` + +| Property | Description | Type | +| -------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| owner | The type of cases to retrieve given an alert ID. If no owner is provided, all cases that the user has access to will be returned. | string \| string[] \| undefined | + + +Response + +An array of: + +| Property | Description | Type | +| -------- | --------------------- | ------ | +| id | The ID of the case | string | +| title | The title of the case | string | + +### ui + +#### `getCases` Arguments: -| Property | Description | -| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| userCanCrud | `boolean;` user permissions to crud | -| owner | `string[];` owner ids of the cases | -| basePath | `string;` path to mount the Cases router on top of | -| useFetchAlertData | `(alertIds: string[]) => [boolean, Record];` fetch alerts | -| disableAlerts? | `boolean` (default: false) flag to not show alerts information | -| actionsNavigation? | CasesNavigation | -| ruleDetailsNavigation? | CasesNavigation | -| onComponentInitialized? | `() => void;` callback when component has initialized | -| showAlertDetails? | `(alertId: string, index: string) => void;` callback to show alert details | -| features? | `CasesFeatures` object defining the features to enable/disable | -| features?.alerts.sync | `boolean` (default: `true`) defines wether the alert sync action should be enabled/disabled | +| Property | Description | +| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| userCanCrud | `boolean;` user permissions to crud | +| owner | `string[];` owner ids of the cases | +| basePath | `string;` path to mount the Cases router on top of | +| useFetchAlertData | `(alertIds: string[]) => [boolean, Record];` fetch alerts | +| disableAlerts? | `boolean` (default: false) flag to not show alerts information | +| actionsNavigation? | CasesNavigation | +| ruleDetailsNavigation? | CasesNavigation | +| onComponentInitialized? | `() => void;` callback when component has initialized | +| showAlertDetails? | `(alertId: string, index: string) => void;` callback to show alert details | +| features? | `CasesFeatures` object defining the features to enable/disable | +| features?.alerts.sync | `boolean` (default: `true`) defines wether the alert sync action should be enabled/disabled | | features?.metrics | `string[]` (default: `[]`) defines the metrics to show in the Case Detail View. Allowed metrics: "alerts.count", "alerts.users", "alerts.hosts", "connectors", "lifespan". | -| timelineIntegration?.editor_plugins | Plugins needed for integrating timeline into markdown editor. | -| timelineIntegration?.editor_plugins.parsingPlugin | `Plugin;` | -| timelineIntegration?.editor_plugins.processingPluginRenderer | `React.FC` | -| timelineIntegration?.editor_plugins.uiPlugin? | `EuiMarkdownEditorUiPlugin` | -| timelineIntegration?.hooks.useInsertTimeline | `(value: string, onChange: (newValue: string) => void): UseInsertTimelineReturn` | -| timelineIntegration?.ui?.renderInvestigateInTimelineActionComponent? | `(alertIds: string[]) => JSX.Element;` space to render `InvestigateInTimelineActionComponent` | -| timelineIntegration?.ui?renderTimelineDetailsPanel? | `() => JSX.Element;` space to render `TimelineDetailsPanel` | +| timelineIntegration?.editor_plugins | Plugins needed for integrating timeline into markdown editor. | +| timelineIntegration?.editor_plugins.parsingPlugin | `Plugin;` | +| timelineIntegration?.editor_plugins.processingPluginRenderer | `React.FC` | +| timelineIntegration?.editor_plugins.uiPlugin? | `EuiMarkdownEditorUiPlugin` | +| timelineIntegration?.hooks.useInsertTimeline | `(value: string, onChange: (newValue: string) => void): UseInsertTimelineReturn` | +| timelineIntegration?.ui?.renderInvestigateInTimelineActionComponent? | `(alertIds: string[]) => JSX.Element;` space to render `InvestigateInTimelineActionComponent` | +| timelineIntegration?.ui?renderTimelineDetailsPanel? | `() => JSX.Element;` space to render `TimelineDetailsPanel` | UI component: ![All Cases Component][all-cases-img] -### `getAllCasesSelectorModal` +#### `getAllCasesSelectorModal` Arguments: -| Property | Description | -| --------------- | ------------------------------------------------------------------------------------------------- | -| userCanCrud | `boolean;` user permissions to crud | -| owner | `string[];` owner ids of the cases | -| alertData? | `Omit;` alert data to post to case | -| hiddenStatuses? | `CaseStatuses[];` array of hidden statuses | +| Property | Description | +| --------------- | ---------------------------------------------------------------------------------- | +| userCanCrud | `boolean;` user permissions to crud | +| owner | `string[];` owner ids of the cases | +| alertData? | `Omit;` alert data to post to case | +| hiddenStatuses? | `CaseStatuses[];` array of hidden statuses | | onRowClick | (theCase?: Case) => void; callback for row click, passing case in row | | updateCase? | (theCase: Case) => void; callback after case has been updated | -| onClose? | `() => void` called when the modal is closed without selecting a case | +| onClose? | `() => void` called when the modal is closed without selecting a case | UI component: ![All Cases Selector Modal Component][all-cases-modal-img] -### `getCreateCaseFlyout` +#### `getCreateCaseFlyout` Arguments: @@ -127,7 +197,7 @@ Arguments: UI component: ![Create Component][create-img] -### `getRecentCases` +#### `getRecentCases` Arguments: @@ -140,12 +210,72 @@ Arguments: UI component: ![Recent Cases Component][recent-cases-img] +### hooks + + +#### getUseCasesAddToNewCaseFlyout + +Returns an object containing two methods: `open` and `close` to either open or close the add to new case flyout. +You can use this hook to prompt the user to create a new case with some attachments directly attached to it. e.g.: alerts or text comments. + + +Arguments: + +| Property | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------ | +| userCanCrud | `boolean;` user permissions to crud | +| onClose | `() => void;` callback when create case is canceled | +| onSuccess | `(theCase: Case) => Promise;` callback passing newly created case after pushCaseToExternalService is called | +| afterCaseCreated? | `(theCase: Case) => Promise;` callback passing newly created case before pushCaseToExternalService is called | +| attachments? | `CaseAttachments`; array of `SupportedCaseAttachment` (see types) that will be attached to the newly created case | + +returns: an object with `open` and `close` methods to open or close the flyout. + +`open()` and `close()` don't take any arguments. They will open or close the flyout at will. + +#### `getUseCasesAddToExistingCaseModal` + +Returns an object containing two methods: `open` and `close` to either open or close the case selector modal. + +You can use this hook to prompt the user to select a case and get the selected case. You can also pass attachments directly and have them attached to the selected case after selection. e.g.: alerts or text comments. + + +Arguments: + +| Property | Description | +| ------------ | ----------------------------------------------------------------------------------------------------------------- | +| onRowClick | (theCase?: Case) => void; callback for row click, passing case in row | +| updateCase? | (theCase: Case) => void; callback after case has been updated | +| onClose? | `() => void` called when the modal is closed without selecting a case | +| attachments? | `CaseAttachments`; array of `SupportedCaseAttachment` (see types) that will be attached to the newly created case | + +### helpers + +#### canUseCases + +Returns the Cases capabilities for the current user. Specifically: + +| Property | Description | Type | +| -------- | -------------------------------------------- | ------- | +| crud | Denotes if the user has all access to Cases | boolean | +| read? | Denotes if the user has read access to Cases | boolean | + +#### getRuleIdFromEvent + +Returns an object with a rule `id` and `name` of the event passed. This helper method is necessary to bridge the gap between previous events schema and new ones. + +Arguments: + +| property | Description | Type | +| -------- | -------------------------------------------------------------------------------------------- | ------ | +| event | Event containing an `ecs` attribute with ecs data and a `data` attribute with `nonEcs` data. | object | + -[pr-shield]: https://img.shields.io/github/issues-pr/elastic/kibana/Team:Threat%20Hunting:Cases?label=pull%20requests&style=for-the-badge -[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc+label%3A%22Team%3AThreat+Hunting%3ACases%22 -[issues-shield]: https://img.shields.io/github/issues-search?label=issue&query=repo%3Aelastic%2Fkibana%20is%3Aissue%20is%3Aopen%20label%3A%22Team%3AThreat%20Hunting%3ACases%22&style=for-the-badge +[pr-shield]: https://img.shields.io/github/issues-pr/elastic/kibana/Feature:Cases?label=pull%20requests&style=for-the-badge +[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc+label%3A%22Feature%3ACases%22 +[issues-shield]: https://img.shields.io/github/issues-search?label=issue&query=repo%3Aelastic%2Fkibana%20is%3Aissue%20is%3Aopen%20label%3A%22Feature%3ACases%22&style=for-the-badge [issues-url]: https://github.com/elastic/kibana/issues?q=is%3Aopen+is%3Aissue+label%3AFeature%3ACases [cases-logo]: images/logo.png [configure-img]: images/configure.png diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index c57b40dbcf00..0f3fd6345a67 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -7,16 +7,33 @@ import { ConnectorTypes } from './api'; import { CasesContextFeatures } from './ui/types'; -export const DEFAULT_DATE_FORMAT = 'dateFormat'; -export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz'; +export const DEFAULT_DATE_FORMAT = 'dateFormat' as const; +export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz' as const; -export const APP_ID = 'cases'; +/** + * Application + */ + +export const APP_ID = 'cases' as const; +export const FEATURE_ID = 'generalCases' as const; +export const APP_OWNER = 'cases' as const; +export const APP_PATH = '/app/management/insightsAndAlerting/cases' as const; +/** + * The main Cases application is in the stack management under the + * Alerts and Insights section. To do that, Cases registers to the management + * application. This constant holds the application ID of the management plugin + */ +export const STACK_APP_ID = 'management' as const; + +/** + * Saved objects + */ -export const CASE_SAVED_OBJECT = 'cases'; -export const CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT = 'cases-connector-mappings'; -export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions'; -export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments'; -export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure'; +export const CASE_SAVED_OBJECT = 'cases' as const; +export const CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT = 'cases-connector-mappings' as const; +export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions' as const; +export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments' as const; +export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure' as const; /** * If more values are added here please also add them here: x-pack/test/cases_api_integration/common/fixtures/plugins @@ -33,32 +50,32 @@ export const SAVED_OBJECT_TYPES = [ * Case routes */ -export const CASES_URL = '/api/cases'; -export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}`; -export const CASE_CONFIGURE_URL = `${CASES_URL}/configure`; -export const CASE_CONFIGURE_DETAILS_URL = `${CASES_URL}/configure/{configuration_id}`; -export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; +export const CASES_URL = '/api/cases' as const; +export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}` as const; +export const CASE_CONFIGURE_URL = `${CASES_URL}/configure` as const; +export const CASE_CONFIGURE_DETAILS_URL = `${CASES_URL}/configure/{configuration_id}` as const; +export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors` as const; -export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`; -export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`; -export const CASE_PUSH_URL = `${CASE_DETAILS_URL}/connector/{connector_id}/_push`; -export const CASE_REPORTERS_URL = `${CASES_URL}/reporters`; -export const CASE_STATUS_URL = `${CASES_URL}/status`; -export const CASE_TAGS_URL = `${CASES_URL}/tags`; -export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions`; +export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments` as const; +export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}` as const; +export const CASE_PUSH_URL = `${CASE_DETAILS_URL}/connector/{connector_id}/_push` as const; +export const CASE_REPORTERS_URL = `${CASES_URL}/reporters` as const; +export const CASE_STATUS_URL = `${CASES_URL}/status` as const; +export const CASE_TAGS_URL = `${CASES_URL}/tags` as const; +export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions` as const; -export const CASE_ALERTS_URL = `${CASES_URL}/alerts/{alert_id}`; -export const CASE_DETAILS_ALERTS_URL = `${CASE_DETAILS_URL}/alerts`; +export const CASE_ALERTS_URL = `${CASES_URL}/alerts/{alert_id}` as const; +export const CASE_DETAILS_ALERTS_URL = `${CASE_DETAILS_URL}/alerts` as const; -export const CASE_METRICS_DETAILS_URL = `${CASES_URL}/metrics/{case_id}`; +export const CASE_METRICS_DETAILS_URL = `${CASES_URL}/metrics/{case_id}` as const; /** * Action routes */ -export const ACTION_URL = '/api/actions'; -export const ACTION_TYPES_URL = `${ACTION_URL}/connector_types`; -export const CONNECTORS_URL = `${ACTION_URL}/connectors`; +export const ACTION_URL = '/api/actions' as const; +export const ACTION_TYPES_URL = `${ACTION_URL}/connector_types` as const; +export const CONNECTORS_URL = `${ACTION_URL}/connectors` as const; export const SUPPORTED_CONNECTORS = [ `${ConnectorTypes.serviceNowITSM}`, @@ -71,10 +88,10 @@ export const SUPPORTED_CONNECTORS = [ /** * Alerts */ -export const MAX_ALERTS_PER_CASE = 5000; +export const MAX_ALERTS_PER_CASE = 5000 as const; -export const SECURITY_SOLUTION_OWNER = 'securitySolution'; -export const OBSERVABILITY_OWNER = 'observability'; +export const SECURITY_SOLUTION_OWNER = 'securitySolution' as const; +export const OBSERVABILITY_OWNER = 'observability' as const; export const OWNER_INFO = { [SECURITY_SOLUTION_OWNER]: { @@ -85,16 +102,16 @@ export const OWNER_INFO = { label: 'Observability', iconType: 'logoObservability', }, -}; +} as const; -export const MAX_DOCS_PER_PAGE = 10000; -export const MAX_CONCURRENT_SEARCHES = 10; +export const MAX_DOCS_PER_PAGE = 10000 as const; +export const MAX_CONCURRENT_SEARCHES = 10 as const; /** * Validation */ -export const MAX_TITLE_LENGTH = 64; +export const MAX_TITLE_LENGTH = 64 as const; /** * Cases features diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 95135f4a0e9a..8abc0805b8a3 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -185,7 +185,7 @@ export interface RuleEcs { id?: string[]; rule_id?: string[]; name?: string[]; - false_positives: string[]; + false_positives?: string[]; saved_id?: string[]; timeline_id?: string[]; timeline_title?: string[]; diff --git a/x-pack/plugins/cases/kibana.json b/x-pack/plugins/cases/kibana.json index 170ac2a96aaa..c96372b57593 100644 --- a/x-pack/plugins/cases/kibana.json +++ b/x-pack/plugins/cases/kibana.json @@ -10,8 +10,10 @@ "id":"cases", "kibanaVersion":"kibana", "optionalPlugins":[ + "home", "security", "spaces", + "features", "usageCollection" ], "owner":{ @@ -20,14 +22,18 @@ }, "requiredPlugins":[ "actions", + "data", + "embeddable", "esUiShared", "lens", "features", "kibanaReact", "kibanaUtils", - "triggersActionsUi" + "triggersActionsUi", + "management" ], "requiredBundles": [ + "home", "savedObjects" ], "server":true, diff --git a/x-pack/plugins/cases/public/application.tsx b/x-pack/plugins/cases/public/application.tsx new file mode 100644 index 000000000000..a528fa2376db --- /dev/null +++ b/x-pack/plugins/cases/public/application.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router } from 'react-router-dom'; +import { I18nProvider } from '@kbn/i18n-react'; +import { EuiErrorBoundary } from '@elastic/eui'; + +import { + KibanaContextProvider, + KibanaThemeProvider, + useUiSetting$, +} from '../../../../src/plugins/kibana_react/public'; +import { EuiThemeProvider as StyledComponentsThemeProvider } from '../../../../src/plugins/kibana_react/common'; +import { RenderAppProps } from './types'; +import { CasesApp } from './components/app'; + +export const renderApp = (deps: RenderAppProps) => { + const { mountParams } = deps; + const { element } = mountParams; + + ReactDOM.render(, element); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; + +const CasesAppWithContext = () => { + const [darkMode] = useUiSetting$('theme:darkMode'); + + return ( + + + + ); +}; + +CasesAppWithContext.displayName = 'CasesAppWithContext'; + +export const App: React.FC<{ deps: RenderAppProps }> = ({ deps }) => { + const { mountParams, coreStart, pluginsStart, storage, kibanaVersion } = deps; + const { history, theme$ } = mountParams; + + return ( + + + + + + + + + + + + ); +}; + +App.displayName = 'App'; diff --git a/x-pack/plugins/cases/public/client/api/index.test.ts b/x-pack/plugins/cases/public/client/api/index.test.ts new file mode 100644 index 000000000000..6e52649d1e68 --- /dev/null +++ b/x-pack/plugins/cases/public/client/api/index.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; +import { createClientAPI } from '.'; + +describe('createClientAPI', () => { + const http = httpServiceMock.createStartContract({ basePath: '' }); + const api = createClientAPI({ http }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getRelatedCases', () => { + const res = [ + { + id: 'test-id', + title: 'test', + }, + ]; + http.get.mockResolvedValue(res); + + it('should return the correct response', async () => { + expect(await api.getRelatedCases('alert-id', { owner: 'test' })).toEqual(res); + }); + + it('should have been called with the correct path', async () => { + await api.getRelatedCases('alert-id', { owner: 'test' }); + expect(http.get).toHaveBeenCalledWith('/api/cases/alerts/alert-id', { + query: { owner: 'test' }, + }); + }); + + it('should accept an empty object with no owner', async () => { + await api.getRelatedCases('alert-id', {}); + expect(http.get).toHaveBeenCalledWith('/api/cases/alerts/alert-id', { + query: {}, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/client/api/index.ts b/x-pack/plugins/cases/public/client/api/index.ts new file mode 100644 index 000000000000..0f9881264927 --- /dev/null +++ b/x-pack/plugins/cases/public/client/api/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpStart } from 'kibana/public'; +import { CasesByAlertId, CasesByAlertIDRequest, getCasesFromAlertsUrl } from '../../../common/api'; +import { CasesUiStart } from '../../types'; + +export const createClientAPI = ({ http }: { http: HttpStart }): CasesUiStart['api'] => { + return { + getRelatedCases: async ( + alertId: string, + query: CasesByAlertIDRequest + ): Promise => + http.get(getCasesFromAlertsUrl(alertId), { query }), + }; +}; diff --git a/x-pack/plugins/cases/public/methods/can_use_cases.test.ts b/x-pack/plugins/cases/public/client/helpers/can_use_cases.test.ts similarity index 100% rename from x-pack/plugins/cases/public/methods/can_use_cases.test.ts rename to x-pack/plugins/cases/public/client/helpers/can_use_cases.test.ts diff --git a/x-pack/plugins/cases/public/methods/can_use_cases.ts b/x-pack/plugins/cases/public/client/helpers/can_use_cases.ts similarity index 97% rename from x-pack/plugins/cases/public/methods/can_use_cases.ts rename to x-pack/plugins/cases/public/client/helpers/can_use_cases.ts index d0b83241963d..ad05be1b07cd 100644 --- a/x-pack/plugins/cases/public/methods/can_use_cases.ts +++ b/x-pack/plugins/cases/public/client/helpers/can_use_cases.ts @@ -6,7 +6,7 @@ */ import type { ApplicationStart } from 'kibana/public'; -import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../common/constants'; +import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; export type CasesOwners = typeof SECURITY_SOLUTION_OWNER | typeof OBSERVABILITY_OWNER; diff --git a/x-pack/plugins/cases/public/client/helpers/get_rule_id_from_event.ts b/x-pack/plugins/cases/public/client/helpers/get_rule_id_from_event.ts new file mode 100644 index 000000000000..0f8cbfe68e1f --- /dev/null +++ b/x-pack/plugins/cases/public/client/helpers/get_rule_id_from_event.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 { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; +import { get } from 'lodash/fp'; +import { Ecs } from '../../../common'; + +type Maybe = T | null; +interface Event { + data: EventNonEcsData[]; + ecs: Ecs; +} +interface EventNonEcsData { + field: string; + value?: Maybe; +} + +export function getRuleIdFromEvent(event: Event): { + id: string; + name: string; +} { + const ruleUuidData = event && event.data.find(({ field }) => field === ALERT_RULE_UUID); + const ruleNameData = event && event.data.find(({ field }) => field === ALERT_RULE_NAME); + const ruleUuidValueData = ruleUuidData && ruleUuidData.value && ruleUuidData.value[0]; + const ruleNameValueData = ruleNameData && ruleNameData.value && ruleNameData.value[0]; + + const ruleUuid = + ruleUuidValueData ?? + get(`ecs.${ALERT_RULE_UUID}[0]`, event) ?? + get(`ecs.signal.rule.id[0]`, event) ?? + null; + const ruleName = + ruleNameValueData ?? + get(`ecs.${ALERT_RULE_NAME}[0]`, event) ?? + get(`ecs.signal.rule.name[0]`, event) ?? + null; + + return { + id: ruleUuid, + name: ruleName, + }; +} diff --git a/x-pack/plugins/cases/public/methods/get_all_cases_selector_modal.tsx b/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx similarity index 87% rename from x-pack/plugins/cases/public/methods/get_all_cases_selector_modal.tsx rename to x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx index 447725cab27b..18821d24e305 100644 --- a/x-pack/plugins/cases/public/methods/get_all_cases_selector_modal.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx @@ -7,13 +7,13 @@ import React, { lazy, Suspense } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; -import { AllCasesSelectorModalProps } from '../components/all_cases/selector_modal'; -import { CasesProvider, CasesContextProps } from '../components/cases_context'; +import { AllCasesSelectorModalProps } from '../../components/all_cases/selector_modal'; +import { CasesProvider, CasesContextProps } from '../../components/cases_context'; export type GetAllCasesSelectorModalProps = AllCasesSelectorModalProps & CasesContextProps; const AllCasesSelectorModalLazy: React.FC = lazy( - () => import('../components/all_cases/selector_modal') + () => import('../../components/all_cases/selector_modal') ); export const getAllCasesSelectorModalLazy = ({ owner, diff --git a/x-pack/plugins/cases/public/methods/get_cases.tsx b/x-pack/plugins/cases/public/client/ui/get_cases.tsx similarity index 82% rename from x-pack/plugins/cases/public/methods/get_cases.tsx rename to x-pack/plugins/cases/public/client/ui/get_cases.tsx index 3c1d3294d38c..be8ae538da97 100644 --- a/x-pack/plugins/cases/public/methods/get_cases.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_cases.tsx @@ -7,12 +7,13 @@ import { EuiLoadingSpinner } from '@elastic/eui'; import React, { lazy, Suspense } from 'react'; -import type { CasesProps } from '../components/app'; -import { CasesProvider, CasesContextProps } from '../components/cases_context'; +import type { CasesProps } from '../../components/app'; +import { CasesProvider, CasesContextProps } from '../../components/cases_context'; export type GetCasesProps = CasesProps & CasesContextProps; -const CasesLazy: React.FC = lazy(() => import('../components/app')); +const CasesRoutesLazy: React.FC = lazy(() => import('../../components/app/routes')); + export const getCasesLazy = ({ owner, userCanCrud, @@ -29,7 +30,7 @@ export const getCasesLazy = ({ }: GetCasesProps) => ( }> - = lazy( - () => import('../components/cases_context') + () => import('../../components/cases_context') ); const CasesProviderLazyWrapper = ({ diff --git a/x-pack/plugins/cases/public/methods/get_create_case_flyout.tsx b/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx similarity index 85% rename from x-pack/plugins/cases/public/methods/get_create_case_flyout.tsx rename to x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx index a0453c8fbb47..7747e8091cdb 100644 --- a/x-pack/plugins/cases/public/methods/get_create_case_flyout.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx @@ -7,13 +7,13 @@ import React, { lazy, Suspense } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; -import type { CreateCaseFlyoutProps } from '../components/create/flyout'; -import { CasesProvider, CasesContextProps } from '../components/cases_context'; +import type { CreateCaseFlyoutProps } from '../../components/create/flyout'; +import { CasesProvider, CasesContextProps } from '../../components/cases_context'; export type GetCreateCaseFlyoutProps = CreateCaseFlyoutProps & CasesContextProps; export const CreateCaseFlyoutLazy: React.FC = lazy( - () => import('../components/create/flyout') + () => import('../../components/create/flyout') ); export const getCreateCaseFlyoutLazy = ({ owner, diff --git a/x-pack/plugins/cases/public/methods/get_recent_cases.tsx b/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx similarity index 79% rename from x-pack/plugins/cases/public/methods/get_recent_cases.tsx rename to x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx index 32ad7932eeeb..52e09ef0a68e 100644 --- a/x-pack/plugins/cases/public/methods/get_recent_cases.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx @@ -7,13 +7,13 @@ import { EuiLoadingSpinner } from '@elastic/eui'; import React, { lazy, Suspense } from 'react'; -import { CasesProvider, CasesContextProps } from '../components/cases_context'; -import { RecentCasesProps } from '../components/recent_cases'; +import { CasesProvider, CasesContextProps } from '../../components/cases_context'; +import { RecentCasesProps } from '../../components/recent_cases'; export type GetRecentCasesProps = RecentCasesProps & CasesContextProps; const RecentCasesLazy: React.FC = lazy( - () => import('../components/recent_cases') + () => import('../../components/recent_cases') ); export const getRecentCasesLazy = ({ owner, userCanCrud, maxCasesToShow }: GetRecentCasesProps) => ( diff --git a/x-pack/plugins/cases/public/common/hooks.test.tsx b/x-pack/plugins/cases/public/common/hooks.test.tsx new file mode 100644 index 000000000000..f122d3312a64 --- /dev/null +++ b/x-pack/plugins/cases/public/common/hooks.test.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 React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { TestProviders } from '../common/mock'; +import { useIsMainApplication } from './hooks'; +import { useApplication } from '../components/cases_context/use_application'; + +jest.mock('../components/cases_context/use_application'); + +const useApplicationMock = useApplication as jest.Mock; + +describe('hooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + useApplicationMock.mockReturnValue({ appId: 'management', appTitle: 'Management' }); + }); + + describe('useIsMainApplication', () => { + it('returns true if it is the main application', () => { + const { result } = renderHook(() => useIsMainApplication(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toBe(true); + }); + + it('returns false if it is not the main application', () => { + useApplicationMock.mockReturnValue({ appId: 'testAppId', appTitle: 'Test app' }); + const { result } = renderHook(() => useIsMainApplication(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/common/hooks.ts b/x-pack/plugins/cases/public/common/hooks.ts new file mode 100644 index 000000000000..f65b56fecfd8 --- /dev/null +++ b/x-pack/plugins/cases/public/common/hooks.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 { STACK_APP_ID } from '../../common/constants'; +import { useCasesContext } from '../components/cases_context/use_cases_context'; + +export const useIsMainApplication = () => { + const { appId } = useCasesContext(); + + return appId === STACK_APP_ID; +}; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts index 08eb2ebf3df7..bf81e92af92b 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts @@ -10,7 +10,11 @@ import moment from 'moment-timezone'; import { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../common/constants'; +import { + FEATURE_ID, + DEFAULT_DATE_FORMAT, + DEFAULT_DATE_FORMAT_TZ, +} from '../../../../common/constants'; import { AuthenticatedUser } from '../../../../../security/common/model'; import { convertToCamelCase } from '../../../containers/utils'; import { StartServices } from '../../../types'; @@ -155,3 +159,17 @@ export const useNavigation = (appId: string) => { const { getAppUrl } = useAppUrl(appId); return { navigateTo, getAppUrl }; }; + +/** + * Returns the capabilities of the main cases application + * + */ +export const useApplicationCapabilities = (): { crud: boolean; read: boolean } => { + const capabilities = useKibana().services.application.capabilities; + const casesCapabilities = capabilities[FEATURE_ID]; + + return { + crud: !!casesCapabilities?.crud_cases, + read: !!casesCapabilities?.read_cases, + }; +}; diff --git a/x-pack/plugins/cases/public/common/navigation/hooks.test.tsx b/x-pack/plugins/cases/public/common/navigation/hooks.test.tsx index 96e34d6c69cc..cd6cf13e7256 100644 --- a/x-pack/plugins/cases/public/common/navigation/hooks.test.tsx +++ b/x-pack/plugins/cases/public/common/navigation/hooks.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { act, renderHook } from '@testing-library/react-hooks'; +import { APP_ID } from '../../../common/constants'; import { useNavigation } from '../../common/lib/kibana'; import { TestProviders } from '../../common/mock'; import { @@ -33,9 +34,12 @@ describe('hooks', () => { describe('useCasesNavigation', () => { it('it calls getAppUrl with correct arguments', () => { - const { result } = renderHook(() => useCasesNavigation(CasesDeepLinkId.cases), { - wrapper: ({ children }) => {children}, - }); + const { result } = renderHook( + () => useCasesNavigation({ deepLinkId: CasesDeepLinkId.cases }), + { + wrapper: ({ children }) => {children}, + } + ); const [getCasesUrl] = result.current; @@ -43,20 +47,23 @@ describe('hooks', () => { getCasesUrl(false); }); - expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: 'cases' }); + expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: APP_ID }); }); it('it calls navigateToAllCases with correct arguments', () => { - const { result } = renderHook(() => useCasesNavigation(CasesDeepLinkId.cases), { - wrapper: ({ children }) => {children}, - }); + const { result } = renderHook( + () => useCasesNavigation({ deepLinkId: CasesDeepLinkId.cases }), + { + wrapper: ({ children }) => {children}, + } + ); const [, navigateToCases] = result.current; act(() => { navigateToCases(); }); - expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases' }); + expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: APP_ID }); }); }); @@ -70,7 +77,7 @@ describe('hooks', () => { result.current.getAllCasesUrl(false); }); - expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: 'cases' }); + expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, path: '/', deepLinkId: APP_ID }); }); it('it calls navigateToAllCases with correct arguments', () => { @@ -82,7 +89,7 @@ describe('hooks', () => { result.current.navigateToAllCases(); }); - expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases' }); + expect(navigateTo).toHaveBeenCalledWith({ path: '/', deepLinkId: APP_ID }); }); }); @@ -96,7 +103,11 @@ describe('hooks', () => { result.current.getCreateCaseUrl(false); }); - expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: 'cases_create' }); + expect(getAppUrl).toHaveBeenCalledWith({ + absolute: false, + path: '/create', + deepLinkId: APP_ID, + }); }); it('it calls navigateToAllCases with correct arguments', () => { @@ -108,7 +119,7 @@ describe('hooks', () => { result.current.navigateToCreateCase(); }); - expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases_create' }); + expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: APP_ID, path: '/create' }); }); }); @@ -122,7 +133,11 @@ describe('hooks', () => { result.current.getConfigureCasesUrl(false); }); - expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: 'cases_configure' }); + expect(getAppUrl).toHaveBeenCalledWith({ + absolute: false, + path: '/configure', + deepLinkId: APP_ID, + }); }); it('it calls navigateToAllCases with correct arguments', () => { @@ -134,7 +149,7 @@ describe('hooks', () => { result.current.navigateToConfigureCases(); }); - expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases_configure' }); + expect(navigateTo).toHaveBeenCalledWith({ path: '/configure', deepLinkId: APP_ID }); }); }); @@ -150,7 +165,7 @@ describe('hooks', () => { expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, - deepLinkId: 'cases', + deepLinkId: APP_ID, path: '/test', }); }); @@ -164,7 +179,7 @@ describe('hooks', () => { result.current.navigateToCaseView({ detailName: 'test' }); }); - expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases', path: '/test' }); + expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: APP_ID, path: '/test' }); }); }); }); diff --git a/x-pack/plugins/cases/public/common/navigation/hooks.ts b/x-pack/plugins/cases/public/common/navigation/hooks.ts index b6dcae1c0c1c..c5488b406079 100644 --- a/x-pack/plugins/cases/public/common/navigation/hooks.ts +++ b/x-pack/plugins/cases/public/common/navigation/hooks.ts @@ -7,10 +7,17 @@ import { useCallback } from 'react'; import { useParams } from 'react-router-dom'; + +import { APP_ID } from '../../../common/constants'; import { useNavigation } from '../lib/kibana'; import { useCasesContext } from '../../components/cases_context/use_cases_context'; -import { CasesDeepLinkId, ICasesDeepLinkId } from './deep_links'; -import { CaseViewPathParams, generateCaseViewPath } from './paths'; +import { ICasesDeepLinkId } from './deep_links'; +import { + CASES_CONFIGURE_PATH, + CASES_CREATE_PATH, + CaseViewPathParams, + generateCaseViewPath, +} from './paths'; export const useCaseViewParams = () => useParams(); @@ -18,34 +25,60 @@ type GetCasesUrl = (absolute?: boolean) => string; type NavigateToCases = () => void; type UseCasesNavigation = [GetCasesUrl, NavigateToCases]; -export const useCasesNavigation = (deepLinkId: ICasesDeepLinkId): UseCasesNavigation => { +export const useCasesNavigation = ({ + path, + deepLinkId, +}: { + path?: string; + deepLinkId?: ICasesDeepLinkId; +}): UseCasesNavigation => { const { appId } = useCasesContext(); const { navigateTo, getAppUrl } = useNavigation(appId); const getCasesUrl = useCallback( - (absolute) => getAppUrl({ deepLinkId, absolute }), - [getAppUrl, deepLinkId] + (absolute) => getAppUrl({ path, deepLinkId, absolute }), + [getAppUrl, deepLinkId, path] ); const navigateToCases = useCallback( - () => navigateTo({ deepLinkId }), - [navigateTo, deepLinkId] + () => navigateTo({ path, deepLinkId }), + [navigateTo, deepLinkId, path] ); return [getCasesUrl, navigateToCases]; }; +/** + * Cases can be either be part of a solution or a standalone application + * The standalone application is registered from the cases plugin and is called + * the main application. The main application uses paths and the solutions + * deep links. + */ +const navigationMapping = { + all: { path: '/' }, + create: { path: CASES_CREATE_PATH }, + configure: { path: CASES_CONFIGURE_PATH }, +}; + export const useAllCasesNavigation = () => { - const [getAllCasesUrl, navigateToAllCases] = useCasesNavigation(CasesDeepLinkId.cases); + const [getAllCasesUrl, navigateToAllCases] = useCasesNavigation({ + path: navigationMapping.all.path, + deepLinkId: APP_ID, + }); + return { getAllCasesUrl, navigateToAllCases }; }; export const useCreateCaseNavigation = () => { - const [getCreateCaseUrl, navigateToCreateCase] = useCasesNavigation(CasesDeepLinkId.casesCreate); + const [getCreateCaseUrl, navigateToCreateCase] = useCasesNavigation({ + path: navigationMapping.create.path, + deepLinkId: APP_ID, + }); return { getCreateCaseUrl, navigateToCreateCase }; }; export const useConfigureCasesNavigation = () => { - const [getConfigureCasesUrl, navigateToConfigureCases] = useCasesNavigation( - CasesDeepLinkId.casesConfigure - ); + const [getConfigureCasesUrl, navigateToConfigureCases] = useCasesNavigation({ + path: navigationMapping.configure.path, + deepLinkId: APP_ID, + }); return { getConfigureCasesUrl, navigateToConfigureCases }; }; @@ -55,19 +88,25 @@ type NavigateToCaseView = (pathParams: CaseViewPathParams) => void; export const useCaseViewNavigation = () => { const { appId } = useCasesContext(); const { navigateTo, getAppUrl } = useNavigation(appId); + const deepLinkId = APP_ID; + const getCaseViewUrl = useCallback( (pathParams, absolute) => getAppUrl({ - deepLinkId: CasesDeepLinkId.cases, + deepLinkId, absolute, path: generateCaseViewPath(pathParams), }), - [getAppUrl] + [deepLinkId, getAppUrl] ); + const navigateToCaseView = useCallback( (pathParams) => - navigateTo({ deepLinkId: CasesDeepLinkId.cases, path: generateCaseViewPath(pathParams) }), - [navigateTo] + navigateTo({ + deepLinkId, + path: generateCaseViewPath(pathParams), + }), + [navigateTo, deepLinkId] ); return { getCaseViewUrl, navigateToCaseView }; }; diff --git a/x-pack/plugins/cases/public/common/navigation/paths.test.ts b/x-pack/plugins/cases/public/common/navigation/paths.test.ts index a3fa042042a2..3750dc4d12eb 100644 --- a/x-pack/plugins/cases/public/common/navigation/paths.test.ts +++ b/x-pack/plugins/cases/public/common/navigation/paths.test.ts @@ -18,24 +18,40 @@ describe('Paths', () => { it('returns the correct path', () => { expect(getCreateCasePath('test')).toBe('test/create'); }); + + it('normalize the path correctly', () => { + expect(getCreateCasePath('//test//page')).toBe('/test/page/create'); + }); }); describe('getCasesConfigurePath', () => { it('returns the correct path', () => { expect(getCasesConfigurePath('test')).toBe('test/configure'); }); + + it('normalize the path correctly', () => { + expect(getCasesConfigurePath('//test//page')).toBe('/test/page/configure'); + }); }); describe('getCaseViewPath', () => { it('returns the correct path', () => { expect(getCaseViewPath('test')).toBe('test/:detailName'); }); + + it('normalize the path correctly', () => { + expect(getCaseViewPath('//test//page')).toBe('/test/page/:detailName'); + }); }); describe('getCaseViewWithCommentPath', () => { it('returns the correct path', () => { expect(getCaseViewWithCommentPath('test')).toBe('test/:detailName/:commentId'); }); + + it('normalize the path correctly', () => { + expect(getCaseViewWithCommentPath('//test//page')).toBe('/test/page/:detailName/:commentId'); + }); }); describe('generateCaseViewPath', () => { diff --git a/x-pack/plugins/cases/public/common/navigation/paths.ts b/x-pack/plugins/cases/public/common/navigation/paths.ts index 1cd7a99630b8..a8660b5cf63a 100644 --- a/x-pack/plugins/cases/public/common/navigation/paths.ts +++ b/x-pack/plugins/cases/public/common/navigation/paths.ts @@ -18,12 +18,16 @@ export const CASES_CONFIGURE_PATH = '/configure' as const; export const CASE_VIEW_PATH = '/:detailName' as const; export const CASE_VIEW_COMMENT_PATH = `${CASE_VIEW_PATH}/:commentId` as const; -export const getCreateCasePath = (casesBasePath: string) => `${casesBasePath}${CASES_CREATE_PATH}`; +const normalizePath = (path: string): string => path.replaceAll('//', '/'); + +export const getCreateCasePath = (casesBasePath: string) => + normalizePath(`${casesBasePath}${CASES_CREATE_PATH}`); export const getCasesConfigurePath = (casesBasePath: string) => - `${casesBasePath}${CASES_CONFIGURE_PATH}`; -export const getCaseViewPath = (casesBasePath: string) => `${casesBasePath}${CASE_VIEW_PATH}`; + normalizePath(`${casesBasePath}${CASES_CONFIGURE_PATH}`); +export const getCaseViewPath = (casesBasePath: string) => + normalizePath(`${casesBasePath}${CASE_VIEW_PATH}`); export const getCaseViewWithCommentPath = (casesBasePath: string) => - `${casesBasePath}${CASE_VIEW_COMMENT_PATH}`; + normalizePath(`${casesBasePath}${CASE_VIEW_COMMENT_PATH}`); export const generateCaseViewPath = (params: CaseViewPathParams): string => { const { commentId } = params; @@ -31,7 +35,7 @@ export const generateCaseViewPath = (params: CaseViewPathParams): string => { const pathParams = params as unknown as { [paramName: string]: string }; if (commentId) { - return generatePath(CASE_VIEW_COMMENT_PATH, pathParams); + return normalizePath(generatePath(CASE_VIEW_COMMENT_PATH, pathParams)); } - return generatePath(CASE_VIEW_PATH, pathParams); + return normalizePath(generatePath(CASE_VIEW_PATH, pathParams)); }; diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index c4535c8f8da2..61554f5191dc 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -254,3 +254,25 @@ export const MAX_LENGTH_ERROR = (field: string, length: number) => export const LINK_APPROPRIATE_LICENSE = i18n.translate('xpack.cases.common.appropriateLicense', { defaultMessage: 'appropriate license', }); + +export const CASE_SUCCESS_TOAST = (title: string) => + i18n.translate('xpack.cases.actions.caseSuccessToast', { + values: { title }, + defaultMessage: 'An alert has been added to "{title}"', + }); + +export const CASE_SUCCESS_SYNC_TEXT = i18n.translate('xpack.cases.actions.caseSuccessSyncText', { + defaultMessage: 'Alerts in this case have their status synched with the case status', +}); + +export const VIEW_CASE = i18n.translate('xpack.cases.actions.viewCase', { + defaultMessage: 'View Case', +}); + +export const APP_TITLE = i18n.translate('xpack.cases.common.appTitle', { + defaultMessage: 'Cases', +}); + +export const APP_DESC = i18n.translate('xpack.cases.common.appDescription', { + defaultMessage: 'Open and track issues, push information to third party systems.', +}); 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 new file mode 100644 index 000000000000..9bd6a6675a5c --- /dev/null +++ b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useToasts } from '../common/lib/kibana'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../common/mock'; +import { CaseToastSuccessContent, useCasesToast } from './use_cases_toast'; +import { mockCase } from '../containers/mock'; +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +jest.mock('../common/lib/kibana'); + +const useToastsMock = useToasts as jest.Mock; + +describe('Use cases toast hook', () => { + describe('Toast hook', () => { + const successMock = jest.fn(); + useToastsMock.mockImplementation(() => { + return { + addSuccess: successMock, + }; + }); + it('should create a success tost when invoked with a case', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach(mockCase); + expect(successMock).toHaveBeenCalled(); + }); + }); + describe('Toast content', () => { + let appMockRender: AppMockRenderer; + const onViewCaseClick = jest.fn(); + beforeEach(() => { + appMockRender = createAppMockRenderer(); + onViewCaseClick.mockReset(); + }); + + it('renders a correct successfull message with synced alerts', () => { + const result = appMockRender.render( + + ); + expect(result.getByTestId('toaster-content-sync-text')).toHaveTextContent( + 'Alerts in this case have their status synched with the case status' + ); + expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View Case'); + expect(onViewCaseClick).not.toHaveBeenCalled(); + }); + + it('renders a correct successfull message with not synced alerts', () => { + const result = appMockRender.render( + + ); + expect(result.queryByTestId('toaster-content-sync-text')).toBeFalsy(); + expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View Case'); + expect(onViewCaseClick).not.toHaveBeenCalled(); + }); + + it('Calls the onViewCaseClick when clicked', () => { + const result = appMockRender.render( + + ); + userEvent.click(result.getByTestId('toaster-content-case-view-link')); + expect(onViewCaseClick).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.tsx new file mode 100644 index 000000000000..98cc7fa1d8fa --- /dev/null +++ b/x-pack/plugins/cases/public/common/use_cases_toast.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 { EuiButtonEmpty, EuiText } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; +import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; +import { Case } from '../../common'; +import { useToasts } from '../common/lib/kibana'; +import { useCaseViewNavigation } from '../common/navigation'; +import { CASE_SUCCESS_SYNC_TEXT, CASE_SUCCESS_TOAST, VIEW_CASE } from './translations'; + +const LINE_CLAMP = 3; +const Title = styled.span` + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: ${LINE_CLAMP}; + -webkit-box-orient: vertical; + overflow: hidden; +`; +const EuiTextStyled = styled(EuiText)` + ${({ theme }) => ` + margin-bottom: ${theme.eui?.paddingSizes?.s ?? 8}px; + `} +`; + +export const useCasesToast = () => { + const { navigateToCaseView } = useCaseViewNavigation(); + + const toasts = useToasts(); + + return { + showSuccessAttach: (theCase: Case) => { + const onViewCaseClick = () => { + navigateToCaseView({ + detailName: theCase.id, + }); + }; + return toasts.addSuccess({ + color: 'success', + iconType: 'check', + title: toMountPoint({CASE_SUCCESS_TOAST(theCase.title)}), + text: toMountPoint( + + ), + }); + }, + }; +}; +export const CaseToastSuccessContent = ({ + syncAlerts, + onViewCaseClick, +}: { + syncAlerts: boolean; + onViewCaseClick: () => void; +}) => { + return ( + <> + {syncAlerts && ( + + {CASE_SUCCESS_SYNC_TEXT} + + )} + + {VIEW_CASE} + + + ); +}; +CaseToastSuccessContent.displayName = 'CaseToastSuccessContent'; 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 36a7a2c240a4..c1d78787a542 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -39,8 +39,8 @@ import { StatusContextMenu } from '../case_action_bar/status_context_menu'; import { TruncatedText } from '../truncated_text'; import { getConnectorIcon } from '../utils'; import { PostComment } from '../../containers/use_post_comment'; -import type { CasesOwners } from '../../methods/can_use_cases'; import { CaseAttachments } from '../../types'; +import type { CasesOwners } from '../../client/helpers/can_use_cases'; export type CasesColumns = | EuiTableActionsColumnType diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.test.tsx index 33eddeccb59b..ef01ead1cb07 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.test.tsx @@ -13,7 +13,6 @@ import { TestProviders } from '../../../common/mock'; import { AllCasesList } from '../all_cases_list'; import { SECURITY_SOLUTION_OWNER } from '../../../../common/constants'; -jest.mock('../../../methods'); jest.mock('../all_cases_list'); const onRowClick = jest.fn(); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx index 08c99c515939..ba553b28a34e 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx @@ -26,7 +26,7 @@ export interface AllCasesSelectorModalProps { */ alertData?: Omit; hiddenStatuses?: CaseStatusWithAllStatus[]; - onRowClick: (theCase?: Case) => void; + onRowClick?: (theCase?: Case) => void; updateCase?: (newCase: Case) => void; onClose?: () => void; attachments?: CaseAttachments; @@ -52,7 +52,9 @@ export const AllCasesSelectorModal = React.memo( const onClick = useCallback( (theCase?: Case) => { closeModal(); - onRowClick(theCase); + if (onRowClick) { + onRowClick(theCase); + } }, [closeModal, onRowClick] ); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/uses_cases_add_to_existing_case_modal.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx similarity index 89% rename from x-pack/plugins/cases/public/components/all_cases/selector_modal/uses_cases_add_to_existing_case_modal.test.tsx rename to x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx index 6eeff6102ae6..6a224949db8b 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/uses_cases_add_to_existing_case_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx @@ -12,6 +12,7 @@ import React from 'react'; import { CasesContext } from '../../cases_context'; import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; import { useCasesAddToExistingCaseModal } from './use_cases_add_to_existing_case_modal'; +jest.mock('../../../common/use_cases_toast'); describe('use cases add to existing case modal hook', () => { const dispatch = jest.fn(); @@ -65,7 +66,7 @@ describe('use cases add to existing case modal hook', () => { ); }); - it('should dispatch the close action when invoked', () => { + it('should dispatch the close action for modal and flyout when invoked', () => { const { result } = renderHook( () => { return useCasesAddToExistingCaseModal(defaultParams()); @@ -78,5 +79,10 @@ describe('use cases add to existing case modal hook', () => { type: CasesContextStoreActionsList.CLOSE_ADD_TO_CASE_MODAL, }) ); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: CasesContextStoreActionsList.CLOSE_CREATE_CASE_FLYOUT, + }) + ); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx index b2ad07c2375d..5341f5be4183 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx @@ -7,15 +7,36 @@ import { useCallback } from 'react'; import { AllCasesSelectorModalProps } from '.'; +import { useCasesToast } from '../../../common/use_cases_toast'; +import { Case } from '../../../containers/types'; import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; import { useCasesContext } from '../../cases_context/use_cases_context'; +import { useCasesAddToNewCaseFlyout } from '../../create/flyout/use_cases_add_to_new_case_flyout'; export const useCasesAddToExistingCaseModal = (props: AllCasesSelectorModalProps) => { + const createNewCaseFlyout = useCasesAddToNewCaseFlyout({ + attachments: props.attachments, + onClose: props.onClose, + // TODO there's no need for onSuccess to be async. This will be fixed + // in a follow up clean up + onSuccess: async (theCase?: Case) => { + if (props.onRowClick) { + return props.onRowClick(theCase); + } + }, + }); const { dispatch } = useCasesContext(); + const casesToasts = useCasesToast(); + const closeModal = useCallback(() => { dispatch({ type: CasesContextStoreActionsList.CLOSE_ADD_TO_CASE_MODAL, }); + // in case the flyout was also open when selecting + // create a new case + dispatch({ + type: CasesContextStoreActionsList.CLOSE_CREATE_CASE_FLYOUT, + }); }, [dispatch]); const openModal = useCallback(() => { @@ -23,6 +44,19 @@ export const useCasesAddToExistingCaseModal = (props: AllCasesSelectorModalProps type: CasesContextStoreActionsList.OPEN_ADD_TO_CASE_MODAL, payload: { ...props, + onRowClick: (theCase?: Case) => { + // when the case is undefined in the modal + // the user clicked "create new case" + if (theCase === undefined) { + closeModal(); + createNewCaseFlyout.open(); + } else { + casesToasts.showSuccessAttach(theCase); + if (props.onRowClick) { + props.onRowClick(theCase); + } + } + }, onClose: () => { closeModal(); if (props.onClose) { @@ -37,7 +71,7 @@ export const useCasesAddToExistingCaseModal = (props: AllCasesSelectorModalProps }, }, }); - }, [closeModal, dispatch, props]); + }, [casesToasts, closeModal, createNewCaseFlyout, dispatch, props]); return { open: openModal, close: closeModal, diff --git a/x-pack/plugins/cases/public/components/app/index.tsx b/x-pack/plugins/cases/public/components/app/index.tsx index 0ac336adb94a..2fcd10fb1431 100644 --- a/x-pack/plugins/cases/public/components/app/index.tsx +++ b/x-pack/plugins/cases/public/components/app/index.tsx @@ -5,9 +5,33 @@ * 2.0. */ -import { CasesRoutes } from './routes'; +import React from 'react'; +import { APP_OWNER } from '../../../common/constants'; +import { getCasesLazy } from '../../client/ui/get_cases'; +import { useApplicationCapabilities } from '../../common/lib/kibana'; + +import { Wrapper } from '../wrappers'; import { CasesRoutesProps } from './types'; export type CasesProps = CasesRoutesProps; -// eslint-disable-next-line import/no-default-export -export { CasesRoutes as default }; + +const CasesAppComponent: React.FC = () => { + const userCapabilities = useApplicationCapabilities(); + + return ( + + {getCasesLazy({ + owner: [APP_OWNER], + useFetchAlertData: () => [false, {}], + userCanCrud: userCapabilities.crud, + basePath: '/', + features: { alerts: { sync: false } }, + releasePhase: 'experimental', + })} + + ); +}; + +CasesAppComponent.displayName = 'CasesApp'; + +export const CasesApp = React.memo(CasesAppComponent); diff --git a/x-pack/plugins/cases/public/components/app/routes.tsx b/x-pack/plugins/cases/public/components/app/routes.tsx index 6222c413a116..6fc87f691b2a 100644 --- a/x-pack/plugins/cases/public/components/app/routes.tsx +++ b/x-pack/plugins/cases/public/components/app/routes.tsx @@ -91,3 +91,5 @@ const CasesRoutesComponent: React.FC = ({ CasesRoutesComponent.displayName = 'CasesRoutes'; export const CasesRoutes = React.memo(CasesRoutesComponent); +// eslint-disable-next-line import/no-default-export +export { CasesRoutes as default }; diff --git a/x-pack/plugins/cases/public/components/app/translations.ts b/x-pack/plugins/cases/public/components/app/translations.ts index 6796f0e03aa7..4958ce4358c1 100644 --- a/x-pack/plugins/cases/public/components/app/translations.ts +++ b/x-pack/plugins/cases/public/components/app/translations.ts @@ -7,21 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const NO_PRIVILEGES_MSG = (pageName: string) => - i18n.translate('xpack.cases.noPrivileges.message', { - values: { pageName }, - defaultMessage: - 'To view {pageName} page, you must update privileges. For more information, contact your Kibana administrator.', - }); - -export const NO_PRIVILEGES_TITLE = i18n.translate('xpack.cases.noPrivileges.title', { - defaultMessage: 'Privileges required', -}); - -export const NO_PRIVILEGES_BUTTON = i18n.translate('xpack.cases.noPrivileges.button', { - defaultMessage: 'Back to Cases', -}); - export const CREATE_CASE_PAGE_NAME = i18n.translate('xpack.cases.createCase', { defaultMessage: 'Create Case', }); diff --git a/x-pack/plugins/cases/public/components/cases_context/cases_global_components.test.tsx b/x-pack/plugins/cases/public/components/cases_context/cases_global_components.test.tsx index ce496b81ece1..0f7efd67dc63 100644 --- a/x-pack/plugins/cases/public/components/cases_context/cases_global_components.test.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/cases_global_components.test.tsx @@ -6,15 +6,14 @@ */ import React from 'react'; +import { getAllCasesSelectorModalNoProviderLazy } from '../../client/ui/get_all_cases_selector_modal'; +import { getCreateCaseFlyoutLazyNoProvider } from '../../client/ui/get_create_case_flyout'; import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; -import { - getAllCasesSelectorModalNoProviderLazy, - getCreateCaseFlyoutLazyNoProvider, -} from '../../methods'; import { getInitialCasesContextState } from './cases_context_reducer'; import { CasesGlobalComponents } from './cases_global_components'; -jest.mock('../../methods'); +jest.mock('../../client/ui/get_create_case_flyout'); +jest.mock('../../client/ui/get_all_cases_selector_modal'); const getCreateCaseFlyoutLazyNoProviderMock = getCreateCaseFlyoutLazyNoProvider as jest.Mock; const getAllCasesSelectorModalNoProviderLazyMock = diff --git a/x-pack/plugins/cases/public/components/cases_context/cases_global_components.tsx b/x-pack/plugins/cases/public/components/cases_context/cases_global_components.tsx index 36891b34a8f7..09b0c0d5658e 100644 --- a/x-pack/plugins/cases/public/components/cases_context/cases_global_components.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/cases_global_components.tsx @@ -6,10 +6,8 @@ */ import React from 'react'; -import { - getAllCasesSelectorModalNoProviderLazy, - getCreateCaseFlyoutLazyNoProvider, -} from '../../methods'; +import { getAllCasesSelectorModalNoProviderLazy } from '../../client/ui/get_all_cases_selector_modal'; +import { getCreateCaseFlyoutLazyNoProvider } from '../../client/ui/get_create_case_flyout'; import { CasesContextState } from './cases_context_reducer'; export const CasesGlobalComponents = React.memo(({ state }: { state: CasesContextState }) => { diff --git a/x-pack/plugins/cases/public/components/cases_context/use_cases_context.ts b/x-pack/plugins/cases/public/components/cases_context/use_cases_context.ts index 2244145f7111..8f93dfe4a02e 100644 --- a/x-pack/plugins/cases/public/components/cases_context/use_cases_context.ts +++ b/x-pack/plugins/cases/public/components/cases_context/use_cases_context.ts @@ -12,7 +12,9 @@ export const useCasesContext = () => { const casesContext = useContext(CasesContext); if (!casesContext) { - throw new Error('useCasesContext must be used within a CasesProvider and have a defined value'); + throw new Error( + 'useCasesContext must be used within a CasesProvider and have a defined value. See https://github.com/elastic/kibana/blob/main/x-pack/plugins/cases/README.md#cases-ui' + ); } return casesContext; diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx index 103e24c4b7a6..e569b1ee7995 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx @@ -12,6 +12,7 @@ import React from 'react'; import { CasesContext } from '../../cases_context'; import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; import { useCasesAddToNewCaseFlyout } from './use_cases_add_to_new_case_flyout'; +jest.mock('../../../common/use_cases_toast'); describe('use cases add to new case flyout hook', () => { const dispatch = jest.fn(); diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx index e4ae4d72f48d..5422ab9be995 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx @@ -6,12 +6,15 @@ */ import { useCallback } from 'react'; +import { useCasesToast } from '../../../common/use_cases_toast'; +import { Case } from '../../../containers/types'; import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; import { useCasesContext } from '../../cases_context/use_cases_context'; import { CreateCaseFlyoutProps } from './create_case_flyout'; export const useCasesAddToNewCaseFlyout = (props: CreateCaseFlyoutProps) => { const { dispatch } = useCasesContext(); + const casesToasts = useCasesToast(); const closeFlyout = useCallback(() => { dispatch({ @@ -30,6 +33,14 @@ export const useCasesAddToNewCaseFlyout = (props: CreateCaseFlyoutProps) => { return props.onClose(); } }, + onSuccess: async (theCase: Case) => { + if (theCase) { + casesToasts.showSuccessAttach(theCase); + } + if (props.onSuccess) { + return props.onSuccess(theCase); + } + }, afterCaseCreated: async (...args) => { closeFlyout(); if (props.afterCaseCreated) { @@ -38,7 +49,7 @@ export const useCasesAddToNewCaseFlyout = (props: CreateCaseFlyoutProps) => { }, }, }); - }, [closeFlyout, dispatch, props]); + }, [casesToasts, closeFlyout, dispatch, props]); return { open: openFlyout, close: closeFlyout, diff --git a/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx b/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx index 9cf956c78fe7..111cc4940ac5 100644 --- a/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx +++ b/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx @@ -221,6 +221,49 @@ describe('EditableTitle', () => { ); }); + it('does not show an error after a previous edit error was displayed', () => { + const longTitle = + 'This is a title that should not be saved as it is longer than 64 characters.'; + + const shortTitle = 'My title'; + const wrapper = mount( + + + + ); + + wrapper.find('button[data-test-subj="editable-title-edit-icon"]').simulate('click'); + wrapper.update(); + + // simualte a long title + wrapper + .find('input[data-test-subj="editable-title-input-field"]') + .simulate('change', { target: { value: longTitle } }); + + wrapper.find('button[data-test-subj="editable-title-submit-btn"]').simulate('click'); + wrapper.update(); + expect(wrapper.find('.euiFormErrorText').text()).toBe( + 'The length of the title is too long. The maximum length is 64.' + ); + + // write a shorter one + wrapper + .find('input[data-test-subj="editable-title-input-field"]') + .simulate('change', { target: { value: shortTitle } }); + wrapper.update(); + + // submit the form + wrapper.find('button[data-test-subj="editable-title-submit-btn"]').simulate('click'); + wrapper.update(); + + // edit again + wrapper.find('button[data-test-subj="editable-title-edit-icon"]').simulate('click'); + wrapper.update(); + + // no error should appear + expect(wrapper.find('.euiFormErrorText').length).toBe(0); + }); + describe('Badges', () => { let appMock: AppMockRenderer; diff --git a/x-pack/plugins/cases/public/components/header_page/editable_title.tsx b/x-pack/plugins/cases/public/components/header_page/editable_title.tsx index 95e3f6f4a4bc..0b142ca40a54 100644 --- a/x-pack/plugins/cases/public/components/header_page/editable_title.tsx +++ b/x-pack/plugins/cases/public/components/header_page/editable_title.tsx @@ -71,6 +71,7 @@ const EditableTitleComponent: React.FC = ({ onSubmit(newTitle); } setEditMode(false); + setErrors([]); }, [newTitle, onSubmit, title]); const handleOnChange = useCallback( @@ -82,39 +83,42 @@ const EditableTitleComponent: React.FC = ({ return editMode ? ( - - + + - - - - {i18n.SAVE} - - - - - {i18n.CANCEL} - - - - + + + {i18n.SAVE} + + + + + {i18n.CANCEL} + + ) : ( diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx index 5179aed6518b..2105618ae031 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx @@ -36,6 +36,7 @@ import type { EmbeddablePackageState } from '../../../../../../../../src/plugins import { SavedObjectFinderUi } from './saved_objects_finder'; import { useLensDraftComment } from './use_lens_draft_comment'; import { VISUALIZATION } from './translations'; +import { useIsMainApplication } from '../../../../common/hooks'; const BetaBadgeWrapper = styled.span` display: inline-flex; @@ -84,6 +85,7 @@ const LensEditorComponent: LensEuiMarkdownEditorUiPlugin['editor'] = ({ const { draftComment, clearDraftComment } = useLensDraftComment(); const commentEditorContext = useContext(CommentEditorContext); const markdownContext = useContext(EuiMarkdownContext); + const isMainApplication = useIsMainApplication(); const handleClose = useCallback(() => { if (currentAppId) { @@ -126,8 +128,11 @@ const LensEditorComponent: LensEuiMarkdownEditorUiPlugin['editor'] = ({ ); const originatingPath = useMemo( - () => `${location.pathname}${location.search}`, - [location.pathname, location.search] + () => + isMainApplication + ? `/insightsAndAlerting/cases${location.pathname}${location.search}` + : `${location.pathname}${location.search}`, + [isMainApplication, location.pathname, location.search] ); const handleCreateInLensClick = useCallback(() => { diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 1fafe5afe699..5ff675a31ce6 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -8,7 +8,7 @@ import { IconType } from '@elastic/eui'; import { ConnectorTypes } from '../../common/api'; import { FieldConfig, ValidationConfig } from '../common/shared_imports'; -import { StartPlugins } from '../types'; +import { CasesPluginStart } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; import { connectorValidator as servicenowConnectorValidator } from './connectors/servicenow/validator'; import { CaseActionConnector } from './types'; @@ -48,7 +48,7 @@ export const getConnectorsFormValidators = ({ }); export const getConnectorIcon = ( - triggersActionsUi: StartPlugins['triggersActionsUi'], + triggersActionsUi: CasesPluginStart['triggersActionsUi'], type?: string ): IconType => { /** diff --git a/x-pack/plugins/cases/public/components/wrappers/index.tsx b/x-pack/plugins/cases/public/components/wrappers/index.tsx index 7a3d611413be..d412ef34451b 100644 --- a/x-pack/plugins/cases/public/components/wrappers/index.tsx +++ b/x-pack/plugins/cases/public/components/wrappers/index.tsx @@ -27,3 +27,8 @@ export const ContentWrapper = styled.div` padding: ${theme.eui.paddingSizes.l} 0 ${gutterTimeline} 0; `}; `; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; +`; diff --git a/x-pack/plugins/cases/public/index.tsx b/x-pack/plugins/cases/public/index.tsx index 0190df8204fc..f81cb1d0a57c 100644 --- a/x-pack/plugins/cases/public/index.tsx +++ b/x-pack/plugins/cases/public/index.tsx @@ -16,12 +16,12 @@ export { DRAFT_COMMENT_STORAGE_ID } from './components/markdown_editor/plugins/l export type { CasesUiPlugin }; export type { CasesUiStart } from './types'; -export type { GetCasesProps } from './methods/get_cases'; -export type { GetCreateCaseFlyoutProps } from './methods/get_create_case_flyout'; -export type { GetAllCasesSelectorModalProps } from './methods/get_all_cases_selector_modal'; -export type { GetRecentCasesProps } from './methods/get_recent_cases'; +export type { GetCasesProps } from './client/ui/get_cases'; +export type { GetCreateCaseFlyoutProps } from './client/ui/get_create_case_flyout'; +export type { GetAllCasesSelectorModalProps } from './client/ui/get_all_cases_selector_modal'; +export type { GetRecentCasesProps } from './client/ui/get_recent_cases'; -export type { CaseAttachments } from './types'; +export type { CaseAttachments, SupportedCaseAttachment } from './types'; export type { ICasesDeepLinkId } from './common/navigation'; export { diff --git a/x-pack/plugins/cases/public/mocks.ts b/x-pack/plugins/cases/public/mocks.ts index f7f80170a877..fd34e9ae4940 100644 --- a/x-pack/plugins/cases/public/mocks.ts +++ b/x-pack/plugins/cases/public/mocks.ts @@ -5,23 +5,45 @@ * 2.0. */ +import { mockCasesContext } from './mocks/mock_cases_context'; import { CasesUiStart } from './types'; -const createStartContract = (): jest.Mocked => ({ - canUseCases: jest.fn(), +const apiMock: jest.Mocked = { + getRelatedCases: jest.fn(), +}; + +const uiMock: jest.Mocked = { getCases: jest.fn(), - getCasesContext: jest.fn(), + getCasesContext: jest.fn().mockImplementation(() => mockCasesContext), getAllCasesSelectorModal: jest.fn(), - getAllCasesSelectorModalNoProvider: jest.fn(), getCreateCaseFlyout: jest.fn(), getRecentCases: jest.fn(), - getCreateCaseFlyoutNoProvider: jest.fn(), - hooks: { - getUseCasesAddToNewCaseFlyout: jest.fn(), - getUseCasesAddToExistingCaseModal: jest.fn(), - }, +}; + +const hooksMock: jest.Mocked = { + getUseCasesAddToNewCaseFlyout: jest.fn(), + getUseCasesAddToExistingCaseModal: jest.fn(), +}; + +const helpersMock: jest.Mocked = { + canUseCases: jest.fn(), + getRuleIdFromEvent: jest.fn(), +}; + +export interface CaseUiClientMock { + api: jest.Mocked; + ui: jest.Mocked; + hooks: jest.Mocked; + helpers: jest.Mocked; +} + +export const mockCasesContract = (): CaseUiClientMock => ({ + api: apiMock, + ui: uiMock, + hooks: hooksMock, + helpers: helpersMock, }); export const casesPluginMock = { - createStartContract, + createStartContract: mockCasesContract, }; diff --git a/x-pack/plugins/security_solution/public/common/mock/mock_cases_context.tsx b/x-pack/plugins/cases/public/mocks/mock_cases_context.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/common/mock/mock_cases_context.tsx rename to x-pack/plugins/cases/public/mocks/mock_cases_context.tsx diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts index 9dbc6ea35125..7ae68ee141d6 100644 --- a/x-pack/plugins/cases/public/plugin.ts +++ b/x-pack/plugins/cases/public/plugin.ts @@ -6,52 +6,104 @@ */ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; -import { CasesUiStart, SetupPlugins, StartPlugins } from './types'; +import { CasesUiStart, CasesPluginSetup, CasesPluginStart } from './types'; import { KibanaServices } from './common/lib/kibana'; -import { - getCasesLazy, - getRecentCasesLazy, - getAllCasesSelectorModalLazy, - getCreateCaseFlyoutLazy, - canUseCases, - getCreateCaseFlyoutLazyNoProvider, - getAllCasesSelectorModalNoProviderLazy, -} from './methods'; import { CasesUiConfigType } from '../common/ui/types'; -import { getCasesContextLazy } from './methods/get_cases_context'; -import { useCasesAddToNewCaseFlyout } from './components/create/flyout/use_cases_add_to_new_case_flyout'; +import { APP_ID, APP_PATH } from '../common/constants'; +import { APP_TITLE, APP_DESC } from './common/translations'; +import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; +import { ManagementAppMountParams } from '../../../../src/plugins/management/public'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { useCasesAddToExistingCaseModal } from './components/all_cases/selector_modal/use_cases_add_to_existing_case_modal'; +import { useCasesAddToNewCaseFlyout } from './components/create/flyout/use_cases_add_to_new_case_flyout'; +import { createClientAPI } from './client/api'; +import { canUseCases } from './client/helpers/can_use_cases'; +import { getRuleIdFromEvent } from './client/helpers/get_rule_id_from_event'; +import { getAllCasesSelectorModalLazy } from './client/ui/get_all_cases_selector_modal'; +import { getCasesLazy } from './client/ui/get_cases'; +import { getCasesContextLazy } from './client/ui/get_cases_context'; +import { getCreateCaseFlyoutLazy } from './client/ui/get_create_case_flyout'; +import { getRecentCasesLazy } from './client/ui/get_recent_cases'; /** * @public * A plugin for retrieving Cases UI components */ -export class CasesUiPlugin implements Plugin { - private kibanaVersion: string; +export class CasesUiPlugin + implements Plugin +{ + private readonly kibanaVersion: string; + private readonly storage = new Storage(localStorage); constructor(private readonly initializerContext: PluginInitializerContext) { this.kibanaVersion = initializerContext.env.packageInfo.version; } - public setup(core: CoreSetup, plugins: SetupPlugins) {} - public start(core: CoreStart, plugins: StartPlugins): CasesUiStart { + public setup(core: CoreSetup, plugins: CasesPluginSetup) { + const kibanaVersion = this.kibanaVersion; + const storage = this.storage; + + if (plugins.home) { + plugins.home.featureCatalogue.register({ + id: APP_ID, + title: APP_TITLE, + description: APP_DESC, + icon: 'watchesApp', + path: APP_PATH, + showOnHomePage: false, + category: FeatureCatalogueCategory.ADMIN, + }); + } + + plugins.management.sections.section.insightsAndAlerting.registerApp({ + id: APP_ID, + title: APP_TITLE, + order: 0, + async mount(params: ManagementAppMountParams) { + const [coreStart, pluginsStart] = (await core.getStartServices()) as [ + CoreStart, + CasesPluginStart, + unknown + ]; + + const { renderApp } = await import('./application'); + + return renderApp({ + mountParams: params, + coreStart, + pluginsStart, + storage, + kibanaVersion, + }); + }, + }); + + // Return methods that should be available to other plugins + return {}; + } + + public start(core: CoreStart, plugins: CasesPluginStart): CasesUiStart { const config = this.initializerContext.config.get(); KibanaServices.init({ ...core, ...plugins, kibanaVersion: this.kibanaVersion, config }); return { - canUseCases: canUseCases(core.application.capabilities), - getCases: getCasesLazy, - getCasesContext: getCasesContextLazy, - getRecentCases: getRecentCasesLazy, - getCreateCaseFlyout: getCreateCaseFlyoutLazy, - getAllCasesSelectorModal: getAllCasesSelectorModalLazy, - // Temporal methods to remove timelines and cases deep integration - // https://github.com/elastic/kibana/issues/123183 - getCreateCaseFlyoutNoProvider: getCreateCaseFlyoutLazyNoProvider, - getAllCasesSelectorModalNoProvider: getAllCasesSelectorModalNoProviderLazy, + api: createClientAPI({ http: core.http }), + ui: { + getCases: getCasesLazy, + getCasesContext: getCasesContextLazy, + getRecentCases: getRecentCasesLazy, + // @deprecated Please use the hook getUseCasesAddToNewCaseFlyout + getCreateCaseFlyout: getCreateCaseFlyoutLazy, + // @deprecated Please use the hook getUseCasesAddToExistingCaseModal + getAllCasesSelectorModal: getAllCasesSelectorModalLazy, + }, hooks: { getUseCasesAddToNewCaseFlyout: useCasesAddToNewCaseFlyout, getUseCasesAddToExistingCaseModal: useCasesAddToExistingCaseModal, }, + helpers: { + canUseCases: canUseCases(core.application.capabilities), + getRuleIdFromEvent, + }, }; } diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts index cf8d97d835c1..6013a2783093 100644 --- a/x-pack/plugins/cases/public/types.ts +++ b/x-pack/plugins/cases/public/types.ts @@ -10,33 +10,45 @@ import { ReactElement, ReactNode } from 'react'; import type { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import type { Storage } from '../../../../src/plugins/kibana_utils/public'; +import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { + ManagementSetup, + ManagementAppMountParams, +} from '../../../../src/plugins/management/public'; +import { FeaturesPluginStart } from '../..//features/public'; import type { LensPublicStart } from '../../lens/public'; import type { SecurityPluginSetup } from '../../security/public'; import type { SpacesPluginStart } from '../../spaces/public'; import type { TriggersAndActionsUIPublicPluginStart as TriggersActionsStart } from '../../triggers_actions_ui/public'; -import { CommentRequestAlertType, CommentRequestUserType } from '../common/api'; +import { + CasesByAlertId, + CasesByAlertIDRequest, + CommentRequestAlertType, + CommentRequestUserType, +} from '../common/api'; import { UseCasesAddToExistingCaseModal } from './components/all_cases/selector_modal/use_cases_add_to_existing_case_modal'; -import { CreateCaseFlyoutProps } from './components/create/flyout'; import { UseCasesAddToNewCaseFlyout } from './components/create/flyout/use_cases_add_to_new_case_flyout'; -import type { - CasesOwners, - GetAllCasesSelectorModalProps, - GetCasesProps, - GetCreateCaseFlyoutProps, - GetRecentCasesProps, -} from './methods'; -import { GetCasesContextProps } from './methods/get_cases_context'; +import type { CasesOwners } from './client/helpers/can_use_cases'; +import { getRuleIdFromEvent } from './client/helpers/get_rule_id_from_event'; +import type { GetCasesContextProps } from './client/ui/get_cases_context'; +import type { GetCasesProps } from './client/ui/get_cases'; +import { GetAllCasesSelectorModalProps } from './client/ui/get_all_cases_selector_modal'; +import { GetCreateCaseFlyoutProps } from './client/ui/get_create_case_flyout'; +import { GetRecentCasesProps } from './client/ui/get_recent_cases'; -export interface SetupPlugins { +export interface CasesPluginSetup { security: SecurityPluginSetup; + management: ManagementSetup; + home?: HomePublicPluginSetup; } -export interface StartPlugins { +export interface CasesPluginStart { data: DataPublicPluginStart; embeddable: EmbeddableStart; lens: LensPublicStart; storage: Storage; triggersActionsUi: TriggersActionsStart; + features: FeaturesPluginStart; spaces?: SpacesPluginStart; } @@ -47,59 +59,71 @@ export interface StartPlugins { */ export type StartServices = CoreStart & - StartPlugins & { + CasesPluginStart & { security: SecurityPluginSetup; }; +export interface RenderAppProps { + mountParams: ManagementAppMountParams; + coreStart: CoreStart; + pluginsStart: CasesPluginStart; + storage: Storage; + kibanaVersion: string; +} + export interface CasesUiStart { - /** - * Returns an object denoting the current user's ability to read and crud cases. - * If any owner(securitySolution, Observability) is found with crud or read capability respectively, - * then crud or read is set to true. - * Permissions for specific owners can be found by passing an owner array - * @param owners an array of CaseOwners that should be queried for permission - * @returns An object denoting the case permissions of the current user - */ - canUseCases: (owners?: CasesOwners[]) => { crud: boolean; read: boolean }; - /** - * Get cases - * @param props GetCasesProps - * @return {ReactElement} - */ - getCases: (props: GetCasesProps) => ReactElement; - getCasesContext: () => ( - props: GetCasesContextProps & { children: ReactNode } - ) => ReactElement; - /** - * Modal to select a case in a list of all owner cases - * @param props GetAllCasesSelectorModalProps - * @returns A react component that is a modal for selecting a case - */ - getAllCasesSelectorModal: ( - props: GetAllCasesSelectorModalProps - ) => ReactElement; - getAllCasesSelectorModalNoProvider: ( - props: GetAllCasesSelectorModalProps - ) => ReactElement; - /** - * Flyout with the form to create a case for the owner - * @param props GetCreateCaseFlyoutProps - * @returns A react component that is a flyout for creating a case - */ - getCreateCaseFlyout: (props: GetCreateCaseFlyoutProps) => ReactElement; - getCreateCaseFlyoutNoProvider: ( - props: CreateCaseFlyoutProps - ) => ReactElement; - /** - * Get the recent cases component - * @param props GetRecentCasesProps - * @returns A react component for showing recent cases - */ - getRecentCases: (props: GetRecentCasesProps) => ReactElement; + api: { + getRelatedCases: (alertId: string, query: CasesByAlertIDRequest) => Promise; + }; + ui: { + /** + * Get cases + * @param props GetCasesProps + * @return {ReactElement} + */ + getCases: (props: GetCasesProps) => ReactElement; + getCasesContext: () => ( + props: GetCasesContextProps & { children: ReactNode } + ) => ReactElement; + /** + * Modal to select a case in a list of all owner cases + * @param props GetAllCasesSelectorModalProps + * @returns A react component that is a modal for selecting a case + */ + getAllCasesSelectorModal: ( + props: GetAllCasesSelectorModalProps + ) => ReactElement; + /** + * Flyout with the form to create a case for the owner + * @param props GetCreateCaseFlyoutProps + * @returns A react component that is a flyout for creating a case + */ + getCreateCaseFlyout: ( + props: GetCreateCaseFlyoutProps + ) => ReactElement; + /** + * Get the recent cases component + * @param props GetRecentCasesProps + * @returns A react component for showing recent cases + */ + getRecentCases: (props: GetRecentCasesProps) => ReactElement; + }; hooks: { getUseCasesAddToNewCaseFlyout: UseCasesAddToNewCaseFlyout; getUseCasesAddToExistingCaseModal: UseCasesAddToExistingCaseModal; }; + helpers: { + /** + * Returns an object denoting the current user's ability to read and crud cases. + * If any owner(securitySolution, Observability) is found with crud or read capability respectively, + * then crud or read is set to true. + * Permissions for specific owners can be found by passing an owner array + * @param owners an array of CaseOwners that should be queried for permission + * @returns An object denoting the case permissions of the current user + */ + canUseCases: (owners?: CasesOwners[]) => { crud: boolean; read: boolean }; + getRuleIdFromEvent: typeof getRuleIdFromEvent; + }; } export type SupportedCaseAttachment = CommentRequestAlertType | CommentRequestUserType; diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts index 112fd6ef2c04..06b0922cacc4 100644 --- a/x-pack/plugins/cases/server/client/cases/push.ts +++ b/x-pack/plugins/cases/server/client/cases/push.ts @@ -16,6 +16,7 @@ import { ExternalServiceResponse, CasesConfigureAttributes, ActionTypes, + OWNER_FIELD, } from '../../../common/api'; import { createIncident, getCommentContextFromAttributes } from './utils'; @@ -25,6 +26,7 @@ import { CasesClient, CasesClientArgs, CasesClientInternal } from '..'; import { Operations } from '../../authorization'; import { casesConnectors } from '../../connectors'; import { getAlerts } from '../alerts/get'; +import { buildFilter } from '../utils'; /** * Returns true if the case should be closed based on the configuration settings. @@ -139,12 +141,19 @@ export const push = async ( /* End of push to external service */ + const ownerFilter = buildFilter({ + filters: theCase.owner, + field: OWNER_FIELD, + operator: 'or', + type: Operations.findConfigurations.savedObjectType, + }); + /* Start of update case with push information */ const [myCase, myCaseConfigure, comments] = await Promise.all([ caseService.getCase({ id: caseId, }), - caseConfigureService.find({ unsecuredSavedObjectsClient }), + caseConfigureService.find({ unsecuredSavedObjectsClient, options: { filter: ownerFilter } }), caseService.getAllCaseComments({ id: caseId, options: { diff --git a/x-pack/plugins/cases/server/features.ts b/x-pack/plugins/cases/server/features.ts new file mode 100644 index 000000000000..33a3315b7d9d --- /dev/null +++ b/x-pack/plugins/cases/server/features.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 { i18n } from '@kbn/i18n'; + +import { KibanaFeatureConfig } from '../../features/common'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; + +import { APP_ID, FEATURE_ID } from '../common/constants'; + +/** + * The order of appearance in the feature privilege page + * under the management section. Cases should be under + * the Actions and Connectors feature + */ + +const FEATURE_ORDER = 3100; + +export const getCasesKibanaFeature = (): KibanaFeatureConfig => ({ + id: FEATURE_ID, + name: i18n.translate('xpack.cases.features.casesFeatureName', { + defaultMessage: 'Cases', + }), + category: DEFAULT_APP_CATEGORIES.management, + app: [], + order: FEATURE_ORDER, + management: { + insightsAndAlerting: [APP_ID], + }, + cases: [APP_ID], + privileges: { + all: { + cases: { + all: [APP_ID], + }, + management: { + insightsAndAlerting: [APP_ID], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['crud_cases', 'read_cases'], + }, + read: { + cases: { + read: [APP_ID], + }, + management: { + insightsAndAlerting: [APP_ID], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['read_cases'], + }, + }, +}); diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index e6c4faac9393..9d2915491c44 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -28,14 +28,19 @@ import { CasesClient } from './client'; import type { CasesRequestHandlerContext } from './types'; import { CasesClientFactory } from './client/factory'; import { SpacesPluginStart } from '../../spaces/server'; -import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; +import { + PluginStartContract as FeaturesPluginStart, + PluginSetupContract as FeaturesPluginSetup, +} from '../../features/server'; import { LensServerPluginSetup } from '../../lens/server'; +import { getCasesKibanaFeature } from './features'; import { registerRoutes } from './routes/api/register_routes'; import { getExternalRoutes } from './routes/api/get_external_routes'; export interface PluginsSetup { actions: ActionsPluginSetup; lens: LensServerPluginSetup; + features: FeaturesPluginSetup; usageCollection?: UsageCollectionSetup; security?: SecurityPluginSetup; } @@ -77,6 +82,8 @@ export class CasePlugin { this.securityPluginSetup = plugins.security; this.lensEmbeddableFactory = plugins.lens.lensEmbeddableFactory; + plugins.features.registerKibanaFeature(getCasesKibanaFeature()); + core.savedObjects.registerType( createCaseCommentSavedObjectType({ migrationDeps: { diff --git a/x-pack/plugins/cloud/public/fullstory.ts b/x-pack/plugins/cloud/public/fullstory.ts index 4f76abf540ca..602b1c4cc63d 100644 --- a/x-pack/plugins/cloud/public/fullstory.ts +++ b/x-pack/plugins/cloud/public/fullstory.ts @@ -15,9 +15,11 @@ export interface FullStoryDeps { } export type FullstoryUserVars = Record; +export type FullstoryVars = Record; export interface FullStoryApi { identify(userId: string, userVars?: FullstoryUserVars): void; + setVars(pageName: string, vars?: FullstoryVars): void; setUserVars(userVars?: FullstoryUserVars): void; event(eventName: string, eventProperties: Record): void; } diff --git a/x-pack/plugins/cloud/public/plugin.test.mocks.ts b/x-pack/plugins/cloud/public/plugin.test.mocks.ts index b79fb1bc6513..1c185d019491 100644 --- a/x-pack/plugins/cloud/public/plugin.test.mocks.ts +++ b/x-pack/plugins/cloud/public/plugin.test.mocks.ts @@ -11,6 +11,7 @@ import type { FullStoryDeps, FullStoryApi, FullStoryService } from './fullstory' export const fullStoryApiMock: jest.Mocked = { event: jest.fn(), setUserVars: jest.fn(), + setVars: jest.fn(), identify: jest.fn(), }; export const initializeFullStoryMock = jest.fn(() => ({ diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index 1eef581610f0..edbf724e2539 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -12,6 +12,7 @@ import { securityMock } from '../../security/public/mocks'; import { fullStoryApiMock, initializeFullStoryMock } from './plugin.test.mocks'; import { CloudPlugin, CloudConfigType, loadFullStoryUserId } from './plugin'; import { Observable, Subject } from 'rxjs'; +import { KibanaExecutionContext } from 'kibana/public'; describe('Cloud Plugin', () => { describe('#setup', () => { @@ -24,12 +25,12 @@ describe('Cloud Plugin', () => { config = {}, securityEnabled = true, currentUserProps = {}, - currentAppId$ = undefined, + currentContext$ = undefined, }: { config?: Partial; securityEnabled?: boolean; currentUserProps?: Record; - currentAppId$?: Observable; + currentContext$?: Observable; }) => { const initContext = coreMock.createPluginInitializerContext({ id: 'cloudId', @@ -51,8 +52,8 @@ describe('Cloud Plugin', () => { const coreSetup = coreMock.createSetup(); const coreStart = coreMock.createStart(); - if (currentAppId$) { - coreStart.application.currentAppId$ = currentAppId$; + if (currentContext$) { + coreStart.executionContext.context$ = currentContext$; } coreSetup.getStartServices.mockResolvedValue([coreStart, {}, undefined]); @@ -94,44 +95,98 @@ describe('Cloud Plugin', () => { }); expect(fullStoryApiMock.identify).toHaveBeenCalledWith( - '03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4', + '5ef112cfdae3dea57097bc276e275b2816e73ef2a398dc0ffaf5b6b4e3af2041', { version_str: 'version', version_major_int: -1, version_minor_int: -1, version_patch_int: -1, + org_id_str: 'cloudId', } ); }); - it('calls FS.setUserVars everytime an app changes', async () => { - const currentAppId$ = new Subject(); + it('user hash includes org id', async () => { + await setupPlugin({ + config: { full_story: { enabled: true, org_id: 'foo' }, id: 'esOrg1' }, + currentUserProps: { + username: '1234', + }, + }); + + const hashId1 = fullStoryApiMock.identify.mock.calls[0][0]; + + await setupPlugin({ + config: { full_story: { enabled: true, org_id: 'foo' }, id: 'esOrg2' }, + currentUserProps: { + username: '1234', + }, + }); + + const hashId2 = fullStoryApiMock.identify.mock.calls[1][0]; + + expect(hashId1).not.toEqual(hashId2); + }); + + it('calls FS.setVars everytime an app changes', async () => { + const currentContext$ = new Subject(); const { plugin } = await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' } }, currentUserProps: { username: '1234', }, - currentAppId$, + currentContext$, }); - expect(fullStoryApiMock.setUserVars).not.toHaveBeenCalled(); - currentAppId$.next('App1'); - expect(fullStoryApiMock.setUserVars).toHaveBeenCalledWith({ + // takes the app name + expect(fullStoryApiMock.setVars).not.toHaveBeenCalled(); + currentContext$.next({ + name: 'App1', + description: '123', + }); + + expect(fullStoryApiMock.setVars).toHaveBeenCalledWith('page', { + pageName: 'App1', app_id_str: 'App1', }); - currentAppId$.next(); - expect(fullStoryApiMock.setUserVars).toHaveBeenCalledWith({ - app_id_str: 'unknown', + + // context clear + currentContext$.next({}); + expect(fullStoryApiMock.setVars).toHaveBeenCalledWith('page', { + pageName: 'App1', + app_id_str: 'App1', }); - currentAppId$.next('App2'); - expect(fullStoryApiMock.setUserVars).toHaveBeenCalledWith({ + // different app + currentContext$.next({ + name: 'App2', + page: 'page2', + id: '123', + }); + expect(fullStoryApiMock.setVars).toHaveBeenCalledWith('page', { + pageName: 'App2:page2', app_id_str: 'App2', + page_str: 'page2', + ent_id_str: '123', + }); + + // Back to first app + currentContext$.next({ + name: 'App1', + page: 'page3', + id: '123', + }); + + expect(fullStoryApiMock.setVars).toHaveBeenCalledWith('page', { + pageName: 'App1:page3', + app_id_str: 'App1', + page_str: 'page3', + ent_id_str: '123', }); - expect(currentAppId$.observers.length).toBe(1); + expect(currentContext$.observers.length).toBe(1); plugin.stop(); - expect(currentAppId$.observers.length).toBe(0); + expect(currentContext$.observers.length).toBe(0); }); it('does not call FS.identify when security is not available', async () => { diff --git a/x-pack/plugins/cloud/public/plugin.tsx b/x-pack/plugins/cloud/public/plugin.tsx index 991a7c1f8b56..89f24971de25 100644 --- a/x-pack/plugins/cloud/public/plugin.tsx +++ b/x-pack/plugins/cloud/public/plugin.tsx @@ -13,11 +13,12 @@ import { PluginInitializerContext, HttpStart, IBasePath, - ApplicationStart, + ExecutionContextStart, } from 'src/core/public'; import { i18n } from '@kbn/i18n'; import useObservable from 'react-use/lib/useObservable'; import { BehaviorSubject, Subscription } from 'rxjs'; +import { compact, isUndefined, omitBy } from 'lodash'; import type { AuthenticatedUser, SecurityPluginSetup, @@ -83,8 +84,9 @@ export interface CloudSetup { } interface SetupFullstoryDeps extends CloudSetupDependencies { - application?: Promise; + executionContextPromise?: Promise; basePath: IBasePath; + esOrgId?: string; } interface SetupChatDeps extends Pick { @@ -103,11 +105,16 @@ export class CloudPlugin implements Plugin { } public setup(core: CoreSetup, { home, security }: CloudSetupDependencies) { - const application = core.getStartServices().then(([coreStart]) => { - return coreStart.application; + const executionContextPromise = core.getStartServices().then(([coreStart]) => { + return coreStart.executionContext; }); - this.setupFullstory({ basePath: core.http.basePath, security, application }).catch((e) => + this.setupFullstory({ + basePath: core.http.basePath, + security, + executionContextPromise, + esOrgId: this.config.id, + }).catch((e) => // eslint-disable-next-line no-console console.debug(`Error setting up FullStory: ${e.toString()}`) ); @@ -223,9 +230,14 @@ export class CloudPlugin implements Plugin { return user?.roles.includes('superuser') ?? true; } - private async setupFullstory({ basePath, security, application }: SetupFullstoryDeps) { - const { enabled, org_id: orgId } = this.config.full_story; - if (!enabled || !orgId) { + private async setupFullstory({ + basePath, + security, + executionContextPromise, + esOrgId, + }: SetupFullstoryDeps) { + const { enabled, org_id: fsOrgId } = this.config.full_story; + if (!enabled || !fsOrgId) { return; // do not load any fullstory code in the browser if not enabled } @@ -243,7 +255,7 @@ export class CloudPlugin implements Plugin { const { fullStory, sha256 } = initializeFullStory({ basePath, - orgId, + orgId: fsOrgId, packageInfo: this.initializerContext.env.packageInfo, }); @@ -252,16 +264,29 @@ export class CloudPlugin implements Plugin { // This needs to be called syncronously to be sure that we populate the user ID soon enough to make sessions merging // across domains work if (userId) { - // Do the hashing here to keep it at clear as possible in our source code that we do not send literal user IDs - const hashedId = sha256(userId.toString()); - application - ?.then(async () => { - const appStart = await application; - this.appSubscription = appStart.currentAppId$.subscribe((appId) => { - // Update the current application every time it changes - fullStory.setUserVars({ - app_id_str: appId ?? 'unknown', - }); + // 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 + const hashedId = sha256(esOrgId ? `${esOrgId}:${userId}` : `${userId}`); + + executionContextPromise + ?.then(async (executionContext) => { + this.appSubscription = executionContext.context$.subscribe((context) => { + const { name, page, id } = context; + // Update the current context every time it changes + fullStory.setVars( + 'page', + omitBy( + { + // Read about the special pageName property + // https://help.fullstory.com/hc/en-us/articles/1500004101581-FS-setVars-API-Sending-custom-page-data-to-FullStory + pageName: `${compact([name, page]).join(':')}`, + app_id_str: name ?? 'unknown', + page_str: page, + ent_id_str: id, + }, + isUndefined + ) + ); }); }) .catch((e) => { @@ -282,6 +307,7 @@ export class CloudPlugin implements Plugin { version_major_int: parsedVer[0] ?? -1, version_minor_int: parsedVer[1] ?? -1, version_patch_int: parsedVer[2] ?? -1, + org_id_str: esOrgId, }); } } catch (e) { diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index 8523c4b5757d..e9f30f657d51 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -5,11 +5,16 @@ * 2.0. */ -export const CSP_KUBEBEAT_INDEX_PATTERN = 'logs-k8s_cis*'; -export const CSP_FINDINGS_INDEX_NAME = 'findings'; export const STATS_ROUTE_PATH = '/api/csp/stats'; export const FINDINGS_ROUTE_PATH = '/api/csp/findings'; -export const AGENT_LOGS_INDEX_PATTERN = '.logs-k8s_cis.metadata*'; +export const BENCHMARKS_ROUTE_PATH = '/api/csp/benchmarks'; +export const UPDATE_RULES_CONFIG_ROUTE_PATH = '/api/csp/update_rules_config'; + +export const CSP_KUBEBEAT_INDEX_PATTERN = 'logs-cis_kubernetes_benchmark.findings*'; +export const AGENT_LOGS_INDEX_PATTERN = '.logs-cis_kubernetes_benchmark.metadata*'; + +export const CSP_FINDINGS_INDEX_NAME = 'findings'; +export const CIS_KUBERNETES_PACKAGE_NAME = 'cis_kubernetes_benchmark'; export const RULE_PASSED = `passed`; export const RULE_FAILED = `failed`; @@ -17,5 +22,8 @@ export const RULE_FAILED = `failed`; // A mapping of in-development features to their status. These features should be hidden from users but can be easily // activated via a simple code change in a single location. export const INTERNAL_FEATURE_FLAGS = { - benchmarks: false, + showBenchmarks: false, + showTrendLineMock: false, + showManageRulesMock: false, + showRisksMock: false, } as const; 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 new file mode 100644 index 000000000000..f5d38e938e2c --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/common/schemas/csp_configuration.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 { schema as rt, TypeOf } from '@kbn/config-schema'; + +export const cspRulesConfigSchema = rt.object({ + activated_rules: rt.object({ + cis_k8s: rt.arrayOf(rt.string()), + }), +}); + +export type CspRulesConfigSchema = TypeOf; diff --git a/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule.ts b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule.ts new file mode 100644 index 000000000000..d5c8e9fab1f2 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema as rt, TypeOf } from '@kbn/config-schema'; + +export const cspRuleAssetSavedObjectType = 'csp_rule'; + +// TODO: needs to be shared with kubebeat +export const cspRuleSchema = rt.object({ + id: rt.string(), + name: rt.string(), + description: rt.string(), + rationale: rt.string(), + impact: rt.string(), + default_value: rt.string(), + remediation: rt.string(), + benchmark: rt.object({ name: rt.string(), version: rt.string() }), + tags: rt.arrayOf(rt.string()), + enabled: rt.boolean(), + muted: rt.boolean(), +}); + +export type CspRuleSchema = TypeOf; diff --git a/x-pack/plugins/cloud_security_posture/common/types.ts b/x-pack/plugins/cloud_security_posture/common/types.ts index aa6b1d5bc985..c09a99563ede 100644 --- a/x-pack/plugins/cloud_security_posture/common/types.ts +++ b/x-pack/plugins/cloud_security_posture/common/types.ts @@ -9,25 +9,32 @@ export type Evaluation = 'passed' | 'failed' | 'NA'; /** number between 1-100 */ export type Score = number; -export interface FindingsResults { +export interface FindingsEvaluation { totalFindings: number; totalPassed: number; totalFailed: number; } -export interface Stats extends FindingsResults { +export interface Stats extends FindingsEvaluation { postureScore: Score; } -export interface ResourceTypeAgg extends FindingsResults { - resourceType: string; +export interface ResourceType extends FindingsEvaluation { + name: string; } -export interface BenchmarkStats extends Stats { - name: string; +export interface Cluster { + meta: { + clusterId: string; + benchmarkName: string; + lastUpdate: number; // unix epoch time + }; + stats: Stats; + resourcesTypes: ResourceType[]; } -export interface CloudPostureStats extends Stats { - benchmarksStats: BenchmarkStats[]; - resourceTypesAggs: ResourceTypeAgg[]; +export interface CloudPostureStats { + stats: Stats; + resourcesTypes: ResourceType[]; + clusters: Cluster[]; } diff --git a/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts b/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts index 1df0c18ebdd0..745b59724dcb 100644 --- a/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts +++ b/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import * as t from 'io-ts'; /** * @example @@ -16,7 +15,7 @@ export const isNonNullable = (v: T): v is NonNullable => export const extractErrorMessage = (e: unknown, defaultMessage = 'Unknown Error'): string => { if (e instanceof Error) return e.message; - if (t.record(t.literal('message'), t.string).is(e)) return e.message; + if (typeof e === 'string') return e; return defaultMessage; // TODO: i18n }; diff --git a/x-pack/plugins/cloud_security_posture/kibana.json b/x-pack/plugins/cloud_security_posture/kibana.json index 67143c15e2b7..29f3813c211c 100755 --- a/x-pack/plugins/cloud_security_posture/kibana.json +++ b/x-pack/plugins/cloud_security_posture/kibana.json @@ -10,6 +10,6 @@ "description": "The cloud security posture plugin", "server": true, "ui": true, - "requiredPlugins": ["navigation", "data"], + "requiredPlugins": ["navigation", "data", "fleet"], "requiredBundles": ["kibanaReact"] } diff --git a/x-pack/plugins/cloud_security_posture/public/application/constants.tsx b/x-pack/plugins/cloud_security_posture/public/application/constants.tsx index b592a30aeb2b..128382d039f1 100644 --- a/x-pack/plugins/cloud_security_posture/public/application/constants.tsx +++ b/x-pack/plugins/cloud_security_posture/public/application/constants.tsx @@ -12,4 +12,5 @@ export const pageToComponentMapping: Record = findings: pages.Findings, dashboard: pages.ComplianceDashboard, benchmarks: pages.Benchmarks, + rules: pages.Rules, }; diff --git a/x-pack/plugins/cloud_security_posture/public/application/index.tsx b/x-pack/plugins/cloud_security_posture/public/application/index.tsx index 6530483bf219..38fbef254ea4 100644 --- a/x-pack/plugins/cloud_security_posture/public/application/index.tsx +++ b/x-pack/plugins/cloud_security_posture/public/application/index.tsx @@ -7,8 +7,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; import { CspApp } from './app'; - import type { AppMountParameters, CoreStart } from '../../../../../src/core/public'; import type { CspClientPluginStartDeps } from '../types'; @@ -17,7 +17,12 @@ export const renderApp = ( deps: CspClientPluginStartDeps, params: AppMountParameters ) => { - ReactDOM.render(, params.element); + ReactDOM.render( + + + , + params.element + ); return () => ReactDOM.unmountComponentAtNode(params.element); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/utils.tsx b/x-pack/plugins/cloud_security_posture/public/common/api/use_kubebeat_data_view.ts similarity index 93% rename from x-pack/plugins/cloud_security_posture/public/pages/findings/utils.tsx rename to x-pack/plugins/cloud_security_posture/public/common/api/use_kubebeat_data_view.ts index 529b1ca5cdd1..3e83187ef9ba 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/utils.tsx +++ b/x-pack/plugins/cloud_security_posture/public/common/api/use_kubebeat_data_view.ts @@ -4,10 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import { useQuery } from 'react-query'; -import type { CspClientPluginStartDeps } from '../../types'; -import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; +import { CspClientPluginStartDeps } from '../../types'; /** * TODO: use perfected kibana data views diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_kibana.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_kibana.ts new file mode 100644 index 000000000000..b2d1d2a7b6bb --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_kibana.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. + */ + +import type { CoreStart } from '../../../../../../src/core/public'; +import type { CspClientPluginStartDeps } from '../../types'; +import { useKibana as useKibanaBase } from '../../../../../../src/plugins/kibana_react/public'; + +type CspKibanaContext = CoreStart & CspClientPluginStartDeps; + +export const useKibana = () => useKibanaBase(); diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts new file mode 100644 index 000000000000..6cc1582d7ff7 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useHistory } from 'react-router-dom'; +import { Query } from '@kbn/es-query'; +import { allNavigationItems } from '../navigation/constants'; +import { encodeQuery } from '../navigation/query_utils'; +import { CspFindingsRequest } from '../../pages/findings/use_findings'; + +const getFindingsQuery = (queryValue: Query['query']): Pick => { + const query = + typeof queryValue === 'string' + ? queryValue + : // TODO: use a tested query builder instead ASAP + Object.entries(queryValue) + .reduce((a, [key, value]) => { + a.push(`${key} : "${value}"`); + return a; + }, []) + .join(' and '); + + return { + query: { + language: 'kuery', + // NOTE: a query object is valid TS but throws on runtime + query, + }, + }!; +}; + +export const useNavigateFindings = () => { + const history = useHistory(); + + return (query?: Query['query']) => { + history.push({ + pathname: allNavigationItems.findings.path, + ...(query && { search: encodeQuery(getFindingsQuery(query)) }), + }); + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts index bde28fa1ce3b..8603e13159f5 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts @@ -12,9 +12,10 @@ import type { CspPage, CspNavigationItem } from './types'; export const allNavigationItems: Record = { dashboard: { name: TEXT.DASHBOARD, path: '/dashboard' }, findings: { name: TEXT.FINDINGS, path: '/findings' }, + rules: { name: 'Rules', path: '/rules', disabled: !INTERNAL_FEATURE_FLAGS.showBenchmarks }, benchmarks: { name: TEXT.MY_BENCHMARKS, path: '/benchmarks', - disabled: !INTERNAL_FEATURE_FLAGS.benchmarks, + disabled: !INTERNAL_FEATURE_FLAGS.showBenchmarks, }, }; diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts index 64db2e59b667..87f62b88ba17 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts @@ -10,4 +10,4 @@ export interface CspNavigationItem { readonly disabled?: boolean; } -export type CspPage = 'dashboard' | 'findings' | 'benchmarks'; +export type CspPage = 'dashboard' | 'findings' | 'benchmarks' | 'rules'; diff --git a/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx b/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx index 5190f71a1721..2b0882d0916e 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; -import { css } from '@emotion/react'; import { EuiPanel, EuiText, @@ -64,7 +63,7 @@ export const ChartPanel: React.FC = ({ {title && ( - +

{title}

)} @@ -74,7 +73,3 @@ export const ChartPanel: React.FC = ({ ); }; - -const euiTitleStyle = css` - font-weight: 400; -`; diff --git a/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx b/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx index 8603bef59122..93aa87c18a9b 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiBadge, EuiBadgeProps } from '@elastic/eui'; +import { EuiBadge, type EuiBadgeProps } from '@elastic/eui'; import { CSP_EVALUATION_BADGE_FAILED, CSP_EVALUATION_BADGE_PASSED } from './translations'; interface Props { diff --git a/x-pack/plugins/cloud_security_posture/public/components/page_template.test.tsx b/x-pack/plugins/cloud_security_posture/public/components/page_template.test.tsx index a9be5ebcdebf..1c367cd5c57f 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/page_template.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/page_template.test.tsx @@ -4,15 +4,37 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { ComponentProps } from 'react'; +import React, { type ComponentProps } from 'react'; import { render, screen } from '@testing-library/react'; import Chance from 'chance'; +import { coreMock } from '../../../../../src/core/public/mocks'; +import { createStubDataView } from '../../../../../src/plugins/data_views/public/data_views/data_view.stub'; +import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../common/constants'; +import { useKubebeatDataView } from '../common/api/use_kubebeat_data_view'; import { createNavigationItemFixture } from '../test/fixtures/navigation_item'; +import { createReactQueryResponse } from '../test/fixtures/react_query'; import { TestProvider } from '../test/test_provider'; import { CspPageTemplate, getSideNavItems } from './page_template'; +import { + LOADING, + NO_DATA_CONFIG_BUTTON, + NO_DATA_CONFIG_DESCRIPTION, + NO_DATA_CONFIG_TITLE, +} from './translations'; const chance = new Chance(); +const BLANK_PAGE_GRAPHIC_TEXTS = [ + NO_DATA_CONFIG_TITLE, + NO_DATA_CONFIG_DESCRIPTION, + NO_DATA_CONFIG_BUTTON, +]; + +// Synchronized to the error message in the formatted message in `page_template.tsx` +const ERROR_LOADING_DATA_DEFAULT_MESSAGE = "We couldn't fetch your cloud security posture data"; + +jest.mock('../common/api/use_kubebeat_data_view'); + describe('getSideNavItems', () => { it('maps navigation items to side navigation items', () => { const navigationItem = createNavigationItemFixture(); @@ -36,40 +58,101 @@ describe('getSideNavItems', () => { }); describe('', () => { - const renderCspPageTemplate = (props: ComponentProps) => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const renderCspPageTemplate = (props: ComponentProps = {}) => { + const mockCore = coreMock.createStart(); + render( - + ); }; - it('renders children when not loading', () => { + it('renders children when data view is found', () => { + (useKubebeatDataView as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: createStubDataView({ + spec: { + id: CSP_KUBEBEAT_INDEX_PATTERN, + }, + }), + }) + ); + const children = chance.sentence(); - renderCspPageTemplate({ isLoading: false, children }); + renderCspPageTemplate({ children }); expect(screen.getByText(children)).toBeInTheDocument(); + expect(screen.queryByText(LOADING)).not.toBeInTheDocument(); + expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument(); + BLANK_PAGE_GRAPHIC_TEXTS.forEach((blankPageGraphicText) => + expect(screen.queryByText(blankPageGraphicText)).not.toBeInTheDocument() + ); }); - it('does not render loading text when not loading', () => { + it('renders loading text when data view is loading', () => { + (useKubebeatDataView as jest.Mock).mockImplementation(() => + createReactQueryResponse({ status: 'loading' }) + ); + const children = chance.sentence(); - const loadingText = chance.sentence(); - renderCspPageTemplate({ isLoading: false, loadingText, children }); + renderCspPageTemplate({ children }); - expect(screen.queryByText(loadingText)).not.toBeInTheDocument(); + expect(screen.getByText(LOADING)).toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument(); + BLANK_PAGE_GRAPHIC_TEXTS.forEach((blankPageGraphicText) => + expect(screen.queryByText(blankPageGraphicText)).not.toBeInTheDocument() + ); }); - it('renders loading text when loading is true', () => { - const loadingText = chance.sentence(); - renderCspPageTemplate({ loadingText, isLoading: true }); + it('renders an error view when data view fetching has an error', () => { + (useKubebeatDataView as jest.Mock).mockImplementation(() => + createReactQueryResponse({ status: 'error', error: new Error('') }) + ); + + const children = chance.sentence(); + renderCspPageTemplate({ children }); - expect(screen.getByText(loadingText)).toBeInTheDocument(); + expect(screen.getByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).toBeInTheDocument(); + expect(screen.queryByText(LOADING)).not.toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + BLANK_PAGE_GRAPHIC_TEXTS.forEach((blankPageGraphicText) => + expect(screen.queryByText(blankPageGraphicText)).not.toBeInTheDocument() + ); }); - it('does not render children when loading', () => { + it('renders the blank page graphic when data view is missing', () => { + (useKubebeatDataView as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: undefined, + }) + ); + const children = chance.sentence(); - renderCspPageTemplate({ isLoading: true, children }); + renderCspPageTemplate({ children }); + BLANK_PAGE_GRAPHIC_TEXTS.forEach((text) => expect(screen.getByText(text)).toBeInTheDocument()); + expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument(); + expect(screen.queryByText(LOADING)).not.toBeInTheDocument(); expect(screen.queryByText(children)).not.toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx b/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx index 4b28671a446c..f164b7b92fc7 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx @@ -4,20 +4,26 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { EuiSpacer } from '@elastic/eui'; import React from 'react'; import { NavLink } from 'react-router-dom'; -import { EuiErrorBoundary } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiErrorBoundary, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; import { KibanaPageTemplate, - KibanaPageTemplateProps, + type KibanaPageTemplateProps, } from '../../../../../src/plugins/kibana_react/public'; +import { useKubebeatDataView } from '../common/api/use_kubebeat_data_view'; import { allNavigationItems } from '../common/navigation/constants'; import type { CspNavigationItem } from '../common/navigation/types'; import { CLOUD_SECURITY_POSTURE } from '../common/translations'; import { CspLoadingState } from './csp_loading_state'; -import { LOADING } from './translations'; +import { + LOADING, + NO_DATA_CONFIG_BUTTON, + NO_DATA_CONFIG_DESCRIPTION, + NO_DATA_CONFIG_SOLUTION_NAME, + NO_DATA_CONFIG_TITLE, +} from './translations'; const activeItemStyle = { fontWeight: 700 }; @@ -36,37 +42,69 @@ export const getSideNavItems = ( ), })); -const defaultProps: KibanaPageTemplateProps = { +const DEFAULT_PROPS: KibanaPageTemplateProps = { solutionNav: { name: CLOUD_SECURITY_POSTURE, items: getSideNavItems(allNavigationItems), }, restrictWidth: false, - template: 'default', }; -interface CspPageTemplateProps extends KibanaPageTemplateProps { - isLoading?: boolean; - loadingText?: string; -} +const NO_DATA_CONFIG: KibanaPageTemplateProps['noDataConfig'] = { + pageTitle: NO_DATA_CONFIG_TITLE, + solution: NO_DATA_CONFIG_SOLUTION_NAME, + // TODO: Add real docs link once we have it + docsLink: 'https://www.elastic.co/guide/index.html', + logo: 'logoSecurity', + actions: { + elasticAgent: { + // TODO: Use `href` prop to link to our own integration once we have it + title: NO_DATA_CONFIG_BUTTON, + description: NO_DATA_CONFIG_DESCRIPTION, + }, + }, +}; + +export const CspPageTemplate: React.FC = ({ children, ...props }) => { + // TODO: Consider using more sophisticated logic to find out if our integration is installed + const kubeBeatQuery = useKubebeatDataView(); + + let noDataConfig: KibanaPageTemplateProps['noDataConfig']; + if (kubeBeatQuery.status === 'success' && !kubeBeatQuery.data) { + noDataConfig = NO_DATA_CONFIG; + } + + let template: KibanaPageTemplateProps['template'] = 'default'; + if (kubeBeatQuery.status === 'error' || kubeBeatQuery.status === 'loading') { + template = 'centeredContent'; + } -export const CspPageTemplate: React.FC = ({ - children, - isLoading, - loadingText = LOADING, - ...props -}) => { return ( - + - {isLoading ? ( - <> - - {loadingText} - - ) : ( - children + {kubeBeatQuery.status === 'loading' && {LOADING}} + {kubeBeatQuery.status === 'error' && ( + +

+ +

+
+ } + /> )} + {kubeBeatQuery.status === 'success' && children} ); diff --git a/x-pack/plugins/cloud_security_posture/public/components/translations.ts b/x-pack/plugins/cloud_security_posture/public/components/translations.ts index ac03b9e9df72..b203d0365a28 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/translations.ts +++ b/x-pack/plugins/cloud_security_posture/public/components/translations.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { i18n } from '@kbn/i18n'; export const CRITICAL = i18n.translate('xpack.csp.critical', { @@ -40,3 +39,29 @@ export const CSP_EVALUATION_BADGE_PASSED = i18n.translate( defaultMessage: 'PASSED', } ); + +export const NO_DATA_CONFIG_TITLE = i18n.translate('xpack.csp.pageTemplate.noDataConfigTitle', { + defaultMessage: 'Understand your cloud security posture', +}); + +export const NO_DATA_CONFIG_SOLUTION_NAME = i18n.translate( + 'xpack.csp.pageTemplate.noDataConfig.solutionNameLabel', + { + defaultMessage: 'Cloud Security Posture', + } +); + +export const NO_DATA_CONFIG_DESCRIPTION = i18n.translate( + 'xpack.csp.pageTemplate.noDataConfigDescription', + { + defaultMessage: + 'Use our CIS Kubernetes Benchmark integration to measure your Kubernetes cluster setup against the CIS recommendations.', + } +); + +export const NO_DATA_CONFIG_BUTTON = i18n.translate( + 'xpack.csp.pageTemplate.noDataConfigButtonLabel', + { + defaultMessage: 'Add a CIS integration', + } +); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.test.tsx index c23d6599a1c3..660892df9d01 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.test.tsx @@ -7,6 +7,9 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import type { UseQueryResult } from 'react-query/types/react/types'; +import { createStubDataView } from '../../../../../../src/plugins/data_views/public/data_views/data_view.stub'; +import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; +import { useKubebeatDataView } from '../../common/api/use_kubebeat_data_view'; import { createCspBenchmarkIntegrationFixture } from '../../test/fixtures/csp_benchmark_integration'; import { createReactQueryResponse } from '../../test/fixtures/react_query'; import { TestProvider } from '../../test/test_provider'; @@ -15,10 +18,22 @@ import { ADD_A_CIS_INTEGRATION, BENCHMARK_INTEGRATIONS, LOADING_BENCHMARKS } fro import { useCspBenchmarkIntegrations } from './use_csp_benchmark_integrations'; jest.mock('./use_csp_benchmark_integrations'); +jest.mock('../../common/api/use_kubebeat_data_view'); describe('', () => { beforeEach(() => { jest.resetAllMocks(); + // Required for the page template to render the benchmarks page + (useKubebeatDataView as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: createStubDataView({ + spec: { + id: CSP_KUBEBEAT_INDEX_PATTERN, + }, + }), + }) + ); }); const renderBenchmarks = ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx index 56d43816cf34..e85eb6d6a357 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx @@ -4,10 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiPageHeaderProps, EuiButton } from '@elastic/eui'; +import { EuiPageHeaderProps, EuiButton, EuiSpacer } from '@elastic/eui'; import React from 'react'; import { allNavigationItems } from '../../common/navigation/constants'; import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs'; +import { CspLoadingState } from '../../components/csp_loading_state'; import { CspPageTemplate } from '../../components/page_template'; import { BenchmarksTable } from './benchmarks_table'; import { ADD_A_CIS_INTEGRATION, BENCHMARK_INTEGRATIONS, LOADING_BENCHMARKS } from './translations'; @@ -35,11 +36,13 @@ export const Benchmarks = () => { const query = useCspBenchmarkIntegrations(); return ( - + + {query.status === 'loading' && ( + <> + + {LOADING_BENCHMARKS} + + )} {query.status === 'error' && } {query.status === 'success' && ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cases_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cases_table.tsx new file mode 100644 index 000000000000..30107d668975 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cases_table.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { EuiIcon } from '@elastic/eui'; +import * as TEXT from '../translations'; + +export const CasesTable = () => { + return ( + + + + + + {TEXT.COMING_SOON} + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx index 01dfd837fca2..a1f044241e5d 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx @@ -7,18 +7,23 @@ import React from 'react'; import { + AreaSeries, + Axis, Chart, ElementClickListener, + niceTimeFormatByDay, Partition, PartitionElementEvent, PartitionLayout, Settings, + timeFormatter, } from '@elastic/charts'; import { EuiFlexGroup, EuiText, EuiHorizontalRule, EuiFlexItem } from '@elastic/eui'; import { statusColors } from '../../../common/constants'; import type { Stats } from '../../../../common/types'; import * as TEXT from '../translations'; import { CompactFormattedNumber } from '../../../components/compact_formatted_number'; +import { INTERNAL_FEATURE_FLAGS } from '../../../../common/constants'; interface CloudPostureScoreChartProps { data: Stats; @@ -37,7 +42,7 @@ const ScoreChart = ({ ]; return ( - +
Trend Placeholder
; +const mockData = [ + [0, 9], + [1000, 70], + [2000, 40], + [4000, 90], + [5000, 53], +]; + +const ComplianceTrendChart = () => ( + + + + + + +); export const CloudPostureScoreChart = ({ data, @@ -97,8 +124,8 @@ export const CloudPostureScoreChart = ({ }: CloudPostureScoreChartProps) => ( - - + + @@ -106,7 +133,7 @@ export const CloudPostureScoreChart = ({ - + diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/compliance_stats.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/compliance_stats.tsx deleted file mode 100644 index 1dff4aba203b..000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/compliance_stats.tsx +++ /dev/null @@ -1,158 +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 from 'react'; -import { - EuiStat, - EuiFlexItem, - EuiPanel, - EuiIcon, - EuiFlexGrid, - EuiText, - // EuiFlexGroup, -} from '@elastic/eui'; -// import { Chart, Settings, LineSeries } from '@elastic/charts'; -import type { IconType, EuiStatProps } from '@elastic/eui'; -import { useCloudPostureStatsApi } from '../../../common/api'; -import { statusColors } from '../../../common/constants'; -import { Score } from '../../../../common/types'; -import * as TEXT from '../translations'; -import { NO_DATA_TO_DISPLAY } from '../translations'; - -// type Trend = Array<[time: number, value: number]>; - -// TODO: this is the warning color hash listen in EUI's docs. need to find where to import it from. - -const getTitleColor = (value: Score): EuiStatProps['titleColor'] => { - if (value <= 65) return 'danger'; - if (value <= 95) return statusColors.warning; - if (value <= 100) return 'success'; - return 'default'; -}; - -const getScoreIcon = (value: Score): IconType => { - if (value <= 65) return 'alert'; - if (value <= 86) return 'alert'; - if (value <= 100) return 'check'; - return 'error'; -}; - -// TODO: make score trend check for length, cases for less than 2 or more than 5 should be handled -// const getScoreTrendPercentage = (scoreTrend: Trend) => { -// const beforeLast = scoreTrend[scoreTrend.length - 2][1]; -// const last = scoreTrend[scoreTrend.length - 1][1]; -// -// return Number((last - beforeLast).toFixed(1)); -// }; - -const placeholder = ( - - {NO_DATA_TO_DISPLAY} - -); - -export const ComplianceStats = () => { - const getStats = useCloudPostureStatsApi(); - // TODO: add error/loading state - if (!getStats.isSuccess) return null; - const { postureScore, benchmarksStats: benchmarks } = getStats.data; - - // TODO: in case we dont have a full length trend we will need to handle the sparkline chart alone. not rendering anything is just a temporary solution - if (!benchmarks || !postureScore) return null; - - // TODO: mock data, needs BE - // const scoreTrend = [ - // [0, 0], - // [1, 10], - // [2, 100], - // [3, 50], - // [4, postureScore], - // ] as Trend; - // - // const scoreChange = getScoreTrendPercentage(scoreTrend); - // const isPositiveChange = scoreChange > 0; - - const stats = [ - { - title: postureScore, - description: TEXT.POSTURE_SCORE, - titleColor: getTitleColor(postureScore), - iconType: getScoreIcon(postureScore), - }, - { - // TODO: remove placeholder for the commented out component, needs BE - title: placeholder, - description: TEXT.POSTURE_SCORE_TREND, - }, - // { - // title: ( - // - // - // {`${scoreChange}%`} - // - // ), - // description: 'Posture Score Trend', - // titleColor: isPositiveChange ? 'success' : 'danger', - // renderBody: ( - // <> - // - // - // - // - // - // ), - // }, - { - // TODO: this should count only ACTIVE benchmarks. needs BE - title: benchmarks.length, - description: TEXT.ACTIVE_FRAMEWORKS, - }, - { - // TODO: should be relatively simple to return from BE. needs BE - title: placeholder, - description: TEXT.TOTAL_RESOURCES, - }, - ]; - - return ( - - {stats.map((s) => ( - - - - { - // s.renderBody || - - } - - - - ))} - - ); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts index 0c750e10f060..6b2c00c507e6 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts @@ -5,45 +5,45 @@ * 2.0. */ -import { getTop5Risks, RisksTableProps } from './risks_table'; +import { getTopRisks, RisksTableProps } from './risks_table'; const podsAgg = { - resourceType: 'pods', + name: 'pods', totalFindings: 2, totalPassed: 1, totalFailed: 1, }; const etcdAgg = { - resourceType: 'etcd', + name: 'etcd', totalFindings: 5, totalPassed: 0, totalFailed: 5, }; const clusterAgg = { - resourceType: 'cluster', + name: 'cluster', totalFindings: 2, totalPassed: 2, totalFailed: 0, }; const systemAgg = { - resourceType: 'system', + name: 'system', totalFindings: 10, totalPassed: 6, totalFailed: 4, }; const apiAgg = { - resourceType: 'api', + name: 'api', totalFindings: 19100, totalPassed: 2100, totalFailed: 17000, }; const serverAgg = { - resourceType: 'server', + name: 'server', totalFindings: 7, totalPassed: 4, totalFailed: 3, @@ -58,16 +58,16 @@ const mockData: RisksTableProps['data'] = [ serverAgg, ]; -describe('getTop5Risks', () => { +describe('getTopRisks', () => { it('returns sorted by failed findings', () => { - expect(getTop5Risks([systemAgg, etcdAgg, apiAgg])).toEqual([apiAgg, etcdAgg, systemAgg]); + expect(getTopRisks([systemAgg, etcdAgg, apiAgg], 3)).toEqual([apiAgg, etcdAgg, systemAgg]); }); it('return array filtered with failed findings only', () => { - expect(getTop5Risks([systemAgg, clusterAgg, apiAgg])).toEqual([apiAgg, systemAgg]); + expect(getTopRisks([systemAgg, clusterAgg, apiAgg], 3)).toEqual([apiAgg, systemAgg]); }); - it('return sorted and filtered array with no more then 5 elements', () => { - expect(getTop5Risks(mockData)).toEqual([apiAgg, etcdAgg, systemAgg, serverAgg, podsAgg]); + it('return sorted and filtered array with the correct number of elements', () => { + expect(getTopRisks(mockData, 5)).toEqual([apiAgg, etcdAgg, systemAgg, serverAgg, podsAgg]); }); }); 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 fb43a8129ed7..1e355b3f3c82 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { EuiBasicTable, EuiButtonEmpty, @@ -14,50 +14,44 @@ import { EuiLink, EuiText, } from '@elastic/eui'; -import type { Query } from '@kbn/es-query'; -import { useHistory } from 'react-router-dom'; -import { CloudPostureStats, ResourceTypeAgg } from '../../../../common/types'; -import { allNavigationItems } from '../../../common/navigation/constants'; -import { encodeQuery } from '../../../common/navigation/query_utils'; +import { CloudPostureStats, ResourceType } from '../../../../common/types'; import { CompactFormattedNumber } from '../../../components/compact_formatted_number'; import * as TEXT from '../translations'; -import { RULE_FAILED } from '../../../../common/constants'; +import { INTERNAL_FEATURE_FLAGS } from '../../../../common/constants'; -// TODO: remove this option after we get data from the beat -const useMockData: boolean = false; -const mock = [ +const mockData = [ { - resourceType: 'pods', + name: 'pods', totalFindings: 2, totalPassed: 1, totalFailed: 1, }, { - resourceType: 'etcd', + name: 'etcd', totalFindings: 5, totalPassed: 0, totalFailed: 5, }, { - resourceType: 'cluster', + name: 'cluster', totalFindings: 2, totalPassed: 2, totalFailed: 0, }, { - resourceType: 'system', + name: 'system', totalFindings: 10, totalPassed: 6, totalFailed: 4, }, { - resourceType: 'api', + name: 'api', totalFindings: 19100, totalPassed: 2100, totalFailed: 17000, }, { - resourceType: 'server', + name: 'server', totalFindings: 7, totalPassed: 4, totalFailed: 3, @@ -65,62 +59,41 @@ const mock = [ ]; export interface RisksTableProps { - data: CloudPostureStats['resourceTypesAggs']; + data: CloudPostureStats['resourcesTypes']; + maxItems: number; + onCellClick: (resourceTypeName: string) => void; + onViewAllClick: () => void; } -const maxRisks = 5; - -export const getTop5Risks = (resourceTypesAggs: CloudPostureStats['resourceTypesAggs']) => { - const filtered = resourceTypesAggs.filter((x) => x.totalFailed > 0); +export const getTopRisks = ( + resourcesTypes: CloudPostureStats['resourcesTypes'], + maxItems: number +) => { + const filtered = resourcesTypes.filter((x) => x.totalFailed > 0); const sorted = filtered.slice().sort((first, second) => second.totalFailed - first.totalFailed); - return sorted.slice(0, maxRisks); + return sorted.slice(0, maxItems); }; -const getFailedFindingsQuery = (): Query => ({ - language: 'kuery', - query: `result.evaluation : "${RULE_FAILED}" `, -}); - -const getResourceTypeFailedFindingsQuery = (resourceType: string): Query => ({ - language: 'kuery', - query: `resource.type : "${resourceType}" and result.evaluation : "${RULE_FAILED}" `, -}); - -export const RisksTable = ({ data: resourceTypesAggs }: RisksTableProps) => { - const { push } = useHistory(); - - const handleCellClick = useCallback( - (resourceType: ResourceTypeAgg['resourceType']) => - push({ - pathname: allNavigationItems.findings.path, - search: encodeQuery(getResourceTypeFailedFindingsQuery(resourceType)), - }), - [push] - ); - - const handleViewAllClick = useCallback( - () => - push({ - pathname: allNavigationItems.findings.path, - search: encodeQuery(getFailedFindingsQuery()), - }), - [push] - ); - +export const RisksTable = ({ + data: resourcesTypes, + maxItems, + onCellClick, + onViewAllClick, +}: RisksTableProps) => { const columns = useMemo( () => [ { - field: 'resourceType', + field: 'name', name: TEXT.RESOURCE_TYPE, - render: (resourceType: ResourceTypeAgg['resourceType']) => ( - handleCellClick(resourceType)}>{resourceType} + render: (resourceTypeName: ResourceType['name']) => ( + onCellClick(resourceTypeName)}>{resourceTypeName} ), }, { field: 'totalFailed', - name: TEXT.FAILED_FINDINGS, - render: (totalFailed: ResourceTypeAgg['totalFailed'], resource: ResourceTypeAgg) => ( + name: TEXT.FINDINGS, + render: (totalFailed: ResourceType['totalFailed'], resource: ResourceType) => ( <> @@ -133,22 +106,24 @@ export const RisksTable = ({ data: resourceTypesAggs }: RisksTableProps) => { ), }, ], - [handleCellClick] + [onCellClick] ); + const items = useMemo(() => getTopRisks(resourcesTypes, maxItems), [resourcesTypes, maxItems]); + return ( - - rowHeader="resourceType" - items={useMockData ? getTop5Risks(mock) : getTop5Risks(resourceTypesAggs)} + + rowHeader="name" + items={INTERNAL_FEATURE_FLAGS.showRisksMock ? getTopRisks(mockData, maxItems) : items} columns={columns} /> - + {TEXT.VIEW_ALL_FAILED_FINDINGS} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/score_per_account_chart.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/score_per_account_chart.tsx deleted file mode 100644 index fd47a3ecf9e4..000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/score_per_account_chart.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { Axis, BarSeries, Chart, Settings } from '@elastic/charts'; -import { statusColors } from '../../../common/constants'; - -// soon to be deprecated -export const ScorePerAccountChart = () => { - return ( - - - - `${Number(v * 100).toFixed(0)}%`, - }} - id="bars" - data={[]} - xAccessor={'resource'} - yAccessors={['value']} - splitSeriesAccessors={['evaluation']} - stackAccessors={['evaluation']} - stackMode="percentage" - /> - - ); -}; - -const theme = { - colors: { vizColors: [statusColors.success, statusColors.danger] }, - barSeriesStyle: { - displayValue: { - fontSize: 14, - fill: { color: 'white', borderColor: 'blue', borderWidth: 0 }, - offsetX: 5, - offsetY: -5, - }, - }, -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx index a21ac4877f1c..07b5294a8d4a 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx @@ -37,6 +37,7 @@ export const ComplianceDashboard = () => { pageHeader={{ pageTitle: TEXT.CLOUD_POSTURE, }} + restrictWidth={1600} >
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx index fcbfe47ea6d2..0fc8f0d3d0be 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx @@ -7,27 +7,27 @@ import React from 'react'; import { - EuiFlexGrid, EuiFlexItem, EuiPanel, EuiIcon, - EuiTitle, EuiSpacer, - EuiDescriptionList, + EuiFlexGroup, + EuiText, + EuiButtonEmpty, + useEuiTheme, } from '@elastic/eui'; +import moment from 'moment'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; -import { Query } from '@kbn/es-query'; -import { useHistory } from 'react-router-dom'; import { PartitionElementEvent } from '@elastic/charts'; +import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types'; import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart'; -import { ComplianceTrendChart } from '../compliance_charts/compliance_trend_chart'; import { useCloudPostureStatsApi } from '../../../common/api/use_cloud_posture_stats_api'; -import { CspHealthBadge } from '../../../components/csp_health_badge'; import { ChartPanel } from '../../../components/chart_panel'; import * as TEXT from '../translations'; -import { allNavigationItems } from '../../../common/navigation/constants'; -import { encodeQuery } from '../../../common/navigation/query_utils'; import { Evaluation } from '../../../../common/types'; +import { RisksTable } from '../compliance_charts/risks_table'; +import { INTERNAL_FEATURE_FLAGS, RULE_FAILED } from '../../../../common/constants'; +import { useNavigateFindings } from '../../../common/hooks/use_navigate_findings'; const logoMap: ReadonlyMap = new Map([['CIS Kubernetes', 'logoKubernetes']]); @@ -35,118 +35,119 @@ const getBenchmarkLogo = (benchmarkName: string): EuiIconType => { return logoMap.get(benchmarkName) ?? 'logoElastic'; }; -const getBenchmarkEvaluationQuery = (name: string, evaluation: Evaluation): Query => ({ - language: 'kuery', - query: `rule.benchmark : "${name}" and result.evaluation : "${evaluation}"`, -}); +const mockClusterId = '2468540'; + +const cardHeight = 300; export const BenchmarksSection = () => { - const history = useHistory(); + const { euiTheme } = useEuiTheme(); + const navToFindings = useNavigateFindings(); const getStats = useCloudPostureStatsApi(); - const benchmarks = getStats.isSuccess && getStats.data.benchmarksStats; - if (!benchmarks) return null; + const clusters = getStats.isSuccess && getStats.data.clusters; + if (!clusters) return null; - const handleElementClick = (name: string, elements: PartitionElementEvent[]) => { + const handleElementClick = (clusterId: string, elements: PartitionElementEvent[]) => { const [element] = elements; const [layerValue] = element; - const rollupValue = layerValue[0].groupByRollup as Evaluation; + const evaluation = layerValue[0].groupByRollup as Evaluation; - history.push({ - pathname: allNavigationItems.findings.path, - search: encodeQuery(getBenchmarkEvaluationQuery(name, rollupValue)), + navToFindings({ cluster_id: clusterId, 'result.evaluation': evaluation }); + }; + + const handleCellClick = (clusterId: string, resourceTypeName: string) => { + navToFindings({ + cluster_id: clusterId, + 'resource.type': resourceTypeName, + 'result.evaluation': RULE_FAILED, }); }; + const handleViewAllClick = (clusterId: string) => { + navToFindings({ cluster_id: clusterId, 'result.evaluation': RULE_FAILED }); + }; + return ( <> - {benchmarks.map((benchmark) => ( - - - - - - -

{benchmark.name}

-
-
- - - - handleElementClick(benchmark.name, elements) - } - /> - - ), - }, - ]} - /> - - - - {/* TODO: no api for this chart yet, using empty state for now. needs BE */} - - - ), - }, - ]} - /> - - - - ) : ( - TEXT.ERROR - ), - }, - { - title: TEXT.TOTAL_FAILURES, - description: benchmark.totalFailed || TEXT.ERROR, - }, - ]} - /> - -
-
- ))} + {clusters.map((cluster) => { + const shortId = cluster.meta.clusterId.slice(0, 6); + + return ( + <> + + + + + + +

{cluster.meta.benchmarkName}

+
+ +

{`Cluster ID ${shortId || mockClusterId}`}

+
+ + + + {` ${moment(cluster.meta.lastUpdate).fromNow()}`} + +
+ + + + + {INTERNAL_FEATURE_FLAGS.showManageRulesMock && ( + {'Manage Rules'} + )} + +
+
+ + + + handleElementClick(cluster.meta.clusterId, elements) + } + /> + + + + + + handleCellClick(cluster.meta.clusterId, resourceTypeName) + } + onViewAllClick={() => handleViewAllClick(cluster.meta.clusterId)} + /> + + +
+
+ + + ); + })} ); }; + +const getIntegrationBoxStyle = (euiTheme: EuiThemeComputed) => ({ + border: `1px solid ${euiTheme.colors.lightShade}`, + borderRadius: `${euiTheme.border.radius.medium} 0 0 ${euiTheme.border.radius.medium}`, + background: euiTheme.colors.lightestShade, +}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx index db768aa5c7b7..01dd07290747 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx @@ -7,23 +7,16 @@ import React from 'react'; import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; import { PartitionElementEvent } from '@elastic/charts'; -import { Query } from '@kbn/es-query'; -import { ScorePerAccountChart } from '../compliance_charts/score_per_account_chart'; import { ChartPanel } from '../../../components/chart_panel'; import { useCloudPostureStatsApi } from '../../../common/api'; import * as TEXT from '../translations'; import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart'; -import { allNavigationItems } from '../../../common/navigation/constants'; -import { encodeQuery } from '../../../common/navigation/query_utils'; import { Evaluation } from '../../../../common/types'; import { RisksTable } from '../compliance_charts/risks_table'; - -const getEvaluationQuery = (evaluation: Evaluation): Query => ({ - language: 'kuery', - query: `"result.evaluation : "${evaluation}"`, -}); +import { CasesTable } from '../compliance_charts/cases_table'; +import { useNavigateFindings } from '../../../common/hooks/use_navigate_findings'; +import { RULE_FAILED } from '../../../../common/constants'; const defaultHeight = 360; @@ -33,19 +26,24 @@ const summarySectionWrapperStyle = { }; export const SummarySection = () => { - const history = useHistory(); + const navToFindings = useNavigateFindings(); const getStats = useCloudPostureStatsApi(); if (!getStats.isSuccess) return null; const handleElementClick = (elements: PartitionElementEvent[]) => { const [element] = elements; const [layerValue] = element; - const rollupValue = layerValue[0].groupByRollup as Evaluation; + const evaluation = layerValue[0].groupByRollup as Evaluation; + + navToFindings({ 'result.evaluation': evaluation }); + }; - history.push({ - pathname: allNavigationItems.findings.path, - search: encodeQuery(getEvaluationQuery(rollupValue)), - }); + const handleCellClick = (resourceTypeName: string) => { + navToFindings({ 'resource.type': resourceTypeName, 'result.evaluation': RULE_FAILED }); + }; + + const handleViewAllClick = () => { + navToFindings({ 'result.evaluation': RULE_FAILED }); }; return ( @@ -58,24 +56,28 @@ export const SummarySection = () => { >
- + - {/* TODO: no api for this chart yet, using empty state for now. needs BE */} - + diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts index 975c0069f147..87193ef67fa3 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts @@ -15,16 +15,17 @@ export const CLOUD_POSTURE_SCORE = i18n.translate('xpack.csp.cloud_posture_score defaultMessage: 'Cloud Posture Score', }); -export const RISKS = i18n.translate('xpack.csp.risks', { - defaultMessage: 'Risks', +export const RISKS = i18n.translate('xpack.csp.complianceDashboard.failedFindingsChartLabel', { + defaultMessage: 'Failed Findings', +}); + +export const OPEN_CASES = i18n.translate('xpack.csp.open_cases', { + defaultMessage: 'Open Cases', }); -export const SCORE_PER_CLUSTER_CHART_TITLE = i18n.translate( - 'xpack.csp.score_per_cluster_chart_title', - { - defaultMessage: 'Score Per Account / Cluster', - } -); +export const COMING_SOON = i18n.translate('xpack.csp.coming_soon', { + defaultMessage: 'Coming soon', +}); export const COMPLIANCE_SCORE = i18n.translate('xpack.csp.compliance_score', { defaultMessage: 'Compliance Score', @@ -78,10 +79,6 @@ export const RESOURCE_TYPE = i18n.translate('xpack.csp.resource_type', { defaultMessage: 'Resource Type', }); -export const FAILED_FINDINGS = i18n.translate('xpack.csp.failed_findings', { - defaultMessage: 'Failed Findings', -}); - -export const NO_DATA_TO_DISPLAY = i18n.translate('xpack.csp.complianceDashboard.noDataLabel', { - defaultMessage: 'No data to display', +export const FINDINGS = i18n.translate('xpack.csp.findings', { + defaultMessage: 'Findings', }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.test.tsx index 8678993a7380..54f1ecf9f31e 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.test.tsx @@ -7,49 +7,31 @@ import React from 'react'; import type { UseQueryResult } from 'react-query'; import { render, screen } from '@testing-library/react'; +import { useKubebeatDataView } from '../../common/api/use_kubebeat_data_view'; import { Findings } from './findings'; -import { MISSING_KUBEBEAT } from './translations'; import { TestProvider } from '../../test/test_provider'; -import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +import { + dataPluginMock, + type Start as DataPluginStart, +} from '../../../../../../src/plugins/data/public/mocks'; import { createStubDataView } from '../../../../../../src/plugins/data_views/public/data_views/data_view.stub'; -import { useKubebeatDataView } from './utils'; import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; import * as TEST_SUBJECTS from './test_subjects'; import type { DataView } from '../../../../../../src/plugins/data/common'; -jest.mock('./utils'); +jest.mock('../../common/api/use_kubebeat_data_view'); beforeEach(() => { jest.restoreAllMocks(); }); -const Wrapper = ({ data = dataPluginMock.createStartContract() }) => ( +const Wrapper = ({ data = dataPluginMock.createStartContract() }: { data: DataPluginStart }) => ( ); describe('', () => { - it("renders the error state component when 'kubebeat' DataView doesn't exists", async () => { - (useKubebeatDataView as jest.Mock).mockReturnValue({ - status: 'success', - } as UseQueryResult); - - render(); - - expect(await screen.findByText(MISSING_KUBEBEAT)).toBeInTheDocument(); - }); - - it("renders the error state component when 'kubebeat' request status is 'error'", async () => { - (useKubebeatDataView as jest.Mock).mockReturnValue({ - status: 'error', - } as UseQueryResult); - - render(); - - expect(await screen.findByText(MISSING_KUBEBEAT)).toBeInTheDocument(); - }); - it("renders the success state component when 'kubebeat' DataView exists and request status is 'success'", async () => { const data = dataPluginMock.createStartContract(); const source = await data.search.searchSource.create(); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx index 801632d4915b..6ed043361b44 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx @@ -5,15 +5,13 @@ * 2.0. */ import React from 'react'; -import { EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; import type { EuiPageHeaderProps } from '@elastic/eui'; +import { useKubebeatDataView } from '../../common/api/use_kubebeat_data_view'; import { allNavigationItems } from '../../common/navigation/constants'; import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs'; import { FindingsContainer } from './findings_container'; import { CspPageTemplate } from '../../components/page_template'; -import { useKubebeatDataView } from './utils'; -import * as TEST_SUBJECTS from './test_subjects'; -import { FINDINGS, MISSING_KUBEBEAT } from './translations'; +import { FINDINGS } from './translations'; const pageHeader: EuiPageHeaderProps = { pageTitle: FINDINGS, @@ -24,27 +22,11 @@ export const Findings = () => { useCspBreadcrumbs([allNavigationItems.findings]); return ( + // `CspPageTemplate` takes care of loading and error states for the kubebeat data view, no need to handle them here - {dataView.status === 'loading' && } - {(dataView.status === 'error' || (dataView.status !== 'loading' && !dataView.data)) && ( - - )} {dataView.status === 'success' && dataView.data && ( )} ); }; - -const LoadingPrompt = () => } />; - -// TODO: follow https://elastic.github.io/eui/#/display/empty-prompt/guidelines -const ErrorPrompt = () => ( - {MISSING_KUBEBEAT}} - /> -); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.test.tsx index 0fa106fda1c8..085baae19899 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.test.tsx @@ -54,7 +54,8 @@ const getFakeFindings = (): CspFinding & { id: string } => ({ type TableProps = PropsOf; -describe('', () => { +// FLAKY: https://github.com/elastic/kibana/issues/126664 +describe.skip('', () => { it('renders the zero state when status success and data has a length of zero ', async () => { const props: TableProps = { status: 'success', diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/test_subjects.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/test_subjects.ts index 06c1888a0912..51bcafd46060 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/test_subjects.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/test_subjects.ts @@ -8,5 +8,4 @@ export const FINDINGS_SEARCH_BAR = 'findings_search_bar'; export const FINDINGS_TABLE = 'findings_table'; export const FINDINGS_CONTAINER = 'findings_container'; -export const FINDINGS_MISSING_INDEX = 'findings_page_missing_dataview'; export const FINDINGS_TABLE_ZERO_STATE = 'findings_table_zero_state'; 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 0205a6ee0e68..3517589a37a5 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 @@ -30,10 +30,6 @@ export const FINDINGS = i18n.translate('xpack.csp.findings', { defaultMessage: 'Findings', }); -export const MISSING_KUBEBEAT = i18n.translate('xpack.csp.kubebeatDataViewIsMissing', { - defaultMessage: 'Kubebeat DataView is missing', -}); - export const RESOURCE = i18n.translate('xpack.csp.resource', { defaultMessage: 'Resource', }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts index c1b83bc671d1..38228e513e31 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts @@ -67,9 +67,10 @@ const mapEsQuerySortKey = (sort: readonly EsQuerySortValue[]): EsQuerySortValue[ }, []); const showResponseErrorToast = - ({ toasts: { addDanger } }: CoreStart['notifications']) => + ({ toasts }: CoreStart['notifications']) => (error: unknown): void => { - addDanger(extractErrorMessage(error, TEXT.SEARCH_FAILED)); + if (error instanceof Error) toasts.addError(error, { title: TEXT.SEARCH_FAILED }); + else toasts.addDanger(extractErrorMessage(error, TEXT.SEARCH_FAILED)); }; const extractFindings = ({ diff --git a/x-pack/plugins/cloud_security_posture/public/pages/index.ts b/x-pack/plugins/cloud_security_posture/public/pages/index.ts index 55d62913e447..1e667a8949fc 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/index.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/index.ts @@ -8,3 +8,4 @@ export { Findings } from './findings'; export * from './compliance_dashboard'; export { Benchmarks } from './benchmarks'; +export { Rules } from './rules'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx new file mode 100644 index 000000000000..0b511c9fb903 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/index.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 from 'react'; +import type { EuiPageHeaderProps } from '@elastic/eui'; +import { CspPageTemplate } from '../../components/page_template'; +import { RulesContainer } from './rules_container'; +import { allNavigationItems } from '../../common/navigation/constants'; +import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs'; + +// TODO: +// - get selected integration + +const pageHeader: EuiPageHeaderProps = { + pageTitle: 'Rules', +}; + +const breadcrumbs = [allNavigationItems.rules]; + +export const Rules = () => { + useCspBreadcrumbs(breadcrumbs); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_bottom_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_bottom_bar.tsx new file mode 100644 index 000000000000..ebf4913f895c --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_bottom_bar.tsx @@ -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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiBottomBar, EuiButton } from '@elastic/eui'; +import * as TEST_SUBJECTS from './test_subjects'; +import * as TEXT from './translations'; + +interface RulesBottomBarProps { + onSave(): void; + onCancel(): void; + isLoading: boolean; +} + +export const RulesBottomBar = ({ onSave, onCancel, isLoading }: RulesBottomBarProps) => ( + + + + + {TEXT.CANCEL} + + + + + {TEXT.SAVE} + + + + +); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_bulk_actions_menu.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_bulk_actions_menu.tsx new file mode 100644 index 000000000000..1f264fd33e1c --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_bulk_actions_menu.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 React, { useState } from 'react'; +import { EuiContextMenuPanel, EuiContextMenuItem, EuiPopover, EuiButtonEmpty } from '@elastic/eui'; +import * as TEST_SUBJECTS from './test_subjects'; +import * as TEXT from './translations'; + +interface RulesBulkActionsMenuProps { + items: ReadonlyArray>; +} + +export const RulesBulkActionsMenu = ({ items }: RulesBulkActionsMenuProps) => { + const [isPopoverOpen, setPopover] = useState(false); + const onButtonClick = () => setPopover(!isPopoverOpen); + const closePopover = () => setPopover(false); + + const panelItems = items.map((item, i) => ( + { + closePopover(); + item.onClick?.(e); + }} + /> + )); + + const button = ( + + {TEXT.BULK_ACTIONS} + + ); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.test.tsx new file mode 100644 index 000000000000..bcbc4d5c4bd4 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.test.tsx @@ -0,0 +1,339 @@ +/* + * Copyright 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 { RulesContainer } from './rules_container'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { QueryClient } from 'react-query'; +import { useFindCspRules, useBulkUpdateCspRules, type RuleSavedObject } from './use_csp_rules'; +import * as TEST_SUBJECTS from './test_subjects'; +import { Chance } from 'chance'; +import { TestProvider } from '../../test/test_provider'; + +const chance = new Chance(); + +jest.mock('./use_csp_rules', () => ({ + useFindCspRules: jest.fn(), + useBulkUpdateCspRules: jest.fn(), +})); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, +}); + +const getWrapper = + (): React.FC => + ({ children }) => + {children}; + +const getRuleMock = ({ id = chance.guid(), enabled }: { id?: string; enabled: boolean }) => + ({ + id, + updatedAt: chance.date().toISOString(), + attributes: { + id, + name: chance.word(), + enabled, + }, + } as RuleSavedObject); + +describe('', () => { + beforeEach(() => { + queryClient.clear(); + jest.clearAllMocks(); + (useBulkUpdateCspRules as jest.Mock).mockReturnValue({ + status: 'idle', + mutate: jest.fn(), + }); + }); + + it('displays rules with their initial state', async () => { + const Wrapper = getWrapper(); + const rule1 = getRuleMock({ enabled: true }); + + (useFindCspRules as jest.Mock).mockReturnValue({ + status: 'success', + data: { + total: 1, + savedObjects: [rule1], + }, + }); + + render( + + + + ); + + expect(await screen.findByTestId(TEST_SUBJECTS.CSP_RULES_CONTAINER)).toBeInTheDocument(); + expect(await screen.findByText(rule1.attributes.name)).toBeInTheDocument(); + expect( + screen + .getByTestId(TEST_SUBJECTS.getCspRulesTableItemSwitchTestId(rule1.id)) + .getAttribute('aria-checked') + ).toEqual('true'); + }); + + it('toggles rules locally', () => { + const Wrapper = getWrapper(); + const rule1 = getRuleMock({ enabled: false }); + const rule2 = getRuleMock({ enabled: true }); + + (useFindCspRules as jest.Mock).mockReturnValue({ + status: 'success', + data: { + total: 2, + savedObjects: [rule1, rule2], + }, + }); + + render( + + + + ); + + const switchId1 = TEST_SUBJECTS.getCspRulesTableItemSwitchTestId(rule1.id); + const switchId2 = TEST_SUBJECTS.getCspRulesTableItemSwitchTestId(rule2.id); + + fireEvent.click(screen.getByTestId(switchId1)); + fireEvent.click(screen.getByTestId(switchId2)); + + expect(screen.getByTestId(switchId1).getAttribute('aria-checked')).toEqual( + (!rule1.attributes.enabled).toString() + ); + expect(screen.getByTestId(switchId2).getAttribute('aria-checked')).toEqual( + (!rule2.attributes.enabled).toString() + ); + }); + + it('bulk toggles rules locally', () => { + const Wrapper = getWrapper(); + const rule1 = getRuleMock({ enabled: true }); + const rule2 = getRuleMock({ enabled: true }); + const rule3 = getRuleMock({ enabled: false }); + + (useFindCspRules as jest.Mock).mockReturnValue({ + status: 'success', + data: { + total: 3, + savedObjects: [rule1, rule2, rule3], + }, + }); + + render( + + + + ); + + const switchId1 = TEST_SUBJECTS.getCspRulesTableItemSwitchTestId(rule1.id); + const switchId2 = TEST_SUBJECTS.getCspRulesTableItemSwitchTestId(rule2.id); + const switchId3 = TEST_SUBJECTS.getCspRulesTableItemSwitchTestId(rule3.id); + + fireEvent.click(screen.getByTestId('checkboxSelectAll')); + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_TABLE_BULK_MENU_BUTTON)); + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_TABLE_BULK_DISABLE_BUTTON)); + + expect(screen.getByTestId(switchId1).getAttribute('aria-checked')).toEqual( + (!rule1.attributes.enabled).toString() + ); + expect(screen.getByTestId(switchId2).getAttribute('aria-checked')).toEqual( + (!rule2.attributes.enabled).toString() + ); + expect(screen.getByTestId(switchId3).getAttribute('aria-checked')).toEqual( + rule3.attributes.enabled.toString() + ); + }); + + it('updates rules with local changes done by non-bulk toggles', () => { + const Wrapper = getWrapper(); + const rule1 = getRuleMock({ enabled: false }); + const rule2 = getRuleMock({ enabled: true }); + const rule3 = getRuleMock({ enabled: true }); + + (useFindCspRules as jest.Mock).mockReturnValue({ + status: 'success', + data: { + total: 3, + savedObjects: [rule1, rule2, rule3], + }, + }); + + render( + + + + ); + const { mutate } = useBulkUpdateCspRules(); + + const switchId1 = TEST_SUBJECTS.getCspRulesTableItemSwitchTestId(rule1.id); + const switchId2 = TEST_SUBJECTS.getCspRulesTableItemSwitchTestId(rule2.id); + const switchId3 = TEST_SUBJECTS.getCspRulesTableItemSwitchTestId(rule3.id); + + fireEvent.click(screen.getByTestId(switchId1)); + fireEvent.click(screen.getByTestId(switchId2)); + fireEvent.click(screen.getByTestId(switchId3)); // adds + fireEvent.click(screen.getByTestId(switchId3)); // removes + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_SAVE_BUTTON)); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith([ + { ...rule1.attributes, enabled: !rule1.attributes.enabled }, + { ...rule2.attributes, enabled: !rule2.attributes.enabled }, + ]); + }); + + it('updates rules with local changes done by bulk toggles', () => { + const Wrapper = getWrapper(); + const rule1 = getRuleMock({ enabled: false }); + const rule2 = getRuleMock({ enabled: true }); + const rule3 = getRuleMock({ enabled: true }); + + (useFindCspRules as jest.Mock).mockReturnValue({ + status: 'success', + data: { + total: 3, + savedObjects: [rule1, rule2, rule3], + }, + }); + + render( + + + + ); + const { mutate } = useBulkUpdateCspRules(); + + fireEvent.click(screen.getByTestId('checkboxSelectAll')); + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_TABLE_BULK_MENU_BUTTON)); + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_TABLE_BULK_ENABLE_BUTTON)); // This should only change rule1 + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_SAVE_BUTTON)); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith([ + { ...rule1.attributes, enabled: !rule1.attributes.enabled }, + ]); + }); + + it('only changes selected rules in bulk operations', () => { + const Wrapper = getWrapper(); + const rule1 = getRuleMock({ enabled: false }); + const rule2 = getRuleMock({ enabled: true }); + const rule3 = getRuleMock({ enabled: false }); + const rule4 = getRuleMock({ enabled: false }); + const rule5 = getRuleMock({ enabled: true }); + + (useFindCspRules as jest.Mock).mockReturnValue({ + status: 'success', + data: { + total: 4, + savedObjects: [rule1, rule2, rule3, rule4, rule5], + }, + }); + + render( + + + + ); + const { mutate } = useBulkUpdateCspRules(); + + fireEvent.click(screen.getByTestId(`checkboxSelectRow-${rule1.id}`)); // changes + fireEvent.click(screen.getByTestId(`checkboxSelectRow-${rule2.id}`)); // doesn't change + fireEvent.click(screen.getByTestId(`checkboxSelectRow-${rule4.id}`)); // changes + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_TABLE_BULK_MENU_BUTTON)); + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_TABLE_BULK_ENABLE_BUTTON)); + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.getCspRulesTableItemSwitchTestId(rule5.id))); // changes + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_SAVE_BUTTON)); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith([ + { ...rule1.attributes, enabled: !rule1.attributes.enabled }, + { ...rule4.attributes, enabled: !rule4.attributes.enabled }, + { ...rule5.attributes, enabled: !rule5.attributes.enabled }, + ]); + }); + + it('updates rules with changes of both bulk/non-bulk toggles', () => { + const Wrapper = getWrapper(); + const rule1 = getRuleMock({ enabled: false }); + const rule2 = getRuleMock({ enabled: true }); + const rule3 = getRuleMock({ enabled: false }); + const rule4 = getRuleMock({ enabled: false }); + const rule5 = getRuleMock({ enabled: true }); + + (useFindCspRules as jest.Mock).mockReturnValue({ + status: 'success', + data: { + total: 4, + savedObjects: [rule1, rule2, rule3, rule4, rule5], + }, + }); + + render( + + + + ); + + const { mutate } = useBulkUpdateCspRules(); + + fireEvent.click(screen.getByTestId(`checkboxSelectRow-${rule1.id}`)); // changes rule1 + fireEvent.click(screen.getByTestId(`checkboxSelectRow-${rule2.id}`)); // doesn't change + fireEvent.click(screen.getByTestId(`checkboxSelectRow-${rule4.id}`)); // changes rule4 + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_TABLE_BULK_MENU_BUTTON)); + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_TABLE_BULK_DISABLE_BUTTON)); // changes rule2 + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_TABLE_BULK_ENABLE_BUTTON)); // reverts rule2 + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.getCspRulesTableItemSwitchTestId(rule5.id))); // changes rule5 + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_SAVE_BUTTON)); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith([ + { ...rule1.attributes, enabled: !rule1.attributes.enabled }, + { ...rule4.attributes, enabled: !rule4.attributes.enabled }, + { ...rule5.attributes, enabled: !rule5.attributes.enabled }, + ]); + }); + + it('selects and updates all rules', async () => { + const Wrapper = getWrapper(); + const enabled = true; + const rules = Array.from({ length: 20 }, () => getRuleMock({ enabled })); + + (useFindCspRules as jest.Mock).mockReturnValue({ + status: 'success', + data: { + total: rules.length, + savedObjects: rules, + }, + }); + + render( + + + + ); + + const { mutate } = useBulkUpdateCspRules(); + + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_TABLE_SELECT_ALL_BUTTON)); + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_TABLE_BULK_MENU_BUTTON)); + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_TABLE_BULK_DISABLE_BUTTON)); + fireEvent.click(screen.getByTestId(TEST_SUBJECTS.CSP_RULES_SAVE_BUTTON)); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith( + rules.map((rule) => ({ + ...rule.attributes, + enabled: !enabled, + })) + ); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx new file mode 100644 index 000000000000..108d33cad3eb --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx @@ -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 React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; +import { type EuiBasicTable, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { extractErrorMessage } from '../../../common/utils/helpers'; +import { RulesTable } from './rules_table'; +import { RulesBottomBar } from './rules_bottom_bar'; +import { RulesTableHeader } from './rules_table_header'; +import { + useFindCspRules, + useBulkUpdateCspRules, + type RuleSavedObject, + type RulesQuery, + type RulesQueryResult, +} from './use_csp_rules'; +import * as TEST_SUBJECTS from './test_subjects'; + +interface RulesPageData { + rules_page: RuleSavedObject[]; + all_rules: RuleSavedObject[]; + rules_map: Map; + total: number; + error?: string; + loading: boolean; +} + +export type RulesState = RulesPageData & RulesQuery; + +const getSimpleQueryString = (searchValue?: string): string => + searchValue ? `${searchValue}*` : ''; + +const getChangedRules = ( + baseRules: ReadonlyMap, + currentChangedRules: ReadonlyMap, + rulesToChange: readonly RuleSavedObject[] +): Map => { + const changedRules = new Map(currentChangedRules); + + rulesToChange.forEach((ruleToChange) => { + const baseRule = baseRules.get(ruleToChange.id); + const changedRule = changedRules.get(ruleToChange.id); + + if (!baseRule) throw new Error('expected base rule to exists'); + + const baseRuleChanged = baseRule.attributes.enabled !== ruleToChange.attributes.enabled; + + if (!changedRule && baseRuleChanged) changedRules.set(ruleToChange.id, ruleToChange); + + if (changedRule && !baseRuleChanged) changedRules.delete(ruleToChange.id); + }); + + return changedRules; +}; + +const getRulesPageData = ( + { status, data, error }: Pick, + changedRules: Map, + query: RulesQuery +): RulesPageData => { + const rules = data?.savedObjects || []; + const page = getPage(rules, query); + return { + loading: status === 'loading', + error: error ? extractErrorMessage(error) : undefined, + all_rules: rules, + rules_map: new Map(rules.map((rule) => [rule.id, rule])), + rules_page: page.map((rule) => changedRules.get(rule.attributes.id) || rule), + total: data?.total || 0, + }; +}; + +const getPage = (data: readonly RuleSavedObject[], { page, perPage }: RulesQuery) => + data.slice(page * perPage, (page + 1) * perPage); + +const MAX_ITEMS_PER_PAGE = 10000; + +export const RulesContainer = () => { + const tableRef = useRef(null); + const [changedRules, setChangedRules] = useState>(new Map()); + const [isAllSelected, setIsAllSelected] = useState(false); + const [visibleSelectedRulesIds, setVisibleSelectedRulesIds] = useState([]); + const [rulesQuery, setRulesQuery] = useState({ page: 0, perPage: 5, search: '' }); + + const { data, status, error, refetch } = useFindCspRules({ + search: getSimpleQueryString(rulesQuery.search), + page: 1, + perPage: MAX_ITEMS_PER_PAGE, + }); + + const { mutate: bulkUpdate, isLoading: isUpdating } = useBulkUpdateCspRules(); + + const rulesPageData = useMemo( + () => getRulesPageData({ data, error, status }, changedRules, rulesQuery), + [data, error, status, changedRules, rulesQuery] + ); + + const hasChanges = !!changedRules.size; + + const selectAll = () => { + if (!tableRef.current) return; + tableRef.current.setSelection(rulesPageData.rules_page); + setIsAllSelected(true); + }; + + const toggleRules = (rules: RuleSavedObject[], enabled: boolean) => + setChangedRules( + getChangedRules( + rulesPageData.rules_map, + changedRules, + rules.map((rule) => ({ + ...rule, + attributes: { ...rule.attributes, enabled }, + })) + ) + ); + + const bulkToggleRules = (enabled: boolean) => + toggleRules( + isAllSelected + ? rulesPageData.all_rules + : visibleSelectedRulesIds.map((ruleId) => rulesPageData.rules_map.get(ruleId)!), + enabled + ); + + const toggleRule = (rule: RuleSavedObject) => toggleRules([rule], !rule.attributes.enabled); + + const bulkUpdateRules = () => bulkUpdate([...changedRules].map(([, rule]) => rule.attributes)); + + const discardChanges = useCallback(() => setChangedRules(new Map()), []); + + const clearSelection = useCallback(() => { + if (!tableRef.current) return; + tableRef.current.setSelection([]); + setIsAllSelected(false); + }, []); + + useEffect(discardChanges, [data, discardChanges]); + useEffect(clearSelection, [rulesQuery, clearSelection]); + + return ( +
+ + setRulesQuery((currentQuery) => ({ ...currentQuery, search: value }))} + refresh={() => { + clearSelection(); + refetch(); + }} + bulkEnable={() => bulkToggleRules(true)} + bulkDisable={() => bulkToggleRules(false)} + selectAll={selectAll} + clearSelection={clearSelection} + selectedRulesCount={ + isAllSelected ? rulesPageData.all_rules.length : visibleSelectedRulesIds.length + } + searchValue={rulesQuery.search} + totalRulesCount={rulesPageData.all_rules.length} + isSearching={status === 'loading'} + /> + + { + setIsAllSelected(false); + setVisibleSelectedRulesIds(rules.map((rule) => rule.id)); + }} + setPagination={(paginationQuery) => + setRulesQuery((currentQuery) => ({ ...currentQuery, ...paginationQuery })) + } + /> + + {hasChanges && ( + + )} +
+ ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx new file mode 100644 index 000000000000..cc537ff19ed6 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx @@ -0,0 +1,135 @@ +/* + * Copyright 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, { useMemo } from 'react'; +import { + Criteria, + EuiLink, + EuiSwitch, + EuiTableFieldDataColumnType, + EuiBasicTable, + EuiBasicTableProps, +} from '@elastic/eui'; +import moment from 'moment'; +import type { RulesState } from './rules_container'; +import * as TEST_SUBJECTS from './test_subjects'; +import * as TEXT from './translations'; +import type { RuleSavedObject } from './use_csp_rules'; + +type RulesTableProps = Pick< + RulesState, + 'loading' | 'error' | 'rules_page' | 'total' | 'perPage' | 'page' +> & { + toggleRule(rule: RuleSavedObject): void; + setSelectedRules(rules: RuleSavedObject[]): void; + setPagination(pagination: Pick): void; + // ForwardRef makes this ref not available in parent callbacks + tableRef: React.RefObject>; +}; + +export const RulesTable = ({ + toggleRule, + setSelectedRules, + setPagination, + perPage: pageSize, + rules_page: items, + page, + tableRef, + total, + loading, + error, +}: RulesTableProps) => { + const columns = useMemo(() => getColumns({ toggleRule }), [toggleRule]); + + const euiPagination: EuiBasicTableProps['pagination'] = { + pageIndex: page, + pageSize, + totalItemCount: total, + pageSizeOptions: [1, 5, 10, 25], + hidePerPageOptions: false, + }; + + const selection: EuiBasicTableProps['selection'] = { + selectable: () => true, + onSelectionChange: setSelectedRules, + }; + + const onTableChange = ({ page: pagination }: Criteria) => { + if (!pagination) return; + setPagination({ page: pagination.index, perPage: pagination.size }); + }; + + return ( + v.id} + /> + ); +}; + +const ruleNameRenderer = (name: string) => ( + + {name} + +); + +const timestampRenderer = (timestamp: string) => + moment.duration(moment().diff(timestamp)).humanize(); + +interface GetColumnProps { + toggleRule: (rule: RuleSavedObject) => void; +} + +const createRuleEnabledSwitchRenderer = + ({ toggleRule }: GetColumnProps) => + (value: boolean, rule: RuleSavedObject) => + ( + toggleRule(rule)} + data-test-subj={TEST_SUBJECTS.getCspRulesTableItemSwitchTestId(rule.attributes.id)} + /> + ); + +const getColumns = ({ + toggleRule, +}: GetColumnProps): Array> => [ + { + field: 'attributes.name', + name: TEXT.RULE_NAME, + width: '60%', + truncateText: true, + render: ruleNameRenderer, + }, + { + field: 'section', // TODO: what field is this? + name: TEXT.SECTION, + width: '15%', + }, + { + field: 'updatedAt', + name: TEXT.UPDATED_AT, + width: '15%', + render: timestampRenderer, + }, + { + field: 'attributes.enabled', + name: TEXT.ENABLED, + render: createRuleEnabledSwitchRenderer({ toggleRule }), + width: '10%', + }, +]; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx new file mode 100644 index 000000000000..011cb5d8f7bc --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx @@ -0,0 +1,224 @@ +/* + * Copyright 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 { EuiFieldSearch, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/react'; +import useDebounce from 'react-use/lib/useDebounce'; +import * as TEST_SUBJECTS from './test_subjects'; +import * as TEXT from './translations'; +import { RulesBulkActionsMenu } from './rules_bulk_actions_menu'; + +interface RulesTableToolbarProps { + search(value: string): void; + refresh(): void; + bulkEnable(): void; + bulkDisable(): void; + selectAll(): void; + clearSelection(): void; + totalRulesCount: number; + selectedRulesCount: number; + searchValue: string; + isSearching: boolean; +} + +interface CounterProps { + count: number; +} + +interface ButtonProps { + onClick(): void; +} + +export const RulesTableHeader = ({ + search, + refresh, + bulkEnable, + bulkDisable, + selectAll, + clearSelection, + totalRulesCount, + selectedRulesCount, + searchValue, + isSearching, +}: RulesTableToolbarProps) => ( + + + + + + + +); + +const Counters = ({ total, selected }: { total: number; selected: number }) => ( + + + {Spacer} + + +); + +const SelectAllToggle = ({ + isSelectAll, + select, + clear, +}: { + select(): void; + clear(): void; + isSelectAll: boolean; +}) => ( + + {isSelectAll ? : } + +); + +const BulkMenu = ({ + bulkEnable, + bulkDisable, + selectedRulesCount, +}: Pick) => ( + + , + 'data-test-subj': TEST_SUBJECTS.CSP_RULES_TABLE_BULK_ENABLE_BUTTON, + onClick: bulkEnable, + }, + { + icon: 'eyeClosed', + disabled: !selectedRulesCount, + children: , + 'data-test-subj': TEST_SUBJECTS.CSP_RULES_TABLE_BULK_DISABLE_BUTTON, + onClick: bulkDisable, + }, + ]} + /> + +); + +const SEARCH_DEBOUNCE_MS = 300; + +const SearchField = ({ + search, + isSearching, + searchValue, +}: Pick) => { + const [localValue, setLocalValue] = useState(searchValue); + + useDebounce(() => search(localValue), SEARCH_DEBOUNCE_MS, [localValue]); + + return ( + + setLocalValue(e.target.value)} + style={{ minWidth: 150 }} + /> + + ); +}; + +const TotalRulesCount = ({ count }: CounterProps) => ( + }} + /> +); + +const SelectedRulesCount = ({ count }: CounterProps) => ( + }} + /> +); + +const ActivateRulesMenuItemText = ({ count }: CounterProps) => ( + +); + +const DeactivateRulesMenuItemText = ({ count }: CounterProps) => ( + +); + +const RulesCountBold = ({ count }: CounterProps) => ( + <> + {count} + + +); + +const ClearSelectionButton = ({ onClick }: ButtonProps) => ( + + + +); + +const SelectAllButton = ({ onClick }: ButtonProps) => ( + + + +); + +const RefreshButton = ({ onClick }: ButtonProps) => ( + + + {TEXT.REFRESH} + + +); + +const Spacer = ( + +); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/test_subjects.ts b/x-pack/plugins/cloud_security_posture/public/pages/rules/test_subjects.ts new file mode 100644 index 000000000000..6ba10ef937b6 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/test_subjects.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const CSP_RULES_CONTAINER = 'csp_rules_container'; +export const CSP_RULES_TABLE_ITEM_SWITCH = 'csp_rules_table_item_switch'; +export const CSP_RULES_SAVE_BUTTON = 'csp_rules_table_save_button'; +export const CSP_RULES_TABLE = 'csp_rules_table'; +export const CSP_RULES_TABLE_BULK_MENU_BUTTON = 'csp_rules_table_bulk_menu_button'; +export const CSP_RULES_TABLE_BULK_ENABLE_BUTTON = 'csp_rules_table_bulk_enable_button'; +export const CSP_RULES_TABLE_BULK_DISABLE_BUTTON = 'csp_rules_table_bulk_disable_button'; +export const CSP_RULES_TABLE_REFRESH_BUTTON = 'csp_rules_table_refresh_button'; +export const CSP_RULES_TABLE_SELECT_ALL_BUTTON = 'rules_select_all'; +export const CSP_RULES_TABLE_CLEAR_SELECTION_BUTTON = 'rules_clear_selection'; + +export const getCspRulesTableItemSwitchTestId = (id: string) => + `${CSP_RULES_TABLE_ITEM_SWITCH}_${id}`; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/translations.ts b/x-pack/plugins/cloud_security_posture/public/pages/rules/translations.ts new file mode 100644 index 000000000000..0f9c279ae4ba --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/translations.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SAVE = i18n.translate('xpack.csp.rules.saveButtonLabel', { + defaultMessage: 'Save', +}); + +export const CANCEL = i18n.translate('xpack.csp.rules.cancelButtonLabel', { + defaultMessage: 'Cancel', +}); + +export const UNKNOWN_ERROR = i18n.translate('xpack.csp.rules.unknownErrorMessage', { + defaultMessage: 'Unknown Error', +}); + +export const REFRESH = i18n.translate('xpack.csp.rules.refreshButtonLabel', { + defaultMessage: 'Refresh', +}); + +export const SEARCH = i18n.translate('xpack.csp.rules.searchPlaceholder', { + defaultMessage: 'Search', +}); + +export const BULK_ACTIONS = i18n.translate('xpack.csp.rules.bulkActionsButtonLabel', { + defaultMessage: 'Bulk Actions', +}); + +export const RULE_NAME = i18n.translate('xpack.csp.rules.ruleNameColumnHeaderLabel', { + defaultMessage: 'Rule Name', +}); + +export const SECTION = i18n.translate('xpack.csp.rules.sectionColumnHeaderLabel', { + defaultMessage: 'Section', +}); + +export const UPDATED_AT = i18n.translate('xpack.csp.rules.updatedAtColumnHeaderLabel', { + defaultMessage: 'Updated at', +}); + +export const ENABLED = i18n.translate('xpack.csp.rules.enabledColumnHeaderLabel', { + defaultMessage: 'Enabled', +}); + +export const DISABLE = i18n.translate('xpack.csp.rules.disableLabel', { + defaultMessage: 'Disable', +}); + +export const ENABLE = i18n.translate('xpack.csp.rules.enableLabel', { + defaultMessage: 'Enable', +}); + +export const MISSING_RULES = i18n.translate('xpack.csp.rules.missingRulesMessage', { + defaultMessage: 'Rules are missing', +}); + +export const UPDATE_FAILED = i18n.translate('xpack.csp.rules.updateFailedMessage', { + defaultMessage: 'Update failed', +}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_rules.ts b/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_rules.ts new file mode 100644 index 000000000000..32e1a2635a85 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_rules.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useQuery, useMutation, useQueryClient } from 'react-query'; +import { FunctionKeys } from 'utility-types'; +import { cspRuleAssetSavedObjectType, type CspRuleSchema } from '../../../common/schemas/csp_rule'; +import type { SavedObjectsFindOptions, SimpleSavedObject } from '../../../../../../src/core/public'; +import { useKibana } from '../../common/hooks/use_kibana'; +import { UPDATE_FAILED } from './translations'; + +export type RuleSavedObject = Omit< + SimpleSavedObject, + FunctionKeys +>; + +export type RulesQuery = Required>; +export type RulesQueryResult = ReturnType; + +export const useFindCspRules = ({ search, page, perPage }: RulesQuery) => { + const { savedObjects } = useKibana().services; + return useQuery( + [cspRuleAssetSavedObjectType, { search, page, perPage }], + () => + savedObjects.client.find({ + type: cspRuleAssetSavedObjectType, + search, + searchFields: ['name'], + page: 1, + // NOTE: 'name.raw' is a field mapping we defined on 'name' + sortField: 'name.raw', + perPage, + }), + { refetchOnWindowFocus: false } + ); +}; + +export const useBulkUpdateCspRules = () => { + const { savedObjects, notifications } = useKibana().services; + const queryClient = useQueryClient(); + + return useMutation( + (rules: CspRuleSchema[]) => + savedObjects.client.bulkUpdate( + rules.map((rule) => ({ + type: cspRuleAssetSavedObjectType, + id: rule.id, + attributes: rule, + })) + ), + { + onError: (err) => { + if (err instanceof Error) notifications.toasts.addError(err, { title: UPDATE_FAILED }); + else notifications.toasts.addDanger(UPDATE_FAILED); + }, + onSettled: () => + // Invalidate all queries for simplicity + queryClient.invalidateQueries({ + queryKey: cspRuleAssetSavedObjectType, + exact: false, + }), + } + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx b/x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx index 1bb04128d4a6..3806dcb95558 100755 --- a/x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx +++ b/x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx @@ -10,18 +10,19 @@ import { I18nProvider } from '@kbn/i18n-react'; import { Router, Switch, Route } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from 'react-query'; import { coreMock } from '../../../../../src/core/public/mocks'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import type { CspAppDeps } from '../application/app'; export const TestProvider: React.FC> = ({ core = coreMock.createStart(), - deps = {}, + deps = { data: dataPluginMock.createStartContract() }, params = coreMock.createAppMountParameters(), children, } = {}) => { const queryClient = useMemo(() => new QueryClient(), []); return ( - + diff --git a/x-pack/plugins/cloud_security_posture/server/index.ts b/x-pack/plugins/cloud_security_posture/server/index.ts index c0912e68218c..f790ac5256ff 100755 --- a/x-pack/plugins/cloud_security_posture/server/index.ts +++ b/x-pack/plugins/cloud_security_posture/server/index.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import type { PluginInitializerContext } from '../../../../src/core/server'; import { CspPlugin } from './plugin'; diff --git a/x-pack/plugins/cloud_security_posture/server/lib/csp_app_services.ts b/x-pack/plugins/cloud_security_posture/server/lib/csp_app_services.ts new file mode 100644 index 000000000000..f769ea171c17 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/lib/csp_app_services.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 { + AgentService, + PackageService, + AgentPolicyServiceInterface, + PackagePolicyServiceInterface, +} from '../../../fleet/server'; + +export interface CspAppServiceDependencies { + packageService: PackageService; + agentService: AgentService; + packagePolicyService: PackagePolicyServiceInterface; + agentPolicyService: AgentPolicyServiceInterface; +} + +export class CspAppService { + public agentService: AgentService | undefined; + public packageService: PackageService | undefined; + public packagePolicyService: PackagePolicyServiceInterface | undefined; + public agentPolicyService: AgentPolicyServiceInterface | undefined; + + public start(dependencies: CspAppServiceDependencies) { + this.agentService = dependencies.agentService; + this.packageService = dependencies.packageService; + this.packagePolicyService = dependencies.packagePolicyService; + this.agentPolicyService = dependencies.agentPolicyService; + } + + public stop() {} +} diff --git a/x-pack/plugins/cloud_security_posture/server/plugin.ts b/x-pack/plugins/cloud_security_posture/server/plugin.ts index 1225d3481d33..ce6e38e4c63c 100755 --- a/x-pack/plugins/cloud_security_posture/server/plugin.ts +++ b/x-pack/plugins/cloud_security_posture/server/plugin.ts @@ -12,6 +12,7 @@ import type { Plugin, Logger, } from '../../../../src/core/server'; +import { CspAppService } from './lib/csp_app_services'; import type { CspServerPluginSetup, CspServerPluginStart, @@ -19,6 +20,13 @@ import type { CspServerPluginStartDeps, } from './types'; import { defineRoutes } from './routes'; +import { cspRuleAssetType } from './saved_objects/cis_1_4_1/csp_rule_type'; +import { initializeCspRules } from './saved_objects/cis_1_4_1/initialize_rules'; + +export interface CspAppContext { + logger: Logger; + service: CspAppService; +} export class CspPlugin implements @@ -33,20 +41,34 @@ export class CspPlugin constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); } + private readonly CspAppService = new CspAppService(); public setup( core: CoreSetup, plugins: CspServerPluginSetupDeps ): CspServerPluginSetup { + const cspAppContext: CspAppContext = { + logger: this.logger, + service: this.CspAppService, + }; + + core.savedObjects.registerType(cspRuleAssetType); + const router = core.http.createRouter(); // Register server side APIs - defineRoutes(router, this.logger); + defineRoutes(router, cspAppContext); return {}; } public start(core: CoreStart, plugins: CspServerPluginStartDeps): CspServerPluginStart { + this.CspAppService.start({ + ...plugins.fleet, + }); + + initializeCspRules(core.savedObjects.createInternalRepository()); + return {}; } public stop() {} diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts new file mode 100644 index 000000000000..a81cdff3042c --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts @@ -0,0 +1,288 @@ +/* + * Copyright 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, loggingSystemMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import { + defineGetBenchmarksRoute, + benchmarksInputSchema, + DEFAULT_BENCHMARKS_PER_PAGE, + PACKAGE_POLICY_SAVED_OBJECT_TYPE, + getPackagePolicies, + getAgentPolicies, + createBenchmarkEntry, +} from './benchmarks'; +import { SavedObjectsClientContract } from 'src/core/server'; +import { + createMockAgentPolicyService, + createPackagePolicyServiceMock, +} from '../../../../fleet/server/mocks'; +import { createPackagePolicyMock } from '../../../../fleet/common/mocks'; +import { AgentPolicy } from '../../../../fleet/common'; + +import { CspAppService } from '../../lib/csp_app_services'; +import { CspAppContext } from '../../plugin'; + +function createMockAgentPolicy(props: Partial = {}): AgentPolicy { + return { + id: 'some-uuid1', + namespace: 'default', + monitoring_enabled: [], + name: 'Test Policy', + description: '', + is_default: false, + is_preconfigured: false, + status: 'active', + is_managed: false, + revision: 1, + updated_at: '', + updated_by: 'elastic', + package_policies: [], + ...props, + }; +} +describe('benchmarks API', () => { + let logger: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + jest.clearAllMocks(); + }); + + it('validate the API route path', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetBenchmarksRoute(router, cspContext); + + const [config, _] = router.get.mock.calls[0]; + + expect(config.path).toEqual('/api/csp/benchmarks'); + }); + + describe('test input schema', () => { + it('expect to find default values', async () => { + const validatedQuery = benchmarksInputSchema.validate({}); + + expect(validatedQuery).toMatchObject({ + page: 1, + per_page: DEFAULT_BENCHMARKS_PER_PAGE, + }); + }); + + it('expect to find benchmark_name', async () => { + const validatedQuery = benchmarksInputSchema.validate({ + benchmark_name: 'my_cis_benchmark', + }); + + expect(validatedQuery).toMatchObject({ + page: 1, + per_page: DEFAULT_BENCHMARKS_PER_PAGE, + benchmark_name: 'my_cis_benchmark', + }); + }); + + it('should throw when page field is not a positive integer', async () => { + expect(() => { + benchmarksInputSchema.validate({ page: -2 }); + }).toThrow(); + }); + + it('should throw when per_page field is not a positive integer', async () => { + expect(() => { + benchmarksInputSchema.validate({ per_page: -2 }); + }).toThrow(); + }); + }); + + it('should throw when sort_field is not string', async () => { + expect(() => { + benchmarksInputSchema.validate({ sort_field: true }); + }).toThrow(); + }); + + it('should not throw when sort_field is a string', async () => { + expect(() => { + benchmarksInputSchema.validate({ sort_field: 'field1' }); + }).not.toThrow(); + }); + + it('should throw when sort_order is not `asc` or `desc`', async () => { + expect(() => { + benchmarksInputSchema.validate({ sort_order: 'Other Direction' }); + }).toThrow(); + }); + + it('should not throw when `asc` is input for sort_order field', async () => { + expect(() => { + benchmarksInputSchema.validate({ sort_order: 'asc' }); + }).not.toThrow(); + }); + + it('should not throw when `desc` is input for sort_order field', async () => { + expect(() => { + benchmarksInputSchema.validate({ sort_order: 'desc' }); + }).not.toThrow(); + }); + + it('should throw when fields is not string', async () => { + expect(() => { + benchmarksInputSchema.validate({ fields: ['field1', 'field2'] }); + }).toThrow(); + }); + + it('should not throw when fields is a string', async () => { + expect(() => { + benchmarksInputSchema.validate({ sort_field: 'field1, field2' }); + }).not.toThrow(); + }); + + describe('test benchmarks utils', () => { + let mockSoClient: jest.Mocked; + + beforeEach(() => { + mockSoClient = savedObjectsClientMock.create(); + }); + + describe('test getPackagePolicies', () => { + it('should format request by package name', async () => { + const mockPackagePolicyService = createPackagePolicyServiceMock(); + + await getPackagePolicies(mockSoClient, mockPackagePolicyService, 'myPackage', { + page: 1, + per_page: 100, + sort_order: 'desc', + }); + + expect(mockPackagePolicyService.list.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:myPackage`, + page: 1, + perPage: 100, + }) + ); + }); + + it('should build sort request by `sort_field` and default `sort_order`', async () => { + const mockAgentPolicyService = createPackagePolicyServiceMock(); + + await getPackagePolicies(mockSoClient, mockAgentPolicyService, 'myPackage', { + page: 1, + per_page: 100, + sort_field: 'name', + sort_order: 'desc', + }); + + expect(mockAgentPolicyService.list.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:myPackage`, + page: 1, + perPage: 100, + sortField: 'name', + sortOrder: 'desc', + }) + ); + }); + + it('should build sort request by `sort_field` and asc `sort_order`', async () => { + const mockAgentPolicyService = createPackagePolicyServiceMock(); + + await getPackagePolicies(mockSoClient, mockAgentPolicyService, 'myPackage', { + page: 1, + per_page: 100, + sort_field: 'name', + sort_order: 'asc', + }); + + expect(mockAgentPolicyService.list.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:myPackage`, + page: 1, + perPage: 100, + sortField: 'name', + sortOrder: 'asc', + }) + ); + }); + }); + + it('should format request by benchmark_name', async () => { + const mockAgentPolicyService = createPackagePolicyServiceMock(); + + await getPackagePolicies(mockSoClient, mockAgentPolicyService, 'myPackage', { + page: 1, + per_page: 100, + sort_order: 'desc', + benchmark_name: 'my_cis_benchmark', + }); + + expect(mockAgentPolicyService.list.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:myPackage AND ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.name: *my_cis_benchmark*`, + page: 1, + perPage: 100, + }) + ); + }); + + describe('test getAgentPolicies', () => { + it('should return one agent policy id when there is duplication', async () => { + const agentPolicyService = createMockAgentPolicyService(); + const packagePolicies = [createPackagePolicyMock(), createPackagePolicyMock()]; + + await getAgentPolicies(mockSoClient, packagePolicies, agentPolicyService); + + expect(agentPolicyService.getByIds.mock.calls[0][1]).toHaveLength(1); + }); + + it('should return full policy ids list when there is no id duplication', async () => { + const agentPolicyService = createMockAgentPolicyService(); + + const packagePolicy1 = createPackagePolicyMock(); + const packagePolicy2 = createPackagePolicyMock(); + packagePolicy2.policy_id = 'AnotherId'; + const packagePolicies = [packagePolicy1, packagePolicy2]; + + await getAgentPolicies(mockSoClient, packagePolicies, agentPolicyService); + + expect(agentPolicyService.getByIds.mock.calls[0][1]).toHaveLength(2); + }); + }); + + describe('test createBenchmarkEntry', () => { + it('should build benchmark entry agent policy and package policy', async () => { + const packagePolicy = createPackagePolicyMock(); + const agentPolicy = createMockAgentPolicy(); + // @ts-expect-error + agentPolicy.agents = 3; + + const enrichAgentPolicy = await createBenchmarkEntry(agentPolicy, packagePolicy); + + expect(enrichAgentPolicy).toMatchObject({ + package_policy: { + id: 'c6d16e42-c32d-4dce-8a88-113cfe276ad1', + name: 'endpoint-1', + policy_id: '93c46720-c217-11ea-9906-b5b8a21b268e', + namespace: 'default', + updated_at: '2020-06-25T16:03:38.159292', + updated_by: 'kibana', + created_at: '2020-06-25T16:03:38.159292', + created_by: 'kibana', + package: { + name: 'endpoint', + title: 'Elastic Endpoint', + version: '0.9.0', + }, + }, + agent_policy: { id: 'some-uuid1', name: 'Test Policy', agents: 3 }, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts new file mode 100644 index 000000000000..85ad67ecaef7 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts @@ -0,0 +1,238 @@ +/* + * Copyright 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, map } from 'lodash'; +import type { IRouter, SavedObjectsClientContract } from 'src/core/server'; +import { schema as rt, TypeOf } from '@kbn/config-schema'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { + PackagePolicyServiceInterface, + AgentPolicyServiceInterface, + AgentService, +} from '../../../../fleet/server'; +import { + GetAgentPoliciesResponseItem, + PackagePolicy, + AgentPolicy, + ListWithKuery, +} from '../../../../fleet/common'; +import { BENCHMARKS_ROUTE_PATH, CIS_KUBERNETES_PACKAGE_NAME } from '../../../common/constants'; +import { CspAppContext } from '../../plugin'; + +export const isNonNullable = (v: T): v is NonNullable => + v !== null && v !== undefined; + +type BenchmarksQuerySchema = TypeOf; + +export interface Benchmark { + package_policy: Pick< + PackagePolicy, + | 'id' + | 'name' + | 'policy_id' + | 'namespace' + | 'package' + | 'updated_at' + | 'updated_by' + | 'created_at' + | 'created_by' + >; + agent_policy: Pick; +} + +export const DEFAULT_BENCHMARKS_PER_PAGE = 20; +export const PACKAGE_POLICY_SAVED_OBJECT_TYPE = 'ingest-package-policies'; + +const getPackageNameQuery = (packageName: string, benchmarkFilter?: string): string => { + const integrationNameQuery = `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${packageName}`; + const kquery = benchmarkFilter + ? `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${packageName} AND ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.name: *${benchmarkFilter}*` + : integrationNameQuery; + + return kquery; +}; + +const addSortToQuery = ( + baseQuery: ListWithKuery, + queryParams: BenchmarksQuerySchema +): ListWithKuery => + queryParams.sort_field + ? { + ...baseQuery, + sortField: queryParams.sort_field, + sortOrder: queryParams.sort_order, + } + : baseQuery; + +export const getPackagePolicies = async ( + soClient: SavedObjectsClientContract, + packagePolicyService: PackagePolicyServiceInterface, + packageName: string, + queryParams: BenchmarksQuerySchema +): Promise => { + if (!packagePolicyService) { + throw new Error('packagePolicyService is undefined'); + } + + const packageNameQuery = getPackageNameQuery(packageName, queryParams.benchmark_name); + + const baseQuery = { + kuery: packageNameQuery, + page: queryParams.page, + perPage: queryParams.per_page, + }; + + const query = addSortToQuery(baseQuery, queryParams); + + const { items: packagePolicies } = (await packagePolicyService?.list(soClient, query)) ?? { + items: [] as PackagePolicy[], + }; + return packagePolicies; +}; + +export const getAgentPolicies = async ( + soClient: SavedObjectsClientContract, + packagePolicies: PackagePolicy[], + agentPolicyService: AgentPolicyServiceInterface +): Promise => { + const agentPolicyIds = uniq(map(packagePolicies, 'policy_id')); + const agentPolicies = await agentPolicyService.getByIds(soClient, agentPolicyIds); + + return agentPolicies; +}; + +const addRunningAgentToAgentPolicy = async ( + agentService: AgentService, + agentPolicies: AgentPolicy[] +): Promise => { + if (!agentPolicies?.length) return []; + return Promise.all( + agentPolicies.map((agentPolicy) => + agentService.asInternalUser + .getAgentStatusForAgentPolicy(agentPolicy.id) + .then((agentStatus) => ({ + ...agentPolicy, + agents: agentStatus.total, + })) + ) + ); +}; + +export const createBenchmarkEntry = ( + agentPolicy: GetAgentPoliciesResponseItem, + packagePolicy: PackagePolicy +): Benchmark => ({ + package_policy: { + id: packagePolicy.id, + name: packagePolicy.name, + policy_id: packagePolicy.policy_id, + namespace: packagePolicy.namespace, + updated_at: packagePolicy.updated_at, + updated_by: packagePolicy.updated_by, + created_at: packagePolicy.created_at, + created_by: packagePolicy.created_by, + package: packagePolicy.package + ? { + name: packagePolicy.package.name, + title: packagePolicy.package.title, + version: packagePolicy.package.version, + } + : undefined, + }, + agent_policy: { + id: agentPolicy.id, + name: agentPolicy.name, + agents: agentPolicy.agents, + }, +}); + +const createBenchmarks = ( + agentPolicies: GetAgentPoliciesResponseItem[], + packagePolicies: PackagePolicy[] +): Benchmark[] => + packagePolicies.flatMap((packagePolicy) => { + return agentPolicies + .map((agentPolicy) => { + const agentPkgPolicies = agentPolicy.package_policies as string[]; + const isExistsOnAgent = agentPkgPolicies.find( + (pkgPolicy) => pkgPolicy === packagePolicy.id + ); + if (isExistsOnAgent) { + return createBenchmarkEntry(agentPolicy, packagePolicy); + } + return; + }) + .filter(isNonNullable); + }); + +export const defineGetBenchmarksRoute = (router: IRouter, cspContext: CspAppContext): void => + router.get( + { + path: BENCHMARKS_ROUTE_PATH, + validate: { query: benchmarksInputSchema }, + }, + async (context, request, response) => { + try { + const soClient = context.core.savedObjects.client; + const { query } = request; + + const agentService = cspContext.service.agentService; + const agentPolicyService = cspContext.service.agentPolicyService; + const packagePolicyService = cspContext.service.packagePolicyService; + + if (!agentPolicyService || !agentService || !packagePolicyService) { + throw new Error(`Failed to get Fleet services`); + } + + const packagePolicies = await getPackagePolicies( + soClient, + packagePolicyService, + CIS_KUBERNETES_PACKAGE_NAME, + query + ); + + const agentPolicies = await getAgentPolicies(soClient, packagePolicies, agentPolicyService); + const enrichAgentPolicies = await addRunningAgentToAgentPolicy(agentService, agentPolicies); + const benchmarks = createBenchmarks(enrichAgentPolicies, packagePolicies); + + return response.ok({ + body: benchmarks, + }); + } catch (err) { + const error = transformError(err); + cspContext.logger.error(`Failed to fetch benchmarks ${err}`); + return response.customError({ + body: { message: error.message }, + statusCode: error.statusCode, + }); + } + } + ); + +export const benchmarksInputSchema = rt.object({ + /** + * The page of objects to return + */ + page: rt.number({ defaultValue: 1, min: 1 }), + /** + * The number of objects to include in each page + */ + per_page: rt.number({ defaultValue: DEFAULT_BENCHMARKS_PER_PAGE, min: 0 }), + /** + * Once of PackagePolicy fields for sorting the found objects. + * Sortable fields: id, name, policy_id, namespace, updated_at, updated_by, created_at, created_by, + * package.name, package.title, package.version + */ + sort_field: rt.maybe(rt.string()), + /** + * The order to sort by + */ + sort_order: rt.oneOf([rt.literal('asc'), rt.literal('desc')], { defaultValue: 'desc' }), + /** + * Benchmark filter + */ + benchmark_name: rt.maybe(rt.string()), +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts new file mode 100644 index 000000000000..f554eb91a4a4 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient, IRouter } from 'src/core/server'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import type { + AggregationsMultiBucketAggregateBase as Aggregation, + AggregationsTopHitsAggregate, + QueryDslQueryContainer, + SearchRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import type { CloudPostureStats } from '../../../common/types'; +import { CSP_KUBEBEAT_INDEX_PATTERN, STATS_ROUTE_PATH } from '../../../common/constants'; +import { CspAppContext } from '../../plugin'; +import { getResourcesTypes } from './get_resources_types'; +import { getClusters } from './get_clusters'; +import { getStats } from './get_stats'; + +export interface ClusterBucket { + ordered_top_hits: AggregationsTopHitsAggregate; +} + +interface ClustersQueryResult { + aggs_by_cluster_id: Aggregation; +} + +export interface KeyDocCount { + key: TKey; + doc_count: number; +} + +export const getLatestFindingQuery = (): SearchRequest => ({ + index: CSP_KUBEBEAT_INDEX_PATTERN, + size: 0, + query: { + match_all: {}, + }, + aggs: { + aggs_by_cluster_id: { + terms: { field: 'cluster_id.keyword' }, + aggs: { + ordered_top_hits: { + top_hits: { + size: 1, + sort: { + '@timestamp': { + order: 'desc', + }, + }, + }, + }, + }, + }, + }, +}); + +const getLatestCyclesIds = async (esClient: ElasticsearchClient): Promise => { + const queryResult = await esClient.search(getLatestFindingQuery(), { + meta: true, + }); + + const clusters = queryResult.body.aggregations?.aggs_by_cluster_id.buckets; + if (!Array.isArray(clusters)) throw new Error('missing aggs by cluster id'); + + return clusters.map((c) => { + const topHit = c.ordered_top_hits.hits.hits[0]; + if (!topHit) throw new Error('missing cluster latest hit'); + return topHit._source.cycle_id; + }); +}; + +// TODO: Utilize ES "Point in Time" feature https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html +export const defineGetComplianceDashboardRoute = ( + router: IRouter, + cspContext: CspAppContext +): void => + router.get( + { + path: STATS_ROUTE_PATH, + validate: false, + }, + async (context, _, response) => { + try { + const esClient = context.core.elasticsearch.client.asCurrentUser; + const latestCyclesIds = await getLatestCyclesIds(esClient); + const query: QueryDslQueryContainer = { + bool: { + should: latestCyclesIds.map((id) => ({ + match: { 'cycle_id.keyword': { query: id } }, + })), + }, + }; + + const [stats, resourcesTypes, clusters] = await Promise.all([ + getStats(esClient, query), + getResourcesTypes(esClient, query), + getClusters(esClient, query), + ]); + + const body: CloudPostureStats = { + stats, + resourcesTypes, + clusters, + }; + + return response.ok({ + body, + }); + } catch (err) { + const error = transformError(err); + + return response.customError({ + body: { message: error.message }, + statusCode: error.statusCode, + }); + } + } + ); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.test.ts new file mode 100644 index 000000000000..df45e7fb8e73 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.test.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 { ClusterBucket, getClustersFromAggs } from './get_clusters'; + +const mockClusterBuckets: ClusterBucket[] = [ + { + key: 'cluster_id', + doc_count: 10, + benchmarks: { + buckets: [{ key: 'CIS Kubernetes', doc_count: 10 }], + }, + timestamps: { + buckets: [{ key: 123, doc_count: 1 }], + }, + failed_findings: { + doc_count: 6, + }, + passed_findings: { + doc_count: 6, + }, + aggs_by_resource_type: { + buckets: [ + { + key: 'foo_type', + doc_count: 6, + failed_findings: { + doc_count: 3, + }, + passed_findings: { + doc_count: 3, + }, + }, + { + key: 'boo_type', + doc_count: 6, + failed_findings: { + doc_count: 3, + }, + passed_findings: { + doc_count: 3, + }, + }, + ], + }, + }, +]; + +describe('getClustersFromAggs', () => { + it('should return value matching CloudPostureStats["clusters"]', async () => { + const clusters = getClustersFromAggs(mockClusterBuckets); + expect(clusters).toEqual([ + { + meta: { + lastUpdate: 123, + clusterId: 'cluster_id', + benchmarkName: 'CIS Kubernetes', + }, + stats: { + totalFindings: 12, + totalFailed: 6, + totalPassed: 6, + postureScore: 50.0, + }, + resourcesTypes: [ + { + name: 'foo_type', + totalFindings: 6, + totalFailed: 3, + totalPassed: 3, + }, + { + name: 'boo_type', + totalFindings: 6, + totalFailed: 3, + totalPassed: 3, + }, + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.ts new file mode 100644 index 000000000000..04eecd67cc28 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.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 { ElasticsearchClient } from 'kibana/server'; +import type { + AggregationsMultiBucketAggregateBase as Aggregation, + QueryDslQueryContainer, + SearchRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import { CloudPostureStats } from '../../../common/types'; +import { getResourceTypeFromAggs, resourceTypeAggQuery } from './get_resources_types'; +import type { ResourceTypeQueryResult } from './get_resources_types'; +import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; +import { findingsEvaluationAggsQuery, getStatsFromFindingsEvaluationsAggs } from './get_stats'; +import { KeyDocCount } from './compliance_dashboard'; + +type UnixEpochTime = number; + +export interface ClusterBucket extends ResourceTypeQueryResult, KeyDocCount { + failed_findings: { + doc_count: number; + }; + passed_findings: { + doc_count: number; + }; + benchmarks: Aggregation; + timestamps: Aggregation>; +} + +interface ClustersQueryResult { + aggs_by_cluster_id: Aggregation; +} + +export const getClustersQuery = (query: QueryDslQueryContainer): SearchRequest => ({ + index: CSP_KUBEBEAT_INDEX_PATTERN, + size: 0, + query, + aggs: { + aggs_by_cluster_id: { + terms: { + field: 'cluster_id.keyword', + }, + aggs: { + benchmarks: { + terms: { + field: 'rule.benchmark.name.keyword', + }, + }, + timestamps: { + terms: { + field: '@timestamp', + size: 1, + order: { + _key: 'desc', + }, + }, + }, + ...resourceTypeAggQuery, + ...findingsEvaluationAggsQuery, + }, + }, + }, +}); + +export const getClustersFromAggs = (clusters: ClusterBucket[]): CloudPostureStats['clusters'] => + clusters.map((cluster) => { + // get cluster's meta data + const benchmarks = cluster.benchmarks.buckets; + if (!Array.isArray(benchmarks)) throw new Error('missing aggs by benchmarks per cluster'); + const timestamps = cluster.timestamps.buckets; + if (!Array.isArray(timestamps)) throw new Error('missing aggs by timestamps per cluster'); + + const meta = { + clusterId: cluster.key, + benchmarkName: benchmarks[0].key, + lastUpdate: timestamps[0].key, + }; + + // get cluster's stats + if (!cluster.failed_findings || !cluster.passed_findings) + throw new Error('missing findings evaluations per cluster'); + const stats = getStatsFromFindingsEvaluationsAggs(cluster); + + // get cluster's resource types aggs + const resourcesTypesAggs = cluster.aggs_by_resource_type.buckets; + if (!Array.isArray(resourcesTypesAggs)) + throw new Error('missing aggs by resource type per cluster'); + const resourcesTypes = getResourceTypeFromAggs(resourcesTypesAggs); + + return { + meta, + stats, + resourcesTypes, + }; + }); + +export const getClusters = async ( + esClient: ElasticsearchClient, + query: QueryDslQueryContainer +): Promise => { + const queryResult = await esClient.search(getClustersQuery(query), { + meta: true, + }); + + const clusters = queryResult.body.aggregations?.aggs_by_cluster_id.buckets; + if (!Array.isArray(clusters)) throw new Error('missing aggs by cluster id'); + + return getClustersFromAggs(clusters); +}; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.test.ts new file mode 100644 index 000000000000..b01644fc3f45 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.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 { getResourceTypeFromAggs, ResourceTypeBucket } from './get_resources_types'; + +const resourceTypeBuckets: ResourceTypeBucket[] = [ + { + key: 'foo_type', + doc_count: 41, + failed_findings: { + doc_count: 30, + }, + passed_findings: { + doc_count: 11, + }, + }, + { + key: 'boo_type', + doc_count: 11, + failed_findings: { + doc_count: 5, + }, + passed_findings: { + doc_count: 6, + }, + }, +]; + +describe('getResourceTypeFromAggs', () => { + it('should return value matching CloudPostureStats["resourcesTypes"]', async () => { + const resourceTypes = getResourceTypeFromAggs(resourceTypeBuckets); + expect(resourceTypes).toEqual([ + { + name: 'foo_type', + totalFindings: 41, + totalFailed: 30, + totalPassed: 11, + }, + { + name: 'boo_type', + totalFindings: 11, + totalFailed: 5, + totalPassed: 6, + }, + ]); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.ts new file mode 100644 index 000000000000..0fc6e4b00944 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from 'kibana/server'; +import { + AggregationsMultiBucketAggregateBase as Aggregation, + QueryDslQueryContainer, + SearchRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import { CloudPostureStats } from '../../../common/types'; +import { KeyDocCount } from './compliance_dashboard'; +import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; + +export interface ResourceTypeQueryResult { + aggs_by_resource_type: Aggregation; +} + +export interface ResourceTypeBucket extends KeyDocCount { + failed_findings: { + doc_count: number; + }; + passed_findings: { + doc_count: number; + }; +} + +export const resourceTypeAggQuery = { + aggs_by_resource_type: { + terms: { + field: 'type.keyword', + }, + aggs: { + failed_findings: { + filter: { term: { 'result.evaluation.keyword': 'failed' } }, + }, + passed_findings: { + filter: { term: { 'result.evaluation.keyword': 'passed' } }, + }, + }, + }, +}; + +export const getRisksEsQuery = (query: QueryDslQueryContainer): SearchRequest => ({ + index: CSP_KUBEBEAT_INDEX_PATTERN, + size: 0, + query, + aggs: resourceTypeAggQuery, +}); + +export const getResourceTypeFromAggs = ( + queryResult: ResourceTypeBucket[] +): CloudPostureStats['resourcesTypes'] => + queryResult.map((bucket) => ({ + name: bucket.key, + totalFindings: bucket.doc_count, + totalFailed: bucket.failed_findings.doc_count || 0, + totalPassed: bucket.passed_findings.doc_count || 0, + })); + +export const getResourcesTypes = async ( + esClient: ElasticsearchClient, + query: QueryDslQueryContainer +): Promise => { + const resourceTypesQueryResult = await esClient.search( + getRisksEsQuery(query), + { meta: true } + ); + + const resourceTypes = resourceTypesQueryResult.body.aggregations?.aggs_by_resource_type.buckets; + if (!Array.isArray(resourceTypes)) throw new Error('missing resources types buckets'); + + return getResourceTypeFromAggs(resourceTypes); +}; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.test.ts new file mode 100644 index 000000000000..558fec85860e --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.test.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 { + calculatePostureScore, + FindingsEvaluationsQueryResult, + getStatsFromFindingsEvaluationsAggs, + roundScore, +} from './get_stats'; + +const standardQueryResult: FindingsEvaluationsQueryResult = { + failed_findings: { + doc_count: 30, + }, + passed_findings: { + doc_count: 11, + }, +}; + +const oneIsZeroQueryResult: FindingsEvaluationsQueryResult = { + failed_findings: { + doc_count: 0, + }, + passed_findings: { + doc_count: 11, + }, +}; + +const bothAreZeroQueryResult: FindingsEvaluationsQueryResult = { + failed_findings: { + doc_count: 0, + }, + passed_findings: { + doc_count: 0, + }, +}; + +describe('roundScore', () => { + it('should return decimal values with one fraction digit', async () => { + const rounded = roundScore(0.85245); + expect(rounded).toEqual(85.2); + }); +}); + +describe('calculatePostureScore', () => { + it('should return calculated posture score', async () => { + const score = calculatePostureScore(4, 7); + expect(score).toEqual(36.4); + }); +}); + +describe('getStatsFromFindingsEvaluationsAggs', () => { + it('should throw error in case no findings were found', async () => { + const score = calculatePostureScore(4, 7); + expect(score).toEqual(36.4); + }); + + it('should return value matching CloudPostureStats["stats"]', async () => { + const stats = getStatsFromFindingsEvaluationsAggs(standardQueryResult); + expect(stats).toEqual({ + totalFailed: 30, + totalPassed: 11, + totalFindings: 41, + postureScore: 26.8, + }); + }); + + it('checks for stability in case one of the values is zero', async () => { + const stats = getStatsFromFindingsEvaluationsAggs(oneIsZeroQueryResult); + expect(stats).toEqual({ + totalFailed: 0, + totalPassed: 11, + totalFindings: 11, + postureScore: 100.0, + }); + }); + + it('should throw error if both evaluations are zero', async () => { + // const stats = getStatsFromFindingsEvaluationsAggs(bothAreZeroQueryResult); + expect(() => getStatsFromFindingsEvaluationsAggs(bothAreZeroQueryResult)).toThrow(); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.ts new file mode 100644 index 000000000000..8d5417de24c5 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from 'kibana/server'; +import { QueryDslQueryContainer, SearchRequest } from '@elastic/elasticsearch/lib/api/types'; +import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; +import { CloudPostureStats, Score } from '../../../common/types'; + +/** + * @param value value is [0, 1] range + */ +export const roundScore = (value: number): Score => Number((value * 100).toFixed(1)); + +export const calculatePostureScore = (passed: number, failed: number): Score => + roundScore(passed / (passed + failed)); + +export interface FindingsEvaluationsQueryResult { + failed_findings: { + doc_count: number; + }; + passed_findings: { + doc_count: number; + }; +} + +export const findingsEvaluationAggsQuery = { + failed_findings: { + filter: { term: { 'result.evaluation.keyword': 'failed' } }, + }, + passed_findings: { + filter: { term: { 'result.evaluation.keyword': 'passed' } }, + }, +}; + +export const getEvaluationsQuery = (query: QueryDslQueryContainer): SearchRequest => ({ + index: CSP_KUBEBEAT_INDEX_PATTERN, + query, + aggs: findingsEvaluationAggsQuery, +}); + +export const getStatsFromFindingsEvaluationsAggs = ( + findingsEvaluationsAggs: FindingsEvaluationsQueryResult +): CloudPostureStats['stats'] => { + const failedFindings = findingsEvaluationsAggs.failed_findings.doc_count || 0; + const passedFindings = findingsEvaluationsAggs.passed_findings.doc_count || 0; + const totalFindings = failedFindings + passedFindings; + if (!totalFindings) throw new Error("couldn't calculate posture score"); + const postureScore = calculatePostureScore(passedFindings, failedFindings); + + return { + totalFailed: failedFindings, + totalPassed: passedFindings, + totalFindings, + postureScore, + }; +}; + +export const getStats = async ( + esClient: ElasticsearchClient, + query: QueryDslQueryContainer +): Promise => { + const evaluationsQueryResult = await esClient.search( + getEvaluationsQuery(query), + { meta: true } + ); + const findingsEvaluations = evaluationsQueryResult.body.aggregations; + if (!findingsEvaluations) throw new Error('missing findings evaluations'); + + return getStatsFromFindingsEvaluationsAggs(findingsEvaluations); +}; 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 new file mode 100644 index 000000000000..4e534d565d7e --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { savedObjectsClientMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { + convertRulesConfigToYaml, + createRulesConfig, + defineUpdateRulesConfigRoute, + getCspRules, + setVarToPackagePolicy, + updatePackagePolicy, +} from './update_rules_configuration'; + +import { CspAppService } from '../../lib/csp_app_services'; +import { CspAppContext } from '../../plugin'; +import { createPackagePolicyMock } from '../../../../fleet/common/mocks'; +import { createPackagePolicyServiceMock } from '../../../../fleet/server/mocks'; + +import { cspRuleAssetSavedObjectType, CspRuleSchema } from '../../../common/schemas/csp_rule'; +import { + ElasticsearchClient, + SavedObjectsClientContract, + SavedObjectsFindResponse, +} from 'kibana/server'; +import { Chance } from 'chance'; + +describe('Update rules configuration API', () => { + let logger: ReturnType; + let mockEsClient: jest.Mocked; + let mockSoClient: jest.Mocked; + const chance = new Chance(); + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + jest.clearAllMocks(); + }); + + it('validate the API route path', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineUpdateRulesConfigRoute(router, cspContext); + + const [config, _] = router.post.mock.calls[0]; + + expect(config.path).toEqual('/api/csp/update_rules_config'); + }); + + it('validate getCspRules input parameters', async () => { + mockSoClient = savedObjectsClientMock.create(); + mockSoClient.find.mockResolvedValueOnce({} as SavedObjectsFindResponse); + await getCspRules(mockSoClient); + expect(mockSoClient.find).toBeCalledTimes(1); + expect(mockSoClient.find).toHaveBeenCalledWith( + expect.objectContaining({ type: cspRuleAssetSavedObjectType }) + ); + }); + + it('create csp rules config based on activated csp rules', async () => { + const cspRules = { + page: 1, + per_page: 1000, + total: 2, + saved_objects: [ + { + type: 'csp_rule', + id: '1.1.1', + attributes: { enabled: true }, + }, + { + type: 'csp_rule', + id: '1.1.2', + attributes: { enabled: false }, + }, + { + type: 'csp_rule', + id: '1.1.3', + attributes: { enabled: true }, + }, + ], + } as SavedObjectsFindResponse; + const cspConfig = await createRulesConfig(cspRules); + expect(cspConfig).toMatchObject({ activated_rules: { cis_k8s: ['1.1.1', '1.1.3'] } }); + }); + + it('create empty csp rules config when all rules are disabled', async () => { + const cspRules = { + page: 1, + per_page: 1000, + total: 2, + saved_objects: [ + { + type: 'csp_rule', + id: '1.1.1', + attributes: { enabled: false }, + }, + { + type: 'csp_rule', + id: '1.1.2', + attributes: { enabled: false }, + }, + ], + } 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'); + }); + + 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 updatedPackagePolicy = setVarToPackagePolicy(packagePolicy, dataYaml); + + expect(updatedPackagePolicy.vars).toEqual({ dataYaml: { type: 'config', value: dataYaml } }); + }); + + it('validate updatePackagePolicy is called with the right parameters', async () => { + mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; + mockSoClient = savedObjectsClientMock.create(); + const mockPackagePolicyService = createPackagePolicyServiceMock(); + + const packagePolicyId1 = chance.guid(); + const packagePolicyId2 = chance.guid(); + const mockPackagePolicy1 = createPackagePolicyMock(); + const mockPackagePolicy2 = createPackagePolicyMock(); + mockPackagePolicy1.id = packagePolicyId1; + mockPackagePolicy2.id = packagePolicyId2; + const packagePolicies = mockPackagePolicy1; + + const dataYaml = 'activated_rules:\n cis_k8s:\n - 1.1.1\n - 1.1.2\n'; + + await updatePackagePolicy( + mockPackagePolicyService, + packagePolicies, + mockEsClient, + mockSoClient, + dataYaml + ); + + expect(mockPackagePolicyService.update).toBeCalledTimes(1); + expect(mockPackagePolicyService.update.mock.calls[0][2]).toEqual(packagePolicyId1); + }); +}); 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 new file mode 100644 index 000000000000..50a4759c5ec5 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts @@ -0,0 +1,155 @@ +/* + * Copyright 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 { + ElasticsearchClient, + IRouter, + SavedObjectsClientContract, + SavedObjectsFindResponse, +} from 'src/core/server'; +import { schema as rt } from '@kbn/config-schema'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import { produce } from 'immer'; +import { unset } from 'lodash'; +import yaml from 'js-yaml'; + +import { PackagePolicy, PackagePolicyConfigRecord } from '../../../../fleet/common'; +import { CspAppContext } from '../../plugin'; +import { CspRulesConfigSchema } from '../../../common/schemas/csp_configuration'; +import { CspRuleSchema, cspRuleAssetSavedObjectType } from '../../../common/schemas/csp_rule'; +import { UPDATE_RULES_CONFIG_ROUTE_PATH } from '../../../common/constants'; +import { CIS_KUBERNETES_PACKAGE_NAME } from '../../../common/constants'; +import { PackagePolicyServiceInterface } from '../../../../fleet/server'; + +export const getPackagePolicy = async ( + soClient: SavedObjectsClientContract, + packagePolicyService: PackagePolicyServiceInterface, + packagePolicyId: string +): Promise => { + const packagePolicies = await packagePolicyService.getByIDs(soClient, [packagePolicyId]); + + // PackagePolicies always contains one element, even when package does not exist + if (!packagePolicies || !packagePolicies[0].version) { + throw new Error(`package policy Id '${packagePolicyId}' is not exist`); + } + if (packagePolicies[0].package?.name !== CIS_KUBERNETES_PACKAGE_NAME) { + // TODO: improve this validator to support any future CSP package + throw new Error(`Package Policy Id '${packagePolicyId}' is not CSP package`); + } + + return packagePolicies![0]; +}; + +export const getCspRules = async (soClient: SavedObjectsClientContract) => { + const cspRules = await soClient.find({ + type: cspRuleAssetSavedObjectType, + search: '', + searchFields: ['name'], + // TODO: research how to get all rules + perPage: 10000, + }); + return cspRules; +}; + +export const createRulesConfig = ( + cspRules: SavedObjectsFindResponse +): CspRulesConfigSchema => { + const activatedRules = cspRules.saved_objects.filter((cspRule) => cspRule.attributes.enabled); + + const config = { + activated_rules: { + cis_k8s: activatedRules.map((activatedRule) => activatedRule.id), + }, + }; + return config; +}; + +export const convertRulesConfigToYaml = (config: CspRulesConfigSchema): string => { + return yaml.safeDump(config); +}; + +export const setVarToPackagePolicy = ( + packagePolicy: PackagePolicy, + dataYaml: string +): PackagePolicy => { + const configFile: PackagePolicyConfigRecord = { + dataYaml: { type: 'config', value: dataYaml }, + }; + const updatedPackagePolicy = produce(packagePolicy, (draft) => { + unset(draft, 'id'); + draft.vars = configFile; + // TODO: disable comments after adding base config to integration + // draft.inputs[0].vars = configFile; + }); + return updatedPackagePolicy; +}; + +export const updatePackagePolicy = ( + packagePolicyService: PackagePolicyServiceInterface, + packagePolicy: PackagePolicy, + esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract, + dataYaml: string +): Promise => { + const updatedPackagePolicy = setVarToPackagePolicy(packagePolicy, dataYaml); + return packagePolicyService.update(soClient, esClient, packagePolicy.id, updatedPackagePolicy); +}; + +export const defineUpdateRulesConfigRoute = (router: IRouter, cspContext: CspAppContext): void => + router.post( + { + path: UPDATE_RULES_CONFIG_ROUTE_PATH, + validate: { query: configurationUpdateInputSchema }, + }, + async (context, request, response) => { + try { + const esClient = context.core.elasticsearch.client.asCurrentUser; + const soClient = context.core.savedObjects.client; + const packagePolicyService = cspContext.service.packagePolicyService; + const packagePolicyId = request.query.package_policy_id; + + if (!packagePolicyService) { + throw new Error(`Failed to get Fleet services`); + } + const packagePolicy = await getPackagePolicy( + soClient, + packagePolicyService, + packagePolicyId + ); + + const cspRules = await getCspRules(soClient); + const rulesConfig = createRulesConfig(cspRules); + const dataYaml = convertRulesConfigToYaml(rulesConfig); + + const updatedPackagePolicies = await updatePackagePolicy( + packagePolicyService!, + packagePolicy, + esClient, + soClient, + dataYaml + ); + + return response.ok({ body: updatedPackagePolicies }); + } catch (err) { + const error = transformError(err); + cspContext.logger.error( + `Failed to update rules configuration on package policy ${error.message}` + ); + return response.customError({ + body: { message: error.message }, + statusCode: error.statusCode, + }); + } + } + ); + +export const configurationUpdateInputSchema = rt.object({ + /** + * CSP integration instance ID + */ + package_policy_id: rt.string(), +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts index ffc5526e2fe4..76fc97e92104 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts @@ -12,6 +12,8 @@ import { // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { KibanaRequest } from 'src/core/server/http/router/request'; import { httpServerMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { CspAppService } from '../../lib/csp_app_services'; +import { CspAppContext } from '../../plugin'; import { defineFindingsIndexRoute, findingsInputSchema, @@ -41,7 +43,13 @@ describe('findings API', () => { it('validate the API route path', async () => { const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); const [config, _] = router.get.mock.calls[0]; @@ -130,7 +138,13 @@ describe('findings API', () => { it('takes cycle_id and validate the filter was built right', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); @@ -178,7 +192,14 @@ describe('findings API', () => { it('validate that default sort is timestamp desc', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); const mockResponse = httpServerMock.createResponseFactory(); @@ -202,7 +223,14 @@ describe('findings API', () => { it('should build sort request by `sort_field` and `sort_order` - asc', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); const mockResponse = httpServerMock.createResponseFactory(); @@ -227,7 +255,14 @@ describe('findings API', () => { it('should build sort request by `sort_field` and `sort_order` - desc', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); const mockResponse = httpServerMock.createResponseFactory(); @@ -249,10 +284,17 @@ describe('findings API', () => { }); }); - it('takes `page_number` and `per_page` validate that the requested selected page was called', async () => { + it('takes `page` number and `per_page` validate that the requested selected page was called', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); const mockResponse = httpServerMock.createResponseFactory(); @@ -278,7 +320,14 @@ describe('findings API', () => { it('should format request by fields filter', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts index a5c8f67a41ca..5fea7cdbba9d 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { IRouter, Logger } from 'src/core/server'; +import type { IRouter } from 'src/core/server'; import { SearchRequest, QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { schema as rt, TypeOf } from '@kbn/config-schema'; import type { SortOrder } from '@elastic/elasticsearch/lib/api/types'; @@ -13,6 +13,7 @@ import { transformError } from '@kbn/securitysolution-es-utils'; import { getLatestCycleIds } from './get_latest_cycle_ids'; import { CSP_KUBEBEAT_INDEX_PATTERN, FINDINGS_ROUTE_PATH } from '../../../common/constants'; +import { CspAppContext } from '../../plugin'; type FindingsQuerySchema = TypeOf; @@ -70,7 +71,7 @@ const buildOptionsRequest = (queryParams: FindingsQuerySchema): FindingsOptions ...getSearchFields(queryParams.fields), }); -export const defineFindingsIndexRoute = (router: IRouter, logger: Logger): void => +export const defineFindingsIndexRoute = (router: IRouter, cspContext: CspAppContext): void => router.get( { path: FINDINGS_ROUTE_PATH, @@ -83,7 +84,7 @@ export const defineFindingsIndexRoute = (router: IRouter, logger: Logger): void const latestCycleIds = request.query.latest_cycle === true - ? await getLatestCycleIds(esClient, logger) + ? await getLatestCycleIds(esClient, cspContext.logger) : undefined; const query = buildQueryRequest(latestCycleIds); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/index.ts b/x-pack/plugins/cloud_security_posture/server/routes/index.ts index ab8d1cc3bbed..aa04a610aa48 100755 --- a/x-pack/plugins/cloud_security_posture/server/routes/index.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/index.ts @@ -5,11 +5,16 @@ * 2.0. */ -import type { IRouter, Logger } from '../../../../../src/core/server'; -import { defineGetStatsRoute } from './stats/stats'; +import type { IRouter } from '../../../../../src/core/server'; +import { defineGetComplianceDashboardRoute } from './compliance_dashboard/compliance_dashboard'; +import { defineGetBenchmarksRoute } from './benchmarks/benchmarks'; import { defineFindingsIndexRoute as defineGetFindingsIndexRoute } from './findings/findings'; +import { defineUpdateRulesConfigRoute } from './configuration/update_rules_configuration'; +import { CspAppContext } from '../plugin'; -export function defineRoutes(router: IRouter, logger: Logger) { - defineGetStatsRoute(router, logger); - defineGetFindingsIndexRoute(router, logger); +export function defineRoutes(router: IRouter, cspContext: CspAppContext) { + defineGetComplianceDashboardRoute(router, cspContext); + defineGetFindingsIndexRoute(router, cspContext); + defineGetBenchmarksRoute(router, cspContext); + defineUpdateRulesConfigRoute(router, cspContext); } diff --git a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.test.ts deleted file mode 100644 index 549e8d45c989..000000000000 --- a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.test.ts +++ /dev/null @@ -1,201 +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 { - elasticsearchClientMock, - ElasticsearchClientMock, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from 'src/core/server/elasticsearch/client/mocks'; - -import { - getBenchmarks, - getAllFindingsStats, - roundScore, - getBenchmarksStats, - getResourceTypesAggs, -} from './stats'; - -export const mockCountResultOnce = async (mockEsClient: ElasticsearchClientMock, count: number) => { - mockEsClient.count.mockReturnValueOnce( - // @ts-expect-error @elast ic/elasticsearch Aggregate only allows unknown values - elasticsearchClientMock.createSuccessTransportRequestPromise({ count }) - ); -}; - -export const mockSearchResultOnce = async ( - mockEsClient: ElasticsearchClientMock, - returnedMock: object -) => { - mockEsClient.search.mockReturnValueOnce( - // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values - elasticsearchClientMock.createSuccessTransportRequestPromise(returnedMock) - ); -}; - -const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; - -const resourceTypeAggsMockData = { - aggregations: { - resource_types: { - buckets: [ - { - key: 'pods', - doc_count: 3, - bucket_evaluation: { - buckets: [ - { - key: 'passed', - doc_count: 1, - }, - { - key: 'failed', - doc_count: 2, - }, - ], - }, - }, - { - key: 'etcd', - doc_count: 4, - bucket_evaluation: { - buckets: [ - // there is only one bucket here, in cases where aggs can't find an evaluation we count that as 0. - { - key: 'failed', - doc_count: 4, - }, - ], - }, - }, - ], - }, - }, -}; - -afterEach(() => { - jest.clearAllMocks(); -}); - -describe('testing round score', () => { - it('take decimal and expect the roundScore will return it with one digit after the dot ', async () => { - const score = roundScore(0.85245); - expect(score).toEqual(85.2); - }); -}); - -describe('general cloud posture score', () => { - it('expect to valid score from getAllFindingsStats', async () => { - mockCountResultOnce(mockEsClient, 10); // total findings - mockCountResultOnce(mockEsClient, 3); // pass findings - mockCountResultOnce(mockEsClient, 7); // fail findings - - const generalScore = await getAllFindingsStats(mockEsClient, 'randomCycleId'); - expect(generalScore).toEqual({ - name: 'general', - postureScore: 30, - totalFailed: 7, - totalFindings: 10, - totalPassed: 3, - }); - }); - - it("getAllFindingsStats throws when cycleId doesn't exists", async () => { - try { - await getAllFindingsStats(mockEsClient, 'randomCycleId'); - } catch (e) { - expect(e).toBeInstanceOf(Error); - expect(e.message).toEqual('missing stats'); - } - }); -}); - -describe('get benchmarks list', () => { - it('getBenchmarks - takes aggregated data and expect unique benchmarks array', async () => { - const returnedMock = { - aggregations: { - benchmarks: { - buckets: [ - { key: 'CIS Kubernetes', doc_count: 248514 }, - { key: 'GDPR', doc_count: 248514 }, - ], - }, - }, - }; - mockSearchResultOnce(mockEsClient, returnedMock); - const benchmarks = await getBenchmarks(mockEsClient); - expect(benchmarks).toEqual(['CIS Kubernetes', 'GDPR']); - }); -}); - -describe('score per benchmark, testing getBenchmarksStats', () => { - it('get data for only one benchmark and check', async () => { - mockCountResultOnce(mockEsClient, 10); // total findings - mockCountResultOnce(mockEsClient, 3); // pass findings - mockCountResultOnce(mockEsClient, 7); // fail findings - const benchmarkScore = await getBenchmarksStats(mockEsClient, 'randomCycleId', [ - 'CIS Benchmark', - ]); - expect(benchmarkScore).toEqual([ - { - name: 'CIS Benchmark', - postureScore: 30, - totalFailed: 7, - totalFindings: 10, - totalPassed: 3, - }, - ]); - }); - - it('get data two benchmarks and check', async () => { - mockCountResultOnce(mockEsClient, 10); // total findings - mockCountResultOnce(mockEsClient, 3); // pass findings - mockCountResultOnce(mockEsClient, 7); // fail findings - mockCountResultOnce(mockEsClient, 100); - mockCountResultOnce(mockEsClient, 50); - mockCountResultOnce(mockEsClient, 50); - const benchmarkScore = await getBenchmarksStats(mockEsClient, 'randomCycleId', [ - 'CIS Benchmark', - 'GDPR', - ]); - expect(benchmarkScore).toEqual([ - { - name: 'CIS Benchmark', - postureScore: 30, - totalFailed: 7, - totalFindings: 10, - totalPassed: 3, - }, - { - name: 'GDPR', - postureScore: 50, - totalFailed: 50, - totalFindings: 100, - totalPassed: 50, - }, - ]); - }); -}); - -describe('getResourceTypesAggs', () => { - it('get all resources types aggregations', async () => { - await mockSearchResultOnce(mockEsClient, resourceTypeAggsMockData); - const resourceTypeAggs = await getResourceTypesAggs(mockEsClient, 'RandomCycleId'); - expect(resourceTypeAggs).toEqual([ - { - resourceType: 'pods', - totalFindings: 3, - totalPassed: 1, - totalFailed: 2, - }, - { - resourceType: 'etcd', - totalFindings: 4, - totalPassed: 0, - totalFailed: 4, - }, - ]); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.ts b/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.ts deleted file mode 100644 index 828d7f932113..000000000000 --- a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.ts +++ /dev/null @@ -1,226 +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 type { ElasticsearchClient, IRouter, Logger } from 'src/core/server'; -import type { AggregationsMultiBucketAggregateBase } from '@elastic/elasticsearch/lib/api/types'; -import { number, UnknownRecord } from 'io-ts'; -import { transformError } from '@kbn/securitysolution-es-utils'; - -import type { BenchmarkStats, CloudPostureStats, Evaluation, Score } from '../../../common/types'; -import { - getBenchmarksQuery, - getFindingsEsQuery, - getLatestFindingQuery, - getRisksEsQuery, -} from './stats_queries'; -import { RULE_FAILED, RULE_PASSED } from '../../constants'; -import { STATS_ROUTE_PATH } from '../../../common/constants'; - -// TODO: use a schema decoder -function assertBenchmarkStats(v: unknown): asserts v is BenchmarkStats { - if ( - !UnknownRecord.is(v) || - !number.is(v.totalFindings) || - !number.is(v.totalPassed) || - !number.is(v.totalFailed) || - !number.is(v.postureScore) - ) { - throw new Error('missing stats'); - } -} - -interface LastCycle { - cycle_id: string; -} - -interface GroupFilename { - // TODO find the 'key', 'doc_count' interface - key: string; - doc_count: number; -} - -interface ResourceTypeBucket { - resource_types: AggregationsMultiBucketAggregateBase<{ - key: string; - doc_count: number; - bucket_evaluation: AggregationsMultiBucketAggregateBase; - }>; -} - -interface ResourceTypeEvaluationBucket { - key: Evaluation; - doc_count: number; -} - -/** - * @param value value is [0, 1] range - */ -export const roundScore = (value: number): Score => Number((value * 100).toFixed(1)); - -const calculatePostureScore = (total: number, passed: number, failed: number): Score | undefined => - passed + failed === 0 || total === undefined ? undefined : roundScore(passed / (passed + failed)); - -const getLatestCycleId = async (esClient: ElasticsearchClient) => { - const latestFinding = await esClient.search(getLatestFindingQuery(), { meta: true }); - const lastCycle = latestFinding.body.hits.hits[0]; - - if (lastCycle?._source?.cycle_id === undefined) { - throw new Error('cycle id is missing'); - } - return lastCycle?._source?.cycle_id; -}; - -export const getBenchmarks = async (esClient: ElasticsearchClient) => { - const queryResult = await esClient.search< - {}, - { benchmarks: AggregationsMultiBucketAggregateBase> } - >(getBenchmarksQuery(), { meta: true }); - const benchmarksBuckets = queryResult.body.aggregations?.benchmarks; - - if (!benchmarksBuckets || !Array.isArray(benchmarksBuckets?.buckets)) { - throw new Error('missing buckets'); - } - - return benchmarksBuckets.buckets.map((e) => e.key); -}; - -export const getAllFindingsStats = async ( - esClient: ElasticsearchClient, - cycleId: string -): Promise => { - const [findings, passedFindings, failedFindings] = await Promise.all([ - esClient.count(getFindingsEsQuery(cycleId), { meta: true }), - esClient.count(getFindingsEsQuery(cycleId, RULE_PASSED), { meta: true }), - esClient.count(getFindingsEsQuery(cycleId, RULE_FAILED), { meta: true }), - ]); - - const totalFindings = findings.body.count; - const totalPassed = passedFindings.body.count; - const totalFailed = failedFindings.body.count; - const postureScore = calculatePostureScore(totalFindings, totalPassed, totalFailed); - const stats = { - name: 'general', - postureScore, - totalFindings, - totalPassed, - totalFailed, - }; - - assertBenchmarkStats(stats); - - return stats; -}; - -export const getBenchmarksStats = async ( - esClient: ElasticsearchClient, - cycleId: string, - benchmarks: string[] -): Promise => { - const benchmarkPromises = benchmarks.map((benchmark) => { - const benchmarkFindings = esClient.count(getFindingsEsQuery(cycleId, undefined, benchmark), { - meta: true, - }); - const benchmarkPassedFindings = esClient.count( - getFindingsEsQuery(cycleId, RULE_PASSED, benchmark), - { meta: true } - ); - const benchmarkFailedFindings = esClient.count( - getFindingsEsQuery(cycleId, RULE_FAILED, benchmark), - { meta: true } - ); - - return Promise.all([benchmarkFindings, benchmarkPassedFindings, benchmarkFailedFindings]).then( - ([benchmarkFindingsResult, benchmarkPassedFindingsResult, benchmarkFailedFindingsResult]) => { - const totalFindings = benchmarkFindingsResult.body.count; - const totalPassed = benchmarkPassedFindingsResult.body.count; - const totalFailed = benchmarkFailedFindingsResult.body.count; - const postureScore = calculatePostureScore(totalFindings, totalPassed, totalFailed); - const stats = { - name: benchmark, - postureScore, - totalFindings, - totalPassed, - totalFailed, - }; - - assertBenchmarkStats(stats); - return stats; - } - ); - }); - - return Promise.all(benchmarkPromises); -}; - -export const getResourceTypesAggs = async ( - esClient: ElasticsearchClient, - cycleId: string -): Promise => { - const resourceTypesQueryResult = await esClient.search( - getRisksEsQuery(cycleId), - { meta: true } - ); - - const resourceTypesAggs = resourceTypesQueryResult.body.aggregations?.resource_types.buckets; - if (!Array.isArray(resourceTypesAggs)) throw new Error('missing resources types buckets'); - - return resourceTypesAggs.map((bucket) => { - const evalBuckets = bucket.bucket_evaluation.buckets; - if (!Array.isArray(evalBuckets)) throw new Error('missing resources types evaluations buckets'); - - const failedBucket = evalBuckets.find((evalBucket) => evalBucket.key === RULE_FAILED); - const passedBucket = evalBuckets.find((evalBucket) => evalBucket.key === RULE_PASSED); - - return { - resourceType: bucket.key, - totalFindings: bucket.doc_count, - totalFailed: failedBucket?.doc_count || 0, - totalPassed: passedBucket?.doc_count || 0, - }; - }); -}; - -export const defineGetStatsRoute = (router: IRouter, logger: Logger): void => - router.get( - { - path: STATS_ROUTE_PATH, - validate: false, - }, - async (context, _, response) => { - try { - const esClient = context.core.elasticsearch.client.asCurrentUser; - const [benchmarks, latestCycleID] = await Promise.all([ - getBenchmarks(esClient), - getLatestCycleId(esClient), - ]); - - // TODO: Utilize ES "Point in Time" feature https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html - const [allFindingsStats, benchmarksStats, resourceTypesAggs] = await Promise.all([ - getAllFindingsStats(esClient, latestCycleID), - getBenchmarksStats(esClient, latestCycleID, benchmarks), - getResourceTypesAggs(esClient, latestCycleID), - ]); - - const body: CloudPostureStats = { - ...allFindingsStats, - benchmarksStats, - resourceTypesAggs, - }; - - return response.ok({ - body, - }); - } catch (err) { - const error = transformError(err); - - return response.customError({ - body: { message: error.message }, - statusCode: error.statusCode, - }); - } - } - ); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats_queries.ts b/x-pack/plugins/cloud_security_posture/server/routes/stats/stats_queries.ts deleted file mode 100644 index b88182a27fee..000000000000 --- a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats_queries.ts +++ /dev/null @@ -1,111 +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 type { - SearchRequest, - CountRequest, - QueryDslQueryContainer, -} from '@elastic/elasticsearch/lib/api/types'; - -import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; -import { Evaluation } from '../../../common/types'; - -export const getFindingsEsQuery = ( - cycleId: string, - evaluationResult?: string, - benchmark?: string -): CountRequest => { - const filter: QueryDslQueryContainer[] = [{ term: { 'cycle_id.keyword': cycleId } }]; - - if (benchmark) { - filter.push({ term: { 'rule.benchmark.keyword': benchmark } }); - } - - if (evaluationResult) { - filter.push({ term: { 'result.evaluation.keyword': evaluationResult } }); - } - - return { - index: CSP_KUBEBEAT_INDEX_PATTERN, - query: { - bool: { filter }, - }, - }; -}; - -export const getResourcesEvaluationEsQuery = ( - cycleId: string, - evaluation: Evaluation, - size: number, - resources?: string[] -): SearchRequest => { - const query: QueryDslQueryContainer = { - bool: { - filter: [ - { term: { 'cycle_id.keyword': cycleId } }, - { term: { 'result.evaluation.keyword': evaluation } }, - ], - }, - }; - if (resources) { - query.bool!.must = { terms: { 'resource.filename.keyword': resources } }; - } - return { - index: CSP_KUBEBEAT_INDEX_PATTERN, - size, - query, - aggs: { - group: { - terms: { field: 'resource.filename.keyword' }, - }, - }, - sort: 'resource.filename.keyword', - }; -}; - -export const getBenchmarksQuery = (): SearchRequest => ({ - index: CSP_KUBEBEAT_INDEX_PATTERN, - size: 0, - aggs: { - benchmarks: { - terms: { field: 'rule.benchmark.keyword' }, - }, - }, -}); - -export const getLatestFindingQuery = (): SearchRequest => ({ - index: CSP_KUBEBEAT_INDEX_PATTERN, - size: 1, - /* @ts-expect-error TS2322 - missing SearchSortContainer */ - sort: { '@timestamp': 'desc' }, - query: { - match_all: {}, - }, -}); - -export const getRisksEsQuery = (cycleId: string): SearchRequest => ({ - index: CSP_KUBEBEAT_INDEX_PATTERN, - size: 0, - query: { - bool: { - filter: [{ term: { 'cycle_id.keyword': cycleId } }], - }, - }, - aggs: { - resource_types: { - terms: { - field: 'resource.type.keyword', - }, - aggs: { - bucket_evaluation: { - terms: { - field: 'result.evaluation.keyword', - }, - }, - }, - }, - }, -}); diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts new file mode 100644 index 000000000000..fcff7449fb3f --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { + SavedObjectsType, + SavedObjectsValidationMap, +} from '../../../../../../src/core/server'; +import { + type CspRuleSchema, + cspRuleSchema, + cspRuleAssetSavedObjectType, +} from '../../../common/schemas/csp_rule'; + +const validationMap: SavedObjectsValidationMap = { + '1.0.0': cspRuleSchema, +}; + +export const ruleAssetSavedObjectMappings: SavedObjectsType['mappings'] = { + dynamic: false, + properties: { + name: { + type: 'text', // search + fields: { + // TODO: how is fields mapping shared with UI ? + raw: { + type: 'keyword', // sort + }, + }, + }, + description: { + type: 'text', + }, + }, +}; + +export const cspRuleAssetType: SavedObjectsType = { + name: cspRuleAssetSavedObjectType, + hidden: false, + namespaceType: 'agnostic', + mappings: ruleAssetSavedObjectMappings, + schemas: validationMap, + // migrations: {} + management: { + importableAndExportable: true, + visibleInManagement: true, + getTitle: (savedObject) => + `${i18n.translate('xpack.csp.cspSettings.rules', { + defaultMessage: `CSP Security Rules - `, + })} ${savedObject.attributes.benchmark.name} ${savedObject.attributes.benchmark.version} ${ + savedObject.attributes.name + }`, + }, +}; diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts new file mode 100644 index 000000000000..1cb08ddc1be1 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.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 type { ISavedObjectsRepository } from 'src/core/server'; +import { CIS_BENCHMARK_1_4_1_RULES } from './rules'; +import { cspRuleAssetSavedObjectType } from '../../../common/schemas/csp_rule'; + +export const initializeCspRules = async (client: ISavedObjectsRepository) => { + const existingRules = await client.find({ type: cspRuleAssetSavedObjectType, perPage: 1 }); + + // TODO: version? + if (existingRules.total !== 0) return; + + try { + await client.bulkCreate(CIS_BENCHMARK_1_4_1_RULES); + } catch (e) { + // TODO: add logger + // TODO: handle error + } +}; diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/rules.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/rules.ts new file mode 100644 index 000000000000..8f3d6df65b6b --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/rules.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 type { SavedObjectsBulkCreateObject } from 'src/core/server'; +import type { CspRuleSchema } from '../../../common/schemas/csp_rule'; +import { cspRuleAssetSavedObjectType } from '../../../common/schemas/csp_rule'; + +const benchmark = { name: 'CIS', version: '1.4.1' } as const; + +const RULES: CspRuleSchema[] = [ + { + id: '1.1.1', + name: 'Ensure that the API server pod specification file permissions are set to 644 or more restrictive (Automated)', + description: 'Disable anonymous requests to the API server', + rationale: + 'When enabled, requests that are not rejected by other configured authentication methods\nare treated as anonymous requests. These requests are then served by the API server. You\nshould rely on authentication to authorize access and disallow anonymous requests.\nIf you are using RBAC authorization, it is generally considered reasonable to allow\nanonymous access to the API Server for health checks and discovery purposes, and hence\nthis recommendation is not scored. However, you should consider whether anonymous\ndiscovery is an acceptable risk for your purposes.', + impact: 'Anonymous requests will be rejected.', + default_value: 'By default, anonymous access is enabled.', + remediation: + 'Edit the API server pod specification file /etc/kubernetes/manifests/kubeapiserver.yaml on the master node and set the below parameter.\n--anonymous-auth=false', + tags: [], + enabled: true, + muted: false, + benchmark, + }, + { + id: '1.1.2', + name: 'Ensure that the --basic-auth-file argument is not set (Scored)', + description: 'Do not use basic authentication', + rationale: + 'Basic authentication uses plaintext credentials for authentication. Currently, the basic\nauthentication credentials last indefinitely, and the password cannot be changed without\nrestarting API server. The basic authentication is currently supported for convenience.\nHence, basic authentication should not be used', + impact: + 'You will have to configure and use alternate authentication mechanisms such as tokens and\ncertificates. Username and password for basic authentication could no longer be used.', + default_value: 'By default, basic authentication is not set', + remediation: + 'Follow the documentation and configure alternate mechanisms for authentication. Then,\nedit the API server pod specification file /etc/kubernetes/manifests/kubeapiserver.yaml on the master node and remove the --basic-auth-file=\nparameter.', + tags: [], + enabled: true, + muted: false, + benchmark, + }, +]; + +export const CIS_BENCHMARK_1_4_1_RULES: Array> = + RULES.map((rule) => ({ + attributes: rule, + id: rule.id, + type: cspRuleAssetSavedObjectType, + })); diff --git a/x-pack/plugins/cloud_security_posture/server/types.ts b/x-pack/plugins/cloud_security_posture/server/types.ts index 707002461d2a..4e70027013df 100644 --- a/x-pack/plugins/cloud_security_posture/server/types.ts +++ b/x-pack/plugins/cloud_security_posture/server/types.ts @@ -10,6 +10,8 @@ import type { PluginStart as DataPluginStart, } from '../../../../src/plugins/data/server'; +import type { FleetStartContract } from '../../fleet/server'; + // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface CspServerPluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -25,6 +27,5 @@ export interface CspServerPluginSetupDeps { export interface CspServerPluginStartDeps { // required data: DataPluginStart; - - // optional + fleet: FleetStartContract; } diff --git a/x-pack/plugins/cloud_security_posture/tsconfig.json b/x-pack/plugins/cloud_security_posture/tsconfig.json index 47625c59eae6..d7902b8b0597 100755 --- a/x-pack/plugins/cloud_security_posture/tsconfig.json +++ b/x-pack/plugins/cloud_security_posture/tsconfig.json @@ -19,6 +19,7 @@ "references": [ { "path": "../../../src/core/tsconfig.json" }, { "path": "../../../src/plugins/data/tsconfig.json" }, - { "path": "../../../src/plugins/navigation/tsconfig.json" } + { "path": "../../../src/plugins/navigation/tsconfig.json" }, + { "path": "../../../x-pack/plugins/fleet/tsconfig.json" }, ] } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/multi_select_picker/multi_select_picker.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/multi_select_picker/multi_select_picker.tsx index 8b95296b5f82..24523450a0e7 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/multi_select_picker/multi_select_picker.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/multi_select_picker/multi_select_picker.tsx @@ -15,13 +15,16 @@ import { EuiPopoverTitle, EuiSpacer, } from '@elastic/eui'; -import React, { FC, ReactNode, useEffect, useState } from 'react'; +import React, { FC, ReactNode, useEffect, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { euiDarkVars as euiThemeDark, euiLightVars as euiThemeLight } from '@kbn/ui-theme'; +import { useDataVisualizerKibana } from '../../../kibana_context'; export interface Option { name?: string | ReactNode; value: string; checked?: 'on' | 'off'; + disabled?: boolean; } const NoFilterItems = () => { @@ -41,6 +44,15 @@ const NoFilterItems = () => { ); }; +export function useCurrentEuiTheme() { + const { services } = useDataVisualizerKibana(); + const uiSettings = services.uiSettings; + return useMemo( + () => (uiSettings.get('theme:darkMode') ? euiThemeDark : euiThemeLight), + [uiSettings] + ); +} + export const MultiSelectPicker: FC<{ options: Option[]; onChange?: (items: string[]) => void; @@ -48,6 +60,8 @@ export const MultiSelectPicker: FC<{ checkedOptions: string[]; dataTestSubj: string; }> = ({ options, onChange, title, checkedOptions, dataTestSubj }) => { + const euiTheme = useCurrentEuiTheme(); + const [items, setItems] = useState(options); const [searchTerm, setSearchTerm] = useState(''); @@ -68,6 +82,7 @@ export const MultiSelectPicker: FC<{ const closePopover = () => { setIsPopoverOpen(false); + setSearchTerm(''); }; const handleOnChange = (index: number) => { @@ -126,7 +141,13 @@ export const MultiSelectPicker: FC<{ checked={checked ? 'on' : undefined} key={index} onClick={() => handleOnChange(index)} - style={{ flexDirection: 'row' }} + style={{ + flexDirection: 'row', + color: + item.disabled === true + ? euiTheme.euiColorDisabledText + : euiTheme.euiTextColor, + }} data-test-subj={`${dataTestSubj}-option-${item.value}${ checked ? '-checked' : '' }`} diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.tsx index c9dca9828b1d..1527b3b527c6 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.tsx @@ -72,7 +72,6 @@ export const ColumnChart: FC = ({ )}
= ({ field.fieldName !== undefined ) { options.push({ value: field.fieldName }); + } else { + options.push({ value: field.fieldName, disabled: true }); } }); } diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index f84668068f41..c7c3fb203746 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -7,21 +7,37 @@ import { i18n } from '@kbn/i18n'; -export const ENTERPRISE_SEARCH_PLUGIN = { +export const ENTERPRISE_SEARCH_OVERVIEW_PLUGIN = { ID: 'enterpriseSearch', - NAME: i18n.translate('xpack.enterpriseSearch.productName', { + NAME: i18n.translate('xpack.enterpriseSearch.overview.productName', { defaultMessage: 'Enterprise Search', }), - NAV_TITLE: i18n.translate('xpack.enterpriseSearch.navTitle', { + NAV_TITLE: i18n.translate('xpack.enterpriseSearch.overview.navTitle', { defaultMessage: 'Overview', }), - DESCRIPTION: i18n.translate('xpack.enterpriseSearch.FeatureCatalogue.description', { + DESCRIPTION: i18n.translate('xpack.enterpriseSearch.overview.description', { defaultMessage: 'Create search experiences with a refined set of APIs and tools.', }), URL: '/app/enterprise_search/overview', LOGO: 'logoEnterpriseSearch', }; +export const ENTERPRISE_SEARCH_CONTENT_PLUGIN = { + ID: 'enterpriseSearchContent', + NAME: i18n.translate('xpack.enterpriseSearch.content.productName', { + defaultMessage: 'Enterprise Search', + }), + NAV_TITLE: i18n.translate('xpack.enterpriseSearch.content.navTitle', { + defaultMessage: 'Content', + }), + DESCRIPTION: i18n.translate('xpack.enterpriseSearch.content.description', { + defaultMessage: + 'Enterprise search offers a number of ways to easily make your data searchable. Choose from the web crawler, Elasticsearch indices, API, direct uploads, or thrid party connectors.', // TODO: Make sure this content is correct. + }), + URL: '/app/enterprise_search/content', + LOGO: 'logoEnterpriseSearch', +}; + export const APP_SEARCH_PLUGIN = { ID: 'appSearch', NAME: i18n.translate('xpack.enterpriseSearch.appSearch.productName', { @@ -72,4 +88,5 @@ export const READ_ONLY_MODE_HEADER = 'x-ent-search-read-only-mode'; export const ENTERPRISE_SEARCH_KIBANA_COOKIE = '_enterprise_search'; -export const LOGS_SOURCE_ID = 'ent-search-logs'; +export const ENTERPRISE_SEARCH_RELEVANCE_LOGS_SOURCE_ID = 'ent-search-logs'; +export const ENTERPRISE_SEARCH_AUDIT_LOGS_SOURCE_ID = 'ent-search-audit-logs'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts index 96bf4c062dba..6eef9eccee9b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts @@ -44,7 +44,7 @@ export const AppLogic = kea [selectors.account, LicensingLogic.selectors.hasPlatinumLicense], - ({ role }, hasPlatinumLicense) => (role ? getRoleAbilities(role, hasPlatinumLicense) : {}), + ({ role }) => (role ? getRoleAbilities(role) : {}), ], }, }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.test.ts index ddfc23b5aa62..bb20e0e639aa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.test.ts @@ -297,7 +297,25 @@ describe('CrawlCustomSettingsFlyoutLogic', () => { }); describe('startCustomCrawl', () => { - it('starts a custom crawl with the user set values', async () => { + it('can start a custom crawl for selected domains', async () => { + mount({ + includeSitemapsInRobotsTxt: true, + maxCrawlDepth: 5, + selectedDomainUrls: ['https://www.elastic.co', 'https://swiftype.com'], + }); + jest.spyOn(CrawlerLogic.actions, 'startCrawl'); + + CrawlCustomSettingsFlyoutLogic.actions.startCustomCrawl(); + await nextTick(); + + expect(CrawlerLogic.actions.startCrawl).toHaveBeenCalledWith({ + domain_allowlist: ['https://www.elastic.co', 'https://swiftype.com'], + max_crawl_depth: 5, + sitemap_discovery_disabled: false, + }); + }); + + it('can start a custom crawl selected domains, sitemaps, and seed urls', async () => { mount({ includeSitemapsInRobotsTxt: true, maxCrawlDepth: 5, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.ts index f22dcc7487af..3b04e1b28c17 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.ts @@ -11,7 +11,7 @@ import { flashAPIErrors } from '../../../../../shared/flash_messages'; import { HttpLogic } from '../../../../../shared/http'; import { EngineLogic } from '../../../engine'; -import { CrawlerLogic } from '../../crawler_logic'; +import { CrawlerLogic, CrawlRequestOverrides } from '../../crawler_logic'; import { DomainConfig, DomainConfigFromServer } from '../../types'; import { domainConfigServerToClient } from '../../utils'; import { extractDomainAndEntryPointFromUrl } from '../add_domain/utils'; @@ -213,13 +213,23 @@ export const CrawlCustomSettingsFlyoutLogic = kea< actions.fetchDomainConfigData(); }, startCustomCrawl: () => { - CrawlerLogic.actions.startCrawl({ - domain_allowlist: values.selectedDomainUrls, - max_crawl_depth: values.maxCrawlDepth, - seed_urls: [...values.selectedEntryPointUrls, ...values.customEntryPointUrls], - sitemap_urls: [...values.selectedSitemapUrls, ...values.customSitemapUrls], + const overrides: CrawlRequestOverrides = { sitemap_discovery_disabled: !values.includeSitemapsInRobotsTxt, - }); + max_crawl_depth: values.maxCrawlDepth, + domain_allowlist: values.selectedDomainUrls, + }; + + const seedUrls = [...values.selectedEntryPointUrls, ...values.customEntryPointUrls]; + if (seedUrls.length > 0) { + overrides.seed_urls = seedUrls; + } + + const sitemapUrls = [...values.selectedSitemapUrls, ...values.customSitemapUrls]; + if (sitemapUrls.length > 0) { + overrides.sitemap_urls = sitemapUrls; + } + + CrawlerLogic.actions.startCrawl(overrides); }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_logic.ts index 68b1cb6ec9b2..2d1b8a9e7aa2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_logic.ts @@ -33,7 +33,7 @@ const ACTIVE_STATUSES = [ CrawlerStatus.Canceling, ]; -interface CrawlRequestOverrides { +export interface CrawlRequestOverrides { domain_allowlist?: string[]; max_crawl_depth?: number; seed_urls?: string[]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx index dce56a05f8f8..c2b40a2c0aa0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx @@ -14,7 +14,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EnterpriseSearchPageTemplate } from '../../../../shared/layout'; +import { EnterpriseSearchPageTemplateWrapper } from '../../../../shared/layout'; import { rerender } from '../../../../test_helpers'; jest.mock('./curation_logic', () => ({ CurationLogic: jest.fn() })); @@ -55,7 +55,7 @@ describe('Curation', () => { setMockValues({ dataLoading: true }); const wrapper = shallow(); - expect(wrapper.is(EnterpriseSearchPageTemplate)).toBe(true); + expect(wrapper.is(EnterpriseSearchPageTemplateWrapper)).toBe(true); }); it('renders a view for automated curations', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx index d1b0f43d976a..1c49c077e7a6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx @@ -10,7 +10,7 @@ import { useParams } from 'react-router-dom'; import { useValues, useActions } from 'kea'; -import { EnterpriseSearchPageTemplate } from '../../../../shared/layout'; +import { EnterpriseSearchPageTemplateWrapper } from '../../../../shared/layout'; import { AutomatedCuration } from './automated_curation'; import { CurationLogic } from './curation_logic'; @@ -26,7 +26,7 @@ export const Curation: React.FC = () => { }, [curationId]); if (dataLoading) { - return ; + return ; } return isAutomated ? : ; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/automated_curations_history_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/automated_curations_history_panel.tsx index 04f786b1ee1e..6a82108b971c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/automated_curations_history_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/automated_curations_history_panel.tsx @@ -11,6 +11,7 @@ import { useValues } from 'kea'; import { i18n } from '@kbn/i18n'; +import { ENTERPRISE_SEARCH_RELEVANCE_LOGS_SOURCE_ID } from '../../../../../../../../common/constants'; import { EntSearchLogStream } from '../../../../../../shared/log_stream'; import { DataPanel } from '../../../../data_panel'; import { EngineLogic } from '../../../../engine'; @@ -49,6 +50,7 @@ export const AutomatedCurationsHistoryPanel: React.FC = () => { hasBorder > { hasBorder > ( + <> + + + + +); + +export const FlyoutHeader: React.FC = () => { + return ( + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.elasticsearchIndex.title', + { + defaultMessage: 'Connect an Elasticsearch index', + } + )} +

+
+
+ ); +}; + +export const FlyoutBody: React.FC = () => { + return ( + }> + +

+ + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.elasticsearchIndex.link', + { + defaultMessage: 'Learn more about using an existing index', + } + )} + + ), + }} + /> +

+
+ + {'{Form fields go here}'} +
+ ); +}; + +export const FlyoutFooter: React.FC = () => { + // TODO: replace these + const { textInput, isUploading } = useValues(DocumentCreationLogic); + // TODO: replace 'onSubmitJson' + const { onSubmitJson, closeDocumentCreation } = useActions(DocumentCreationLogic); + + return ( + + + + {CANCEL_BUTTON_LABEL} + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.elasticsearchIndex.button', + { + defaultMessage: 'Connect to index', + } + )} + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/index.ts index 8fd199f9d602..05ccb398c68f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/index.ts @@ -7,5 +7,7 @@ export { ShowCreationModes } from './show_creation_modes'; export { ApiCodeExample } from './api_code_example'; -export { PasteJsonText } from './paste_json_text'; -export { UploadJsonFile } from './upload_json_file'; +export { JsonFlyout } from './json_flyout'; +export { ElasticsearchIndex } from './elasticsearch_index'; +export { PasteJsonTextTabContent, PasteJsonTextFooterContent } from './paste_json_text'; +export { UploadJsonFileTabContent, UploadJsonFileFooterContent } from './upload_json_file'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/layout/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/json_flyout.scss similarity index 76% rename from x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/layout/index.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/json_flyout.scss index c35cb26dfe8f..310cf4dac95d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/layout/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/json_flyout.scss @@ -5,4 +5,6 @@ * 2.0. */ -export { PolicyEventFiltersLayout } from './policy_event_filters_layout'; +.enterpriseSearchTabbedFlyoutHeader.euiFlyoutHeader { + padding-bottom: 0; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/json_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/json_flyout.test.tsx new file mode 100644 index 000000000000..a31a46007a8b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/json_flyout.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter, EuiTabs, EuiTab } from '@elastic/eui'; + +import { + JsonFlyout, + PasteJsonTextTabContent, + UploadJsonFileTabContent, + PasteJsonTextFooterContent, + UploadJsonFileFooterContent, +} from './'; + +describe('JsonFlyout', () => { + const values = { + activeJsonTab: 'uploadTab', + }; + const actions = { + closeDocumentCreation: jest.fn(), + setActiveJsonTab: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders a flyout components', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiFlyoutHeader)).toHaveLength(1); + expect(wrapper.find(EuiFlyoutBody)).toHaveLength(1); + expect(wrapper.find(EuiFlyoutFooter)).toHaveLength(1); + }); + + it('renders Upload json components and calls method with correct param', () => { + const wrapper = shallow(); + const tabs = wrapper.find(EuiTabs).find(EuiTab); + + expect(tabs).toHaveLength(2); + + tabs.at(1).simulate('click'); + + expect(actions.setActiveJsonTab).toHaveBeenCalledWith('pasteTab'); + expect(wrapper.find(UploadJsonFileTabContent)).toHaveLength(1); + expect(wrapper.find(UploadJsonFileFooterContent)).toHaveLength(1); + }); + + it('renders Paste json components and calls method with correct param', () => { + setMockValues({ ...values, activeJsonTab: 'pasteTab' }); + const wrapper = shallow(); + const tabs = wrapper.find(EuiTabs).find(EuiTab); + + expect(tabs).toHaveLength(2); + + tabs.at(0).simulate('click'); + + expect(actions.setActiveJsonTab).toHaveBeenCalledWith('uploadTab'); + expect(wrapper.find(PasteJsonTextTabContent)).toHaveLength(1); + expect(wrapper.find(PasteJsonTextFooterContent)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/json_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/json_flyout.tsx new file mode 100644 index 000000000000..3f78c10ee219 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/json_flyout.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues, useActions } from 'kea'; + +import { + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiSpacer, + EuiTabs, + EuiTab, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { FLYOUT_ARIA_LABEL_ID } from '../constants'; +import { Errors } from '../creation_response_components'; +import { DocumentCreationLogic, ActiveJsonTab } from '../index'; + +import { + PasteJsonTextTabContent, + UploadJsonFileTabContent, + PasteJsonTextFooterContent, + UploadJsonFileFooterContent, +} from './'; + +import './json_flyout.scss'; + +export const JsonFlyout: React.FC = () => { + const { activeJsonTab } = useValues(DocumentCreationLogic); + const { setActiveJsonTab } = useActions(DocumentCreationLogic); + + const tabs = [ + { + id: 'uploadTab', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.jsonFlyout.uploadTabName', + { + defaultMessage: 'Upload', + } + ), + content: , + }, + { + id: 'pasteTab', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.jsonFlyout.pasteTabName', + { + defaultMessage: 'Paste', + } + ), + content: , + }, + ]; + + return ( + <> + + +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.jsonFlyout.title', { + defaultMessage: 'Paste or upload JSON', + })} +

+
+ + + {tabs.map((tab, index) => ( + setActiveJsonTab(tab.id as ActiveJsonTab)} + isSelected={tab.id === activeJsonTab} + > + {tab.name} + + ))} + +
+ }> + {tabs.find((tab) => tab.id === activeJsonTab)?.content} + + + {activeJsonTab === 'uploadTab' ? ( + + ) : ( + + )} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.test.tsx index 863e87a28f40..492b9593e5c8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.test.tsx @@ -15,9 +15,7 @@ import { EuiTextArea, EuiButtonEmpty, EuiButton } from '@elastic/eui'; import { rerender } from '../../../../test_helpers'; -import { Errors } from '../creation_response_components'; - -import { PasteJsonText, FlyoutHeader, FlyoutBody, FlyoutFooter } from './paste_json_text'; +import { PasteJsonTextTabContent, PasteJsonTextFooterContent } from './paste_json_text'; describe('PasteJsonText', () => { const values = { @@ -42,24 +40,10 @@ describe('PasteJsonText', () => { setMockActions(actions); }); - it('renders', () => { - const wrapper = shallow(); - expect(wrapper.find(FlyoutHeader)).toHaveLength(1); - expect(wrapper.find(FlyoutBody)).toHaveLength(1); - expect(wrapper.find(FlyoutFooter)).toHaveLength(1); - }); - - describe('FlyoutHeader', () => { - it('renders', () => { - const wrapper = shallow(); - expect(wrapper.find('h2').text()).toEqual('Create documents'); - }); - }); - - describe('FlyoutBody', () => { + describe('PasteJsonTextTabContent', () => { it('renders and updates the textarea value', () => { setMockValues({ ...values, textInput: 'lorem ipsum' }); - const wrapper = shallow(); + const wrapper = shallow(); const textarea = wrapper.find(EuiTextArea); expect(textarea.prop('value')).toEqual('lorem ipsum'); @@ -67,35 +51,25 @@ describe('PasteJsonText', () => { textarea.simulate('change', { target: { value: 'dolor sit amet' } }); expect(actions.setTextInput).toHaveBeenCalledWith('dolor sit amet'); }); - - it('shows an error banner and sets invalid form props if errors exist', () => { - const wrapper = shallow(); - expect(wrapper.find(EuiTextArea).prop('isInvalid')).toBe(false); - - setMockValues({ ...values, errors: ['some error'] }); - rerender(wrapper); - expect(wrapper.find(EuiTextArea).prop('isInvalid')).toBe(true); - expect(wrapper.prop('banner').type).toEqual(Errors); - }); }); - describe('FlyoutFooter', () => { + describe('PasteJsonTextFooterContent', () => { it('closes the modal', () => { - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(EuiButtonEmpty).simulate('click'); expect(actions.closeDocumentCreation).toHaveBeenCalled(); }); it('submits json', () => { - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(EuiButton).simulate('click'); expect(actions.onSubmitJson).toHaveBeenCalled(); }); it('disables/enables the Continue button based on whether text has been entered', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiButton).prop('isDisabled')).toBe(false); setMockValues({ ...values, textInput: '' }); @@ -104,7 +78,7 @@ describe('PasteJsonText', () => { }); it('sets isLoading based on isUploading', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiButton).prop('isLoading')).toBe(false); setMockValues({ ...values, isUploading: true }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.tsx index 33f1187a8855..4eae3b4f520d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.tsx @@ -10,12 +10,8 @@ import React from 'react'; import { useValues, useActions } from 'kea'; import { - EuiFlyoutHeader, - EuiTitle, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlexGroup, EuiFlexItem, + EuiFlexGroup, EuiButton, EuiButtonEmpty, EuiTextArea, @@ -27,35 +23,11 @@ import { i18n } from '@kbn/i18n'; import { CANCEL_BUTTON_LABEL, CONTINUE_BUTTON_LABEL } from '../../../../shared/constants'; import { AppLogic } from '../../../app_logic'; -import { FLYOUT_ARIA_LABEL_ID } from '../constants'; -import { Errors } from '../creation_response_components'; import { DocumentCreationLogic } from '../index'; import './paste_json_text.scss'; -export const PasteJsonText: React.FC = () => ( - <> - - - - -); - -export const FlyoutHeader: React.FC = () => { - return ( - - -

- {i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.pasteJsonText.title', { - defaultMessage: 'Create documents', - })} -

-
-
- ); -}; - -export const FlyoutBody: React.FC = () => { +export const PasteJsonTextTabContent: React.FC = () => { const { configuredLimits: { engine: { maxDocumentByteSize }, @@ -66,7 +38,7 @@ export const FlyoutBody: React.FC = () => { const { setTextInput } = useActions(DocumentCreationLogic); return ( - }> + <>

{i18n.translate( @@ -92,26 +64,24 @@ export const FlyoutBody: React.FC = () => { fullWidth rows={12} /> - + ); }; -export const FlyoutFooter: React.FC = () => { +export const PasteJsonTextFooterContent: React.FC = () => { const { textInput, isUploading } = useValues(DocumentCreationLogic); const { onSubmitJson, closeDocumentCreation } = useActions(DocumentCreationLogic); return ( - - - - {CANCEL_BUTTON_LABEL} - - - - {CONTINUE_BUTTON_LABEL} - - - - + + + {CANCEL_BUTTON_LABEL} + + + + {CONTINUE_BUTTON_LABEL} + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/show_creation_modes.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/show_creation_modes.tsx index e1ba5ca354ae..ff34f0ff7a0b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/show_creation_modes.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/show_creation_modes.tsx @@ -38,7 +38,7 @@ export const ShowCreationModes: React.FC = () => { - + {CANCEL_BUTTON_LABEL} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.test.tsx index 9b8820434fad..38689279c8fc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.test.tsx @@ -15,9 +15,7 @@ import { EuiFilePicker, EuiButtonEmpty, EuiButton } from '@elastic/eui'; import { rerender } from '../../../../test_helpers'; -import { Errors } from '../creation_response_components'; - -import { UploadJsonFile, FlyoutHeader, FlyoutBody, FlyoutFooter } from './upload_json_file'; +import { UploadJsonFileTabContent, UploadJsonFileFooterContent } from './upload_json_file'; describe('UploadJsonFile', () => { const mockFile = new File(['mock'], 'mock.json', { type: 'application/json' }); @@ -43,23 +41,9 @@ describe('UploadJsonFile', () => { setMockActions(actions); }); - it('renders', () => { - const wrapper = shallow(); - expect(wrapper.find(FlyoutHeader)).toHaveLength(1); - expect(wrapper.find(FlyoutBody)).toHaveLength(1); - expect(wrapper.find(FlyoutFooter)).toHaveLength(1); - }); - - describe('FlyoutHeader', () => { - it('renders', () => { - const wrapper = shallow(); - expect(wrapper.find('h2').text()).toEqual('Drag and drop .json'); - }); - }); - - describe('FlyoutBody', () => { + describe('UploadJsonFileTabContent', () => { it('updates fileInput when files are added & removed', () => { - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(EuiFilePicker).simulate('change', [mockFile]); expect(actions.setFileInput).toHaveBeenCalledWith(mockFile); @@ -69,42 +53,32 @@ describe('UploadJsonFile', () => { }); it('sets isLoading based on isUploading', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiFilePicker).prop('isLoading')).toBe(false); setMockValues({ ...values, isUploading: true }); rerender(wrapper); expect(wrapper.find(EuiFilePicker).prop('isLoading')).toBe(true); }); - - it('shows an error banner and sets invalid form props if errors exist', () => { - const wrapper = shallow(); - expect(wrapper.find(EuiFilePicker).prop('isInvalid')).toBe(false); - - setMockValues({ ...values, errors: ['some error'] }); - rerender(wrapper); - expect(wrapper.find(EuiFilePicker).prop('isInvalid')).toBe(true); - expect(wrapper.prop('banner').type).toEqual(Errors); - }); }); - describe('FlyoutFooter', () => { + describe('UploadJsonFileFooterContent', () => { it('closes the flyout', () => { - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(EuiButtonEmpty).simulate('click'); expect(actions.closeDocumentCreation).toHaveBeenCalled(); }); it('submits the json file', () => { - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(EuiButton).simulate('click'); expect(actions.onSubmitFile).toHaveBeenCalled(); }); it('disables/enables the Continue button based on whether files have been uploaded', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiButton).prop('isDisabled')).toBe(true); setMockValues({ ...values, fineInput: mockFile }); @@ -113,7 +87,7 @@ describe('UploadJsonFile', () => { }); it('sets isLoading based on isUploading', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiButton).prop('isLoading')).toBe(false); setMockValues({ ...values, isUploading: true }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.tsx index ceddd406d19c..ec172f0434dc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.tsx @@ -10,10 +10,6 @@ import React from 'react'; import { useValues, useActions } from 'kea'; import { - EuiFlyoutHeader, - EuiTitle, - EuiFlyoutBody, - EuiFlyoutFooter, EuiFlexGroup, EuiFlexItem, EuiButton, @@ -27,34 +23,9 @@ import { i18n } from '@kbn/i18n'; import { CANCEL_BUTTON_LABEL, CONTINUE_BUTTON_LABEL } from '../../../../shared/constants'; import { AppLogic } from '../../../app_logic'; -import { FLYOUT_ARIA_LABEL_ID } from '../constants'; -import { Errors } from '../creation_response_components'; import { DocumentCreationLogic } from '../index'; -export const UploadJsonFile: React.FC = () => ( - <> - - - - -); - -export const FlyoutHeader: React.FC = () => { - return ( - - -

- {i18n.translate( - 'xpack.enterpriseSearch.appSearch.documentCreation.uploadJsonFile.title', - { defaultMessage: 'Drag and drop .json' } - )} -

- - - ); -}; - -export const FlyoutBody: React.FC = () => { +export const UploadJsonFileTabContent: React.FC = () => { const { configuredLimits: { engine: { maxDocumentByteSize }, @@ -65,7 +36,7 @@ export const FlyoutBody: React.FC = () => { const { setFileInput } = useActions(DocumentCreationLogic); return ( - }> + <>

{i18n.translate( @@ -86,26 +57,24 @@ export const FlyoutBody: React.FC = () => { isLoading={isUploading} isInvalid={errors.length > 0} /> - + ); }; -export const FlyoutFooter: React.FC = () => { +export const UploadJsonFileFooterContent: React.FC = () => { const { fileInput, isUploading } = useValues(DocumentCreationLogic); const { onSubmitFile, closeDocumentCreation } = useActions(DocumentCreationLogic); return ( - - - - {CANCEL_BUTTON_LABEL} - - - - {CONTINUE_BUTTON_LABEL} - - - - + + + {CANCEL_BUTTON_LABEL} + + + + {CONTINUE_BUTTON_LABEL} + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx index cef0a8d05ec6..8fa0f86896c3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx @@ -15,7 +15,7 @@ import { useLocation } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { EuiCard } from '@elastic/eui'; +import { EuiCard, EuiText } from '@elastic/eui'; import { EuiCardTo } from '../../../shared/react_router_helpers'; @@ -45,17 +45,23 @@ describe('DocumentCreationButtons', () => { expect(wrapper.find(EuiCardTo).prop('isDisabled')).toEqual(true); }); + it('renders with flyoutHeader', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiText)).toHaveLength(1); + }); + it('opens the DocumentCreationFlyout on click', () => { const wrapper = shallow(); wrapper.find(EuiCard).at(0).simulate('click'); - expect(actions.openDocumentCreation).toHaveBeenCalledWith('text'); + expect(actions.openDocumentCreation).toHaveBeenCalledWith('json'); wrapper.find(EuiCard).at(1).simulate('click'); - expect(actions.openDocumentCreation).toHaveBeenCalledWith('file'); + expect(actions.openDocumentCreation).toHaveBeenCalledWith('api'); wrapper.find(EuiCard).at(2).simulate('click'); - expect(actions.openDocumentCreation).toHaveBeenCalledWith('api'); + expect(actions.openDocumentCreation).toHaveBeenCalledWith('elasticsearchIndex'); }); it('renders the crawler button with a link to the crawler page', () => { @@ -64,12 +70,12 @@ describe('DocumentCreationButtons', () => { expect(wrapper.find(EuiCardTo).prop('to')).toEqual('/engines/some-engine/crawler'); }); - it('calls openDocumentCreation("file") if ?method=json', () => { + it('calls openDocumentCreation("json") if ?method=json', () => { const search = '?method=json'; (useLocation as jest.Mock).mockImplementationOnce(() => ({ search })); shallow(); - expect(actions.openDocumentCreation).toHaveBeenCalledWith('file'); + expect(actions.openDocumentCreation).toHaveBeenCalledWith('json'); }); it('calls openDocumentCreation("api") if ?method=api', () => { @@ -79,4 +85,12 @@ describe('DocumentCreationButtons', () => { shallow(); expect(actions.openDocumentCreation).toHaveBeenCalledWith('api'); }); + + it('calls openDocumentCreation("elasticsearchIndex") if ?method=elasticsearchIndex', () => { + const search = '?method=elasticsearchIndex'; + (useLocation as jest.Mock).mockImplementationOnce(() => ({ search })); + + shallow(); + expect(actions.openDocumentCreation).toHaveBeenCalledWith('elasticsearchIndex'); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx index a8179f297644..edc91804961e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx @@ -13,30 +13,37 @@ import { Location } from 'history'; import { useActions } from 'kea'; import { + EuiEmptyPrompt, EuiText, - EuiCode, + EuiTitle, + EuiImage, EuiLink, EuiSpacer, - EuiFlexGrid, + EuiFlexGroup, EuiFlexItem, EuiCard, EuiIcon, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import { parseQueryParams } from '../../../shared/query_params'; import { EuiCardTo } from '../../../shared/react_router_helpers'; import { INDEXING_DOCS_URL, ENGINE_CRAWLER_PATH } from '../../routes'; import { generateEnginePath } from '../engine'; +import illustration from './illustration.svg'; + import { DocumentCreationLogic } from './'; interface Props { + isFlyout?: boolean; disabled?: boolean; } -export const DocumentCreationButtons: React.FC = ({ disabled = false }) => { +export const DocumentCreationButtons: React.FC = ({ + isFlyout = false, + disabled = false, +}) => { const { openDocumentCreation } = useActions(DocumentCreationLogic); const { search } = useLocation() as Location; @@ -45,90 +52,156 @@ export const DocumentCreationButtons: React.FC = ({ disabled = false }) = useEffect(() => { switch (method) { case 'json': - openDocumentCreation('file'); + openDocumentCreation('json'); break; case 'api': openDocumentCreation('api'); break; + case 'elasticsearchIndex': + openDocumentCreation('elasticsearchIndex'); + break; } }, []); const crawlerLink = generateEnginePath(ENGINE_CRAWLER_PATH); - return ( - <> - -

- .json, - postCode: POST, - documentsApiLink: ( - - documents API - - ), - }} + const helperText = ( +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.helperText', { + defaultMessage: + 'There are four ways to send documents to your engine for indexing. You can paste or upload a JSON file, POST to the documents API endpoint, connect to an existing Elasticsearch index, or use the Elastic Web Crawler to automatically index documents from a URL.', + })} +

+ ); + + const emptyState = ( + + -

-
+ } + title={ +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.buttons.emptyStateTitle', + { defaultMessage: 'Add documents' } + )} +

+ } + layout="horizontal" + hasBorder + color="plain" + body={helperText} + footer={ + <> + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.buttons.emptyStateFooterText', + { defaultMessage: 'Want to learn more about indexing documents?' } + )} + + {' '} + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.buttons.emptyStateFooterLink', + { defaultMessage: 'Read documentation' } + )} + + + } + /> + + ); + + const flyoutHeader = ( + <> + {helperText} - - + + ); + + return ( + <> + {isFlyout && flyoutHeader} + + } + description={i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.buttons.crawlDescription', + { defaultMessage: 'Automatically index documents from a URL' } + )} + icon={} to={crawlerLink} isDisabled={disabled} /> - - + } + description={i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.buttons.jsonDescription', + { defaultMessage: 'Add documents by pasting or uploading raw JSON' } + )} + icon={} data-test-subj="IndexingPasteJSONButton" - onClick={() => openDocumentCreation('text')} + onClick={() => openDocumentCreation('json')} isDisabled={disabled} /> - - + } - onClick={() => openDocumentCreation('file')} + icon={} + onClick={() => openDocumentCreation('api')} isDisabled={disabled} /> - - + } - onClick={() => openDocumentCreation('api')} + hasBorder + layout="horizontal" + title={i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.buttons.elasticsearchTitle', + { defaultMessage: 'Use an Elasticsearch Index' } + )} + description={i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.buttons.elasticsearchDescription', + { defaultMessage: 'Search your existing indices with App Search' } + )} + icon={} + onClick={() => openDocumentCreation('elasticsearchIndex')} isDisabled={disabled} /> - + {!isFlyout && emptyState} + ); }; 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 3dc5d445930b..3bc7eac1a9e6 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 @@ -16,8 +16,8 @@ import { EuiFlyout } from '@elastic/eui'; import { ShowCreationModes, ApiCodeExample, - PasteJsonText, - UploadJsonFile, + JsonFlyout, + ElasticsearchIndex, } from './creation_mode_components'; import { Summary } from './creation_response_components'; import { DocumentCreationFlyout, FlyoutContent } from './document_creation_flyout'; @@ -26,11 +26,12 @@ import { DocumentCreationStep } from './types'; describe('DocumentCreationFlyout', () => { const values = { isDocumentCreationOpen: true, - creationMode: 'text', + creationMode: 'api', creationStep: DocumentCreationStep.AddDocuments, }; const actions = { closeDocumentCreation: jest.fn(), + setActiveJsonTab: jest.fn(), }; beforeEach(() => { @@ -70,18 +71,18 @@ describe('DocumentCreationFlyout', () => { expect(wrapper.find(ApiCodeExample)).toHaveLength(1); }); - it('renders PasteJsonText', () => { - setMockValues({ ...values, creationMode: 'text' }); + it('renders JsonFlyout', () => { + setMockValues({ ...values, creationMode: 'json' }); const wrapper = shallow(); - expect(wrapper.find(PasteJsonText)).toHaveLength(1); + expect(wrapper.find(JsonFlyout)).toHaveLength(1); }); - it('renders UploadJsonFile', () => { - setMockValues({ ...values, creationMode: 'file' }); + it('renders ElasticsearchIndex', () => { + setMockValues({ ...values, creationMode: 'elasticsearchIndex' }); const wrapper = shallow(); - expect(wrapper.find(UploadJsonFile)).toHaveLength(1); + expect(wrapper.find(ElasticsearchIndex)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.tsx index 159f3403d374..2e8111eb00f1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.tsx @@ -15,8 +15,8 @@ import { FLYOUT_ARIA_LABEL_ID } from './constants'; import { ShowCreationModes, ApiCodeExample, - PasteJsonText, - UploadJsonFile, + JsonFlyout, + ElasticsearchIndex, } from './creation_mode_components'; import { Summary } from './creation_response_components'; import { DocumentCreationStep } from './types'; @@ -46,10 +46,10 @@ export const FlyoutContent: React.FC = () => { switch (creationMode) { case 'api': return ; - case 'text': - return ; - case 'file': - return ; + case 'json': + return ; + case 'elasticsearchIndex': + return ; } case DocumentCreationStep.ShowSummary: return
; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts index edd5c5566a45..e0f49c788a9e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts @@ -31,7 +31,8 @@ describe('DocumentCreationLogic', () => { const DEFAULT_VALUES = { isDocumentCreationOpen: false, - creationMode: 'text', + creationMode: 'api', + activeJsonTab: 'uploadTab', creationStep: DocumentCreationStep.AddDocuments, textInput: dedent(DOCUMENTS_API_JSON_EXAMPLE), fileInput: null, @@ -124,6 +125,24 @@ describe('DocumentCreationLogic', () => { }); }); + describe('setActiveJsonTab', () => { + beforeAll(() => { + mount(); + DocumentCreationLogic.actions.setActiveJsonTab('pasteTab'); + }); + + const EXPECTED_VALUES = { + ...DEFAULT_VALUES, + activeJsonTab: 'pasteTab', + }; + + describe('isDocumentCreationOpen', () => { + it('should be set to "pasteTab"', () => { + expect(DocumentCreationLogic.values).toEqual(EXPECTED_VALUES); + }); + }); + }); + describe('closeDocumentCreation', () => { describe('isDocumentCreationOpen', () => { it('should be set to false', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.ts index 5ea2f0fe7cf7..b67b37b5dbb3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.ts @@ -20,10 +20,13 @@ import { import { DocumentCreationMode, DocumentCreationStep, DocumentCreationSummary } from './types'; import { readUploadedFileAsText } from './utils'; +export type ActiveJsonTab = 'uploadTab' | 'pasteTab'; + interface DocumentCreationValues { isDocumentCreationOpen: boolean; creationMode: DocumentCreationMode; creationStep: DocumentCreationStep; + activeJsonTab: ActiveJsonTab; textInput: string; fileInput: File | null; isUploading: boolean; @@ -37,6 +40,7 @@ interface DocumentCreationActions { openDocumentCreation(creationMode: DocumentCreationMode): { creationMode: DocumentCreationMode }; closeDocumentCreation(): void; setCreationStep(creationStep: DocumentCreationStep): { creationStep: DocumentCreationStep }; + setActiveJsonTab(activeJsonTab: ActiveJsonTab): { activeJsonTab: ActiveJsonTab }; setTextInput(textInput: string): { textInput: string }; setFileInput(fileInput: File | null): { fileInput: File | null }; setWarnings(warnings: string[]): { warnings: string[] }; @@ -56,6 +60,7 @@ export const DocumentCreationLogic = kea< openDocumentCreation: (creationMode) => ({ creationMode }), closeDocumentCreation: () => null, setCreationStep: (creationStep) => ({ creationStep }), + setActiveJsonTab: (activeJsonTab) => ({ activeJsonTab }), setTextInput: (textInput) => ({ textInput }), setFileInput: (fileInput) => ({ fileInput }), setWarnings: (warnings) => ({ warnings }), @@ -75,11 +80,17 @@ export const DocumentCreationLogic = kea< }, ], creationMode: [ - 'text', + 'api', { openDocumentCreation: (_, { creationMode }) => creationMode, }, ], + activeJsonTab: [ + 'uploadTab', + { + setActiveJsonTab: (_, { activeJsonTab }) => activeJsonTab, + }, + ], creationStep: [ DocumentCreationStep.AddDocuments, { @@ -93,6 +104,7 @@ export const DocumentCreationLogic = kea< { setTextInput: (_, { textInput }) => textInput, closeDocumentCreation: () => dedent(DOCUMENTS_API_JSON_EXAMPLE), + setActiveJsonTab: () => dedent(DOCUMENTS_API_JSON_EXAMPLE), }, ], fileInput: [ @@ -100,6 +112,7 @@ export const DocumentCreationLogic = kea< { setFileInput: (_, { fileInput }) => fileInput, closeDocumentCreation: () => null, + setActiveJsonTab: () => null, }, ], isUploading: [ @@ -109,6 +122,7 @@ export const DocumentCreationLogic = kea< onSubmitJson: () => true, setErrors: () => false, setSummary: () => false, + setActiveJsonTab: () => false, }, ], warnings: [ @@ -117,6 +131,7 @@ export const DocumentCreationLogic = kea< onSubmitJson: () => [], setWarnings: (_, { warnings }) => warnings, closeDocumentCreation: () => [], + setActiveJsonTab: () => [], }, ], errors: [ @@ -125,6 +140,7 @@ export const DocumentCreationLogic = kea< onSubmitJson: () => [], setErrors: (_, { errors }) => (Array.isArray(errors) ? errors : [errors]), closeDocumentCreation: () => [], + setActiveJsonTab: () => [], }, ], summary: [ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/illustration.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/illustration.svg new file mode 100644 index 000000000000..6af40daf69d6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/illustration.svg @@ -0,0 +1 @@ + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/index.ts index 9caf0749cfb2..4bf51e50aad3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/index.ts @@ -8,3 +8,4 @@ export { DocumentCreationButtons } from './document_creation_buttons'; export { DocumentCreationFlyout } from './document_creation_flyout'; export { DocumentCreationLogic } from './document_creation_logic'; +export type { ActiveJsonTab } from './document_creation_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/types.ts index 63968b7e9983..33360f467308 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -export type DocumentCreationMode = 'text' | 'file' | 'api'; +export type DocumentCreationMode = 'api' | 'json' | 'elasticsearchIndex'; export enum DocumentCreationStep { ShowCreationModes, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx index 2322bcde831e..b03aa6a64c7d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx @@ -11,7 +11,7 @@ import { useValues } from 'kea'; import { EuiFlexGroup, EuiSpacer, EuiEmptyPrompt } from '@elastic/eui'; // @ts-expect-error types are not available for this package yet -import { Results, Paging, ResultsPerPage } from '@elastic/react-search-ui'; +import { Results } from '@elastic/react-search-ui'; import { i18n } from '@kbn/i18n'; import { Loading } from '../../../../shared/loading'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal.scss new file mode 100644 index 000000000000..11a008a3cc51 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal.scss @@ -0,0 +1,9 @@ +.auditLogsModal { + width: 75vw; +} + +@media (max-width: 1200px) { + .auditLogsModal { + width: 100vw; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal.test.tsx new file mode 100644 index 000000000000..f6687e431e98 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogicMounter, setMockValues, setMockActions } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiText, EuiModal } from '@elastic/eui'; + +import { EntSearchLogStream } from '../../../../../shared/log_stream'; + +import { AuditLogsModal } from './audit_logs_modal'; + +import { AuditLogsModalLogic } from './audit_logs_modal_logic'; + +describe('AuditLogsModal', () => { + const { mount } = new LogicMounter(AuditLogsModalLogic); + beforeEach(() => { + jest.clearAllMocks(); + mount({ isModalVisible: true }); + }); + + it('renders nothing by default', () => { + const wrapper = shallow(); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders the modal when modal visible', () => { + const testEngineName = 'test-engine-123'; + const mockClose = jest.fn(); + setMockValues({ + isModalVisible: true, + engineName: testEngineName, + }); + setMockActions({ + hideModal: mockClose, + }); + + const wrapper = shallow(); + expect(wrapper.find(EntSearchLogStream).prop('query')).toBe( + `event.kind: event and event.action: audit and enterprisesearch.data_repository.name: ${testEngineName}` + ); + expect(wrapper.find(EuiText).children().text()).toBe('Showing events from last 24 hours'); + expect(wrapper.find(EuiModal).prop('onClose')).toBe(mockClose); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal.tsx new file mode 100644 index 000000000000..3807234fd5c1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal.tsx @@ -0,0 +1,121 @@ +/* + * Copyright 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 { useValues, useActions } from 'kea'; + +import { + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { ENTERPRISE_SEARCH_AUDIT_LOGS_SOURCE_ID } from '../../../../../../../common/constants'; +import { EntSearchLogStream } from '../../../../../shared/log_stream'; + +import { AuditLogsModalLogic } from './audit_logs_modal_logic'; + +import './audit_logs_modal.scss'; + +export const AuditLogsModal: React.FC = () => { + const auditLogsModalLogic = AuditLogsModalLogic(); + const { isModalVisible, engineName } = useValues(auditLogsModalLogic); + const { hideModal } = useActions(auditLogsModalLogic); + + const filters = [ + 'event.kind: event', + 'event.action: audit', + `enterprisesearch.data_repository.name: ${engineName}`, + ].join(' and '); + + return !isModalVisible ? null : ( + + + +

{engineName}

+
+
+ + + {i18n.translate('xpack.enterpriseSearch.appSearch.engines.auditLogsModal.eventTip', { + defaultMessage: 'Showing events from last 24 hours', + })} + + + + + + + + {i18n.translate('xpack.enterpriseSearch.appSearch.engines.auditLogsModal.closeButton', { + defaultMessage: 'Close', + })} + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal_logic.test.ts new file mode 100644 index 000000000000..f869dd145087 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal_logic.test.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 { LogicMounter } from '../../../../../__mocks__/kea_logic'; + +import { AuditLogsModalLogic } from './audit_logs_modal_logic'; + +describe('AuditLogsModalLogic', () => { + const { mount } = new LogicMounter(AuditLogsModalLogic); + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has excepted default values', () => { + expect(AuditLogsModalLogic.values).toEqual({ + isModalVisible: false, + engineName: '', + }); + }); + + describe('actions', () => { + describe('hideModal', () => { + it('hides the modal', () => { + mount({ + isModalVisible: true, + engineName: 'test_engine', + }); + + AuditLogsModalLogic.actions.hideModal(); + expect(AuditLogsModalLogic.values).toEqual({ + isModalVisible: false, + engineName: '', + }); + }); + }); + + describe('showModal', () => { + it('show the modal with correct engine name', () => { + AuditLogsModalLogic.actions.showModal('test-engine-123'); + expect(AuditLogsModalLogic.values).toEqual({ + isModalVisible: true, + engineName: 'test-engine-123', + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal_logic.ts new file mode 100644 index 000000000000..afa70b4f3dee --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/audit_logs_modal/audit_logs_modal_logic.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 { kea } from 'kea'; + +export const AuditLogsModalLogic = kea({ + path: ['enterprise_search', 'app_search', 'engines_overview', 'audit_logs_modal'], + actions: () => ({ + hideModal: true, + showModal: (engineName: string) => ({ engineName }), + }), + reducers: () => ({ + isModalVisible: [ + false, + { + showModal: () => true, + hideModal: () => false, + }, + ], + engineName: [ + '', + { + showModal: (_, { engineName }) => engineName, + hideModal: () => '', + }, + ], + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.tsx index a3350d1ef993..229e0def4700 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.tsx @@ -7,11 +7,14 @@ import React from 'react'; +import { EuiLink } from '@elastic/eui'; + import { KibanaLogic } from '../../../../../shared/kibana'; import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../../../shared/telemetry'; import { ENGINE_PATH } from '../../../../routes'; import { generateEncodedPath } from '../../../../utils/encode_path_params'; +import { FormattedDateTime } from '../../../../utils/formatted_date_time'; const sendEngineTableLinkClickTelemetry = () => { TelemetryLogic.actions.sendAppSearchTelemetry({ @@ -34,3 +37,9 @@ export const renderEngineLink = (engineName: string) => ( {engineName} ); + +export const renderLastChangeLink = (dateString: string, onClick = () => {}) => ( + + {!dateString ? '-' : } + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.tsx index 563e272a4a73..5e6ece1003e7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { useValues } from 'kea'; +import { useActions, useValues } from 'kea'; import { EuiBasicTable, EuiBasicTableColumn, EuiTableFieldDataColumnType } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -16,10 +16,13 @@ import { AppLogic } from '../../../../app_logic'; import { UNIVERSAL_LANGUAGE } from '../../../../constants'; import { EngineDetails } from '../../../engine/types'; -import { renderEngineLink } from './engine_link_helpers'; +import { AuditLogsModalLogic } from '../audit_logs_modal/audit_logs_modal_logic'; + +import { renderEngineLink, renderLastChangeLink } from './engine_link_helpers'; import { ACTIONS_COLUMN, CREATED_AT_COLUMN, + LAST_UPDATED_COLUMN, DOCUMENT_COUNT_COLUMN, FIELD_COUNT_COLUMN, NAME_COLUMN, @@ -46,12 +49,22 @@ export const EnginesTable: React.FC = ({ myRole: { canManageEngines }, } = useValues(AppLogic); + const { showModal: showAuditLogModal } = useActions(AuditLogsModalLogic); + const columns: Array> = [ { ...NAME_COLUMN, render: (name: string) => renderEngineLink(name), }, CREATED_AT_COLUMN, + { + ...LAST_UPDATED_COLUMN, + render: (dateString: string, engineDetails) => { + return renderLastChangeLink(dateString, () => { + showAuditLogModal(engineDetails.name); + }); + }, + }, LANGUAGE_COLUMN, DOCUMENT_COUNT_COLUMN, FIELD_COUNT_COLUMN, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.tsx index f99dc7e15eae..24eb8cc8a6b8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.tsx @@ -14,6 +14,9 @@ import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import { AppLogic } from '../../../../app_logic'; import { EngineDetails } from '../../../engine/types'; +import { AuditLogsModalLogic } from '../audit_logs_modal/audit_logs_modal_logic'; + +import { renderLastChangeLink } from './engine_link_helpers'; import { MetaEnginesTableExpandedRow } from './meta_engines_table_expanded_row'; import { MetaEnginesTableLogic } from './meta_engines_table_logic'; import { MetaEnginesTableNameColumnContent } from './meta_engines_table_name_column_content'; @@ -21,6 +24,7 @@ import { ACTIONS_COLUMN, BLANK_COLUMN, CREATED_AT_COLUMN, + LAST_UPDATED_COLUMN, DOCUMENT_COUNT_COLUMN, FIELD_COUNT_COLUMN, NAME_COLUMN, @@ -49,6 +53,8 @@ export const MetaEnginesTable: React.FC = ({ myRole: { canManageMetaEngines }, } = useValues(AppLogic); + const { showModal: showAuditLogModal } = useActions(AuditLogsModalLogic); + const conflictingEnginesSets: ConflictingEnginesSets = useMemo( () => items.reduce((accumulator, metaEngine) => { @@ -89,6 +95,14 @@ export const MetaEnginesTable: React.FC = ({ ), }, CREATED_AT_COLUMN, + { + ...LAST_UPDATED_COLUMN, + render: (dateString: string, engineDetails) => { + return renderLastChangeLink(dateString, () => { + showAuditLogModal(engineDetails.name); + }); + }, + }, BLANK_COLUMN, DOCUMENT_COUNT_COLUMN, FIELD_COUNT_COLUMN, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/shared_columns.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/shared_columns.tsx index 325760b641ef..b0ca36a77783 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/shared_columns.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/shared_columns.tsx @@ -50,6 +50,17 @@ export const CREATED_AT_COLUMN: EuiTableFieldDataColumnType = { render: (dateString: string) => , }; +export const LAST_UPDATED_COLUMN: EuiTableFieldDataColumnType = { + field: 'updated_at', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.lastUpdated', + { + defaultMessage: 'Last updated', + } + ), + dataType: 'string', +}; + export const DOCUMENT_COUNT_COLUMN: EuiTableFieldDataColumnType = { field: 'document_count', name: i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx index f8df9f5abfaa..27cdff5d6981 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx @@ -21,6 +21,7 @@ import { DataPanel } from '../data_panel'; import { AppSearchPageTemplate } from '../layout'; import { EmptyState, EmptyMetaEnginesState } from './components'; +import { AuditLogsModal } from './components/audit_logs_modal/audit_logs_modal'; import { EnginesTable } from './components/tables/engines_table'; import { MetaEnginesTable } from './components/tables/meta_engines_table'; import { @@ -144,6 +145,7 @@ export const EnginesOverview: React.FC = () => { data-test-subj="metaEnginesLicenseCTA" /> )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.test.tsx index 8f47d5f1c464..b26cc00379f3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.test.tsx @@ -14,7 +14,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { SetAppSearchChrome } from '../../../shared/kibana_chrome'; -import { EnterpriseSearchPageTemplate } from '../../../shared/layout'; +import { EnterpriseSearchPageTemplateWrapper } from '../../../shared/layout'; import { SendAppSearchTelemetry } from '../../../shared/telemetry'; import { AppSearchPageTemplate } from './page_template'; @@ -27,7 +27,7 @@ describe('AppSearchPageTemplate', () => { ); - expect(wrapper.type()).toEqual(EnterpriseSearchPageTemplate); + expect(wrapper.type()).toEqual(EnterpriseSearchPageTemplateWrapper); expect(wrapper.prop('solutionNav')).toEqual({ name: 'App Search', items: [] }); expect(wrapper.find('.hello').text()).toEqual('world'); }); @@ -35,7 +35,9 @@ describe('AppSearchPageTemplate', () => { describe('page chrome', () => { it('takes a breadcrumb array & renders a product-specific page chrome', () => { const wrapper = shallow(); - const setPageChrome = wrapper.find(EnterpriseSearchPageTemplate).prop('setPageChrome') as any; + const setPageChrome = wrapper + .find(EnterpriseSearchPageTemplateWrapper) + .prop('setPageChrome') as any; expect(setPageChrome.type).toEqual(SetAppSearchChrome); expect(setPageChrome.props.trail).toEqual(['Some page']); @@ -51,7 +53,7 @@ describe('AppSearchPageTemplate', () => { }); }); - it('passes down any ...pageTemplateProps that EnterpriseSearchPageTemplate accepts', () => { + it('passes down any ...pageTemplateProps that EnterpriseSearchPageTemplateWrapper accepts', () => { const wrapper = shallow( { /> ); - expect(wrapper.find(EnterpriseSearchPageTemplate).prop('pageHeader')!.pageTitle).toEqual( + expect(wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('pageHeader')!.pageTitle).toEqual( 'hello world' ); - expect(wrapper.find(EnterpriseSearchPageTemplate).prop('isLoading')).toEqual(false); - expect(wrapper.find(EnterpriseSearchPageTemplate).prop('emptyState')).toEqual(
); + expect(wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('isLoading')).toEqual(false); + expect(wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('emptyState')).toEqual(
); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.tsx index 31f2eb3215e0..d336bcc6a4c5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; import { SetAppSearchChrome } from '../../../shared/kibana_chrome'; -import { EnterpriseSearchPageTemplate, PageTemplateProps } from '../../../shared/layout'; +import { EnterpriseSearchPageTemplateWrapper, PageTemplateProps } from '../../../shared/layout'; import { SendAppSearchTelemetry } from '../../../shared/telemetry'; import { useAppSearchNav } from './nav'; @@ -21,7 +21,7 @@ export const AppSearchPageTemplate: React.FC = ({ ...pageTemplateProps }) => { return ( - = ({ > {pageViewTelemetry && } {children} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx index df5c5499424c..5109562dd340 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx @@ -364,7 +364,7 @@ export const Library: React.FC = () => { items={[{ id: 1 }, { id: 2 }, { id: 3 }]} columns={[ { name: 'ID', render: (item) =>
{item.id}
}, - { name: 'Whatever', render: (item) =>
Whatever
}, + { name: 'Whatever', render: () =>
Whatever
}, ]} /> @@ -379,7 +379,7 @@ export const Library: React.FC = () => { items={[{ id: 1 }, { id: 2 }, { id: 3 }]} columns={[ { name: 'ID', render: (item) =>
{item.id}
}, - { name: 'Whatever', render: (item) =>
Whatever
}, + { name: 'Whatever', render: () =>
Whatever
}, ]} /> @@ -394,7 +394,7 @@ export const Library: React.FC = () => { items={[{ id: 1 }, { id: 2 }, { id: 3 }]} columns={[ { name: 'ID', render: (item) =>
{item.id}
}, - { name: 'Whatever', render: (item) =>
Whatever
}, + { name: 'Whatever', render: () =>
Whatever
}, ]} /> @@ -409,7 +409,7 @@ export const Library: React.FC = () => { unreorderableItems={[{ id: 4 }, { id: 5 }]} columns={[ { name: 'ID', render: (item) =>
{item.id}
}, - { name: 'Whatever', render: (item) =>
Whatever
}, + { name: 'Whatever', render: () =>
Whatever
}, ]} /> @@ -428,7 +428,7 @@ export const Library: React.FC = () => { items={[{ id: 1 }, { id: 2 }, { id: 3 }]} columns={[ { name: 'ID', render: (item) =>
{item.id}
}, - { name: 'Whatever', render: (item) =>
Whatever
}, + { name: 'Whatever', render: () =>
Whatever
}, ]} /> @@ -442,7 +442,7 @@ export const Library: React.FC = () => { items={[]} columns={[ { name: 'ID', render: (item: { id: number }) =>
{item.id}
}, - { name: 'Whatever', render: (item) =>
Whatever
}, + { name: 'Whatever', render: () =>
Whatever
}, ]} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.test.ts index 60d0dcc0c591..b9db9e11d2a8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.test.ts @@ -23,6 +23,7 @@ describe('getRoleAbilities', () => { // Has access canViewAccountCredentials: true, canManageEngines: true, + canManageMetaEngines: true, // Does not have access canViewMetaEngines: false, canViewEngineAnalytics: false, @@ -35,7 +36,6 @@ describe('getRoleAbilities', () => { canViewMetaEngineSourceEngines: false, canViewSettings: false, canViewRoleMappings: false, - canManageMetaEngines: false, canManageLogSettings: false, canManageSettings: false, canManageEngineCrawler: false, @@ -76,19 +76,19 @@ describe('getRoleAbilities', () => { const canManageEngines = { ability: { manage: ['account_engines'] } }; it('returns true when the user can manage any engines and the account has a platinum license', () => { - const myRole = getRoleAbilities({ ...mockRole, ...canManageEngines }, true); + const myRole = getRoleAbilities({ ...mockRole, ...canManageEngines }); expect(myRole.canManageMetaEngines).toEqual(true); }); - it('returns false when the user can manage any engines but the account does not have a platinum license', () => { - const myRole = getRoleAbilities({ ...mockRole, ...canManageEngines }, false); + it('returns true when the user can manage any engines but the account does not have a platinum license', () => { + const myRole = getRoleAbilities({ ...mockRole, ...canManageEngines }); - expect(myRole.canManageMetaEngines).toEqual(false); + expect(myRole.canManageMetaEngines).toEqual(true); }); it('returns false when has a platinum license but the user cannot manage any engines', () => { - const myRole = getRoleAbilities({ ...mockRole, ability: { manage: [] } }, true); + const myRole = getRoleAbilities({ ...mockRole, ability: { manage: [] } }); expect(myRole.canManageMetaEngines).toEqual(false); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.ts index ef3e22d851f3..2196b40f2aa2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.ts @@ -13,7 +13,7 @@ import { RoleTypes, AbilityTypes, Role } from './types'; * Transforms the `role` data we receive from the Enterprise Search * server into a more convenient format for front-end use */ -export const getRoleAbilities = (role: Account['role'], hasPlatinumLicense = false): Role => { +export const getRoleAbilities = (role: Account['role']): Role => { // Role ability function helpers const myRole = { can: (action: AbilityTypes, subject: string): boolean => { @@ -49,7 +49,7 @@ export const getRoleAbilities = (role: Account['role'], hasPlatinumLicense = fal canViewSettings: myRole.can('view', 'account_settings'), canViewRoleMappings: myRole.can('view', 'role_mappings'), canManageEngines: myRole.can('manage', 'account_engines'), - canManageMetaEngines: hasPlatinumLicense && myRole.can('manage', 'account_engines'), + canManageMetaEngines: myRole.can('manage', 'account_engines'), canManageLogSettings: myRole.can('manage', 'account_log_settings'), canManageSettings: myRole.can('manage', 'account_settings'), canManageEngineCrawler: myRole.can('manage', 'engine_crawler'), diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/app_search.png b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/assets/app_search.png similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/app_search.png rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/assets/app_search.png diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/workplace_search.png b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/assets/workplace_search.png similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/workplace_search.png rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/assets/workplace_search.png diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/error_connecting/error_connecting.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/error_connecting/error_connecting.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/error_connecting/error_connecting.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/error_connecting/error_connecting.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/index.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/error_connecting/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/index.ts rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/error_connecting/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/constants.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/license_callout/constants.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/constants.ts rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/license_callout/constants.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/index.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/license_callout/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/index.ts rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/license_callout/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/license_callout/license_callout.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/license_callout/license_callout.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/license_callout/license_callout.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/license_callout/license_callout.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/index.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_card/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/index.ts rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_card/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.scss b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_card/product_card.scss similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.scss rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_card/product_card.scss diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_card/product_card.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_card/product_card.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_card/product_card.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_card/product_card.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_selector/index.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_selector/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_selector/index.ts rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_selector/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_selector/lock_light.svg b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_selector/lock_light.svg similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_selector/lock_light.svg rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_selector/lock_light.svg diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_selector/product_selector.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_selector/product_selector.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_selector/product_selector.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_selector/product_selector.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_selector/product_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_selector/product_selector.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_selector/product_selector.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_selector/product_selector.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/assets/getting_started.png b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/assets/getting_started.png similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/assets/getting_started.png rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/assets/getting_started.png diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/index.ts rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/setup_guide.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/setup_guide.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/setup_guide.tsx similarity index 93% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/setup_guide.tsx index 1a25d1a7a8d1..c8889320bb34 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/setup_guide.tsx @@ -11,7 +11,7 @@ import { EuiSpacer, EuiTitle, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ENTERPRISE_SEARCH_PLUGIN } from '../../../../../common/constants'; +import { ENTERPRISE_SEARCH_OVERVIEW_PLUGIN } from '../../../../../common/constants'; import { SetEnterpriseSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SetupGuideLayout, SETUP_GUIDE_TITLE } from '../../../shared/setup_guide'; import { SendEnterpriseSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; @@ -20,7 +20,7 @@ import GettingStarted from './assets/getting_started.png'; export const SetupGuide: React.FC = () => ( diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide_cta.scss b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/setup_guide_cta.scss similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide_cta.scss rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/setup_guide_cta.scss diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide_cta.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/setup_guide_cta.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide_cta.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/setup_guide_cta.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide_cta.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/setup_guide_cta.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide_cta.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/setup_guide/setup_guide_cta.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/trial_callout/index.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/trial_callout/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/trial_callout/index.ts rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/trial_callout/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/trial_callout/trial_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/trial_callout/trial_callout.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/trial_callout/trial_callout.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/trial_callout/trial_callout.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/trial_callout/trial_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/trial_callout/trial_callout.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/trial_callout/trial_callout.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/trial_callout/trial_callout.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/constants.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/constants.ts rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/constants.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress.json b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/cypress.json similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress.json rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/cypress.json diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/integration/overview.spec.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/cypress/integration/overview.spec.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/integration/overview.spec.ts rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/cypress/integration/overview.spec.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/tsconfig.json b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/cypress/tsconfig.json similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/tsconfig.json rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/cypress/tsconfig.json diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/index.test.tsx similarity index 87% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/index.test.tsx index 4aef227582d3..e5d883ee819f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/index.test.tsx @@ -18,15 +18,15 @@ import { ErrorConnecting } from './components/error_connecting'; import { ProductSelector } from './components/product_selector'; import { SetupGuide } from './components/setup_guide'; -import { EnterpriseSearch } from './'; +import { EnterpriseSearchOverview } from './'; -describe('EnterpriseSearch', () => { +describe('EnterpriseSearchOverview', () => { it('renders the Setup Guide and Product Selector', () => { setMockValues({ errorConnectingMessage: '', config: { host: 'localhost' }, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(SetupGuide)).toHaveLength(1); expect(wrapper.find(ProductSelector)).toHaveLength(1); @@ -37,7 +37,7 @@ describe('EnterpriseSearch', () => { errorConnectingMessage: '502 Bad Gateway', config: { host: 'localhost' }, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(VersionMismatchPage)).toHaveLength(0); const errorConnecting = wrapper.find(ErrorConnecting); @@ -61,7 +61,7 @@ describe('EnterpriseSearch', () => { config: { host: 'localhost' }, }); const wrapper = shallow( - + ); expect(wrapper.find(VersionMismatchPage)).toHaveLength(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/index.tsx similarity index 96% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/index.tsx index 5f1c7b5072be..ca4b91d0e03b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/index.tsx @@ -21,7 +21,7 @@ import { ProductSelector } from './components/product_selector'; import { SetupGuide } from './components/setup_guide'; import { ROOT_PATH, SETUP_GUIDE_PATH } from './routes'; -export const EnterpriseSearch: React.FC = ({ +export const EnterpriseSearchOverview: React.FC = ({ access = {}, workplaceSearch, enterpriseSearchVersion, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/routes.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search/routes.ts rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/routes.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx index 356a3c26b910..cb47dfe12478 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx @@ -15,7 +15,7 @@ import { licensingMock } from '../../../licensing/public/mocks'; import { securityMock } from '../../../security/public/mocks'; import { AppSearch } from './app_search'; -import { EnterpriseSearch } from './enterprise_search'; +import { EnterpriseSearchOverview } from './enterprise_search_overview'; import { KibanaLogic } from './shared/kibana'; import { WorkplaceSearch } from './workplace_search'; @@ -62,8 +62,8 @@ describe('renderApp', () => { describe('Enterprise Search apps', () => { afterEach(() => unmount()); - it('renders EnterpriseSearch', () => { - mount(EnterpriseSearch); + it('renders EnterpriseSearchOverview', () => { + mount(EnterpriseSearchOverview); expect(mockContainer.querySelector('.kbnPageTemplate')).not.toBeNull(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/hidden_text/hidden_text.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/hidden_text/hidden_text.test.tsx index af63b9a801ed..94244349967b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/hidden_text/hidden_text.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/hidden_text/hidden_text.test.tsx @@ -14,9 +14,7 @@ import { HiddenText } from '.'; describe('HiddenText', () => { it('provides the passed "text" in a "hiddenText" field, with all characters obfuscated', () => { const wrapper = shallow( - - {({ hiddenText, isHidden, toggle }) =>
{hiddenText}
} -
+ {({ hiddenText }) =>
{hiddenText}
}
); expect(wrapper.text()).toEqual('•••••••••••'); }); @@ -26,7 +24,7 @@ describe('HiddenText', () => { const wrapper = shallow( - {({ hiddenText, isHidden, toggle }) => { + {({ hiddenText, toggle }) => { toggleFn = toggle; return
{hiddenText}
; }} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts index 5855dc6990f6..8864600475c1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts @@ -10,7 +10,7 @@ import { useValues } from 'kea'; import { EuiBreadcrumb } from '@elastic/eui'; import { - ENTERPRISE_SEARCH_PLUGIN, + ENTERPRISE_SEARCH_OVERVIEW_PLUGIN, APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN, } from '../../../../common/constants'; @@ -97,8 +97,8 @@ export const useEuiBreadcrumbs = (breadcrumbs: Breadcrumbs): EuiBreadcrumb[] => export const useEnterpriseSearchBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => useEuiBreadcrumbs([ { - text: ENTERPRISE_SEARCH_PLUGIN.NAME, - path: ENTERPRISE_SEARCH_PLUGIN.URL, + text: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.NAME, + path: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.URL, shouldNotCreateHref: true, }, ...breadcrumbs, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts index 650aa00d1801..8b91b7e57a78 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts @@ -6,7 +6,7 @@ */ import { - ENTERPRISE_SEARCH_PLUGIN, + ENTERPRISE_SEARCH_OVERVIEW_PLUGIN, APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN, } from '../../../../common/constants'; @@ -30,7 +30,7 @@ export const generateTitle = (pages: Title) => pages.join(' - '); */ export const enterpriseSearchTitle = (page: Title = []) => - generateTitle([...page, ENTERPRISE_SEARCH_PLUGIN.NAME]); + generateTitle([...page, ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.NAME]); export const appSearchTitle = (page: Title = []) => generateTitle([...page, APP_SEARCH_PLUGIN.NAME]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/index.ts index 79919e925c62..790d72943a1b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/index.ts @@ -6,5 +6,5 @@ */ export type { PageTemplateProps } from './page_template'; -export { EnterpriseSearchPageTemplate } from './page_template'; +export { EnterpriseSearchPageTemplateWrapper } from './page_template'; export { generateNavLink } from './nav_link_helpers'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.test.tsx index 8d480b69b3fe..22c976dfa763 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.test.tsx @@ -17,25 +17,25 @@ import { KibanaPageTemplate } from '../../../../../../../src/plugins/kibana_reac import { FlashMessages } from '../flash_messages'; import { Loading } from '../loading'; -import { EnterpriseSearchPageTemplate } from './page_template'; +import { EnterpriseSearchPageTemplateWrapper } from './page_template'; -describe('EnterpriseSearchPageTemplate', () => { +describe('EnterpriseSearchPageTemplateWrapper', () => { beforeEach(() => { jest.clearAllMocks(); setMockValues({ readOnlyMode: false }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.type()).toEqual(KibanaPageTemplate); }); it('renders children', () => { const wrapper = shallow( - +
world
-
+
); expect(wrapper.find('.hello').text()).toEqual('world'); @@ -44,9 +44,9 @@ describe('EnterpriseSearchPageTemplate', () => { describe('loading state', () => { it('renders a loading icon in place of children', () => { const wrapper = shallow( - +
- + ); expect(wrapper.find(Loading).exists()).toBe(true); @@ -55,9 +55,9 @@ describe('EnterpriseSearchPageTemplate', () => { it('renders children & does not render a loading icon when the page is done loading', () => { const wrapper = shallow( - +
- + ); expect(wrapper.find(Loading).exists()).toBe(false); @@ -68,12 +68,12 @@ describe('EnterpriseSearchPageTemplate', () => { describe('empty state', () => { it('renders a custom empty state in place of children', () => { const wrapper = shallow( - Nothing here yet!
} >
- + ); expect(wrapper.find('.emptyState').exists()).toBe(true); @@ -85,12 +85,12 @@ describe('EnterpriseSearchPageTemplate', () => { it('does not render the custom empty state if the page is not empty', () => { const wrapper = shallow( - Nothing here yet!
} >
- + ); expect(wrapper.find('.emptyState').exists()).toBe(false); @@ -99,7 +99,7 @@ describe('EnterpriseSearchPageTemplate', () => { it('does not render an empty state if the page is still loading', () => { const wrapper = shallow( - } @@ -114,14 +114,14 @@ describe('EnterpriseSearchPageTemplate', () => { describe('read-only mode', () => { it('renders a callout if in read-only mode', () => { setMockValues({ readOnlyMode: true }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiCallOut).exists()).toBe(true); }); it('does not render a callout if not in read-only mode', () => { setMockValues({ readOnlyMode: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiCallOut).exists()).toBe(false); }); @@ -129,7 +129,7 @@ describe('EnterpriseSearchPageTemplate', () => { describe('flash messages', () => { it('renders FlashMessages by default', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(FlashMessages).exists()).toBe(true); }); @@ -137,7 +137,7 @@ describe('EnterpriseSearchPageTemplate', () => { it('does not render FlashMessages if hidden', () => { // Example use case: manually showing flash messages in an open flyout or modal // and not wanting to duplicate flash messages on the overlayed page - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(FlashMessages).exists()).toBe(false); }); @@ -147,14 +147,16 @@ describe('EnterpriseSearchPageTemplate', () => { const SetPageChrome = () =>
; it('renders a product-specific ', () => { - const wrapper = shallow(} />); + const wrapper = shallow( + } /> + ); expect(wrapper.find(SetPageChrome).exists()).toBe(true); }); it('invokes page chrome immediately (without waiting for isLoading to be finished)', () => { const wrapper = shallow( - } isLoading /> + } isLoading /> ); expect(wrapper.find(SetPageChrome).exists()).toBe(true); @@ -166,14 +168,14 @@ describe('EnterpriseSearchPageTemplate', () => { describe('EuiPageTemplate props', () => { it('overrides the restrictWidth prop', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(KibanaPageTemplate).prop('restrictWidth')).toEqual(true); }); it('passes down any ...pageTemplateProps that EuiPageTemplate accepts', () => { const wrapper = shallow( - { it('sets enterpriseSearchPageTemplate classNames while still accepting custom classNames', () => { const wrapper = shallow( - + ); expect(wrapper.find(KibanaPageTemplate).prop('className')).toEqual( @@ -200,7 +205,9 @@ describe('EnterpriseSearchPageTemplate', () => { it('automatically sets the Enterprise Search logo onto passed solution navs', () => { const wrapper = shallow( - + ); expect(wrapper.find(KibanaPageTemplate).prop('solutionNav')).toEqual({ diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx index 7528fa14b7ae..934d571418d0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx @@ -26,7 +26,7 @@ import { Loading } from '../loading'; import './page_template.scss'; /* - * EnterpriseSearchPageTemplate is a light wrapper for KibanaPageTemplate (which + * EnterpriseSearchPageTemplateWrapper is a light wrapper for KibanaPageTemplate (which * is a light wrapper for EuiPageTemplate). It should contain only concerns shared * between both AS & WS, which should have their own AppSearchPageTemplate & * WorkplaceSearchPageTemplate sitting on top of this template (:nesting_dolls:), @@ -46,7 +46,7 @@ export type PageTemplateProps = KibanaPageTemplateProps & { pageViewTelemetry?: string; }; -export const EnterpriseSearchPageTemplate: React.FC = ({ +export const EnterpriseSearchPageTemplateWrapper: React.FC = ({ children, className, hideFlashMessages, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/log_stream/log_stream.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/log_stream/log_stream.test.tsx index d2dd41e82b2e..683b37894255 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/log_stream/log_stream.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/log_stream/log_stream.test.tsx @@ -11,6 +11,8 @@ import { shallow } from 'enzyme'; import { EntSearchLogStream } from './'; +const fakeSourceId = 'fake-source-id'; + describe('EntSearchLogStream', () => { const mockDateNow = jest.spyOn(global.Date, 'now').mockReturnValue(160000000); @@ -22,8 +24,8 @@ describe('EntSearchLogStream', () => { expect(wrapper.type()).toEqual(React.Suspense); }); - it('renders with the enterprise search log source ID', () => { - expect(wrapper.prop('sourceId')).toEqual('ent-search-logs'); + it('renders with the empty sourceId', () => { + expect(wrapper.prop('sourceId')).toBeUndefined(); }); it('renders with a default last-24-hours timestamp if no timestamp is passed', () => { @@ -46,7 +48,9 @@ describe('EntSearchLogStream', () => { }); it('allows passing a custom hoursAgo that modifies the default start timestamp', () => { - const wrapper = shallow(shallow().prop('children')); + const wrapper = shallow( + shallow().prop('children') + ); expect(wrapper.prop('startTimestamp')).toEqual(156400000); expect(wrapper.prop('endTimestamp')).toEqual(160000000); @@ -56,6 +60,7 @@ describe('EntSearchLogStream', () => { const wrapper = shallow( shallow( { + sourceId?: string; startTimestamp?: LogStreamProps['startTimestamp']; endTimestamp?: LogStreamProps['endTimestamp']; hoursAgo?: number; } export const EntSearchLogStream: React.FC = ({ + sourceId, startTimestamp, endTimestamp, hoursAgo = 24, @@ -40,7 +40,7 @@ export const EntSearchLogStream: React.FC = ({ return ( ({ description, isLoading, lastItemWarning, - defaultItem, noItemsMessage = () => null, uneditableItems, ...rest diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/reorderable_table/cell.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/tables/reorderable_table/cell.tsx index 64f4ce1c718c..31a5bfb146e4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/tables/reorderable_table/cell.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/reorderable_table/cell.tsx @@ -11,14 +11,12 @@ import { EuiFlexItem } from '@elastic/eui'; import { DraggableUXStyles } from './types'; -type CellProps = DraggableUXStyles; - -export const Cell = ({ +export const Cell = ({ children, alignItems, flexBasis, flexGrow, -}: CellProps & { children?: React.ReactNode }) => { +}: DraggableUXStyles & { children?: React.ReactNode }) => { return ( { ); - expect(wrapper.type()).toEqual(EnterpriseSearchPageTemplate); + expect(wrapper.type()).toEqual(EnterpriseSearchPageTemplateWrapper); expect(wrapper.prop('solutionNav')).toEqual({ name: 'Workplace Search', items: [] }); expect(wrapper.find('.hello').text()).toEqual('world'); }); @@ -35,7 +35,9 @@ describe('WorkplaceSearchPageTemplate', () => { describe('page chrome', () => { it('takes a breadcrumb array & renders a product-specific page chrome', () => { const wrapper = shallow(); - const setPageChrome = wrapper.find(EnterpriseSearchPageTemplate).prop('setPageChrome') as any; + const setPageChrome = wrapper + .find(EnterpriseSearchPageTemplateWrapper) + .prop('setPageChrome') as any; expect(setPageChrome.type).toEqual(SetWorkplaceSearchChrome); expect(setPageChrome.props.trail).toEqual(['Some page']); @@ -54,13 +56,15 @@ describe('WorkplaceSearchPageTemplate', () => { describe('props', () => { it('allows overriding the restrictWidth default', () => { const wrapper = shallow(); - expect(wrapper.find(EnterpriseSearchPageTemplate).prop('restrictWidth')).toEqual(true); + expect(wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('restrictWidth')).toEqual(true); wrapper.setProps({ restrictWidth: false }); - expect(wrapper.find(EnterpriseSearchPageTemplate).prop('restrictWidth')).toEqual(false); + expect(wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('restrictWidth')).toEqual( + false + ); }); - it('passes down any ...pageTemplateProps that EnterpriseSearchPageTemplate accepts', () => { + it('passes down any ...pageTemplateProps that EnterpriseSearchPageTemplateWrapper accepts', () => { const wrapper = shallow( { /> ); - expect(wrapper.find(EnterpriseSearchPageTemplate).prop('pageHeader')!.pageTitle).toEqual( - 'hello world' - ); - expect(wrapper.find(EnterpriseSearchPageTemplate).prop('isLoading')).toEqual(false); - expect(wrapper.find(EnterpriseSearchPageTemplate).prop('emptyState')).toEqual(
); + expect( + wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('pageHeader')!.pageTitle + ).toEqual('hello world'); + expect(wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('isLoading')).toEqual(false); + expect(wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('emptyState')).toEqual(
); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.tsx index 4a6e0d9c6e2d..f2a522b1b1d6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; import { SetWorkplaceSearchChrome } from '../../../shared/kibana_chrome'; -import { EnterpriseSearchPageTemplate, PageTemplateProps } from '../../../shared/layout'; +import { EnterpriseSearchPageTemplateWrapper, PageTemplateProps } from '../../../shared/layout'; import { SendWorkplaceSearchTelemetry } from '../../../shared/telemetry'; import { useWorkplaceSearchNav } from './nav'; @@ -21,7 +21,7 @@ export const WorkplaceSearchPageTemplate: React.FC = ({ ...pageTemplateProps }) => { return ( - = ({ )} {children} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts index fdccd536c3c6..5b893250235f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts @@ -19,6 +19,7 @@ import oneDrive from './onedrive.svg'; import salesforce from './salesforce.svg'; import serviceNow from './servicenow.svg'; import sharePoint from './sharepoint.svg'; +import sharePointServer from './sharepoint_server.svg'; import slack from './slack.svg'; import zendesk from './zendesk.svg'; @@ -29,6 +30,8 @@ export const images = { confluenceServer: confluence, custom, dropbox, + // TODO: For now external sources are all SharePoint. When this is no longer the case, this needs to be dynamic. + external: sharePoint, github, githubEnterpriseServer: github, githubViaApp: github, @@ -44,6 +47,7 @@ export const images = { salesforceSandbox: salesforce, serviceNow, sharePoint, + sharePointServer, slack, zendesk, } as { [key: string]: string }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/sharepoint_server.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/sharepoint_server.svg new file mode 100644 index 000000000000..aebfd7a8e49c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/sharepoint_server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx index 4d980ca2f531..68f83d935aad 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx @@ -15,9 +15,17 @@ import { SourceIcon } from './'; describe('SourceIcon', () => { it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiIcon)).toHaveLength(1); expect(wrapper.find('.user-group-source')).toHaveLength(0); }); + + it('renders a png icon if one is provided', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiIcon).prop('type')).toEqual('data:image/png;base64,base64encodedstring'); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.tsx index 2d5ffe183632..40153507d9cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.tsx @@ -18,11 +18,18 @@ interface SourceIconProps { name: string; className?: string; size?: IconSize; + iconAsBase64?: string; } -export const SourceIcon: React.FC = ({ name, serviceType, className, size }) => ( +export const SourceIcon: React.FC = ({ + name, + serviceType, + className, + size, + iconAsBase64, +}) => ( = ({ errorReason, allowsReauth, activities, + mainIcon, }, onSearchableToggle, isOrganization, @@ -115,7 +116,11 @@ export const SourceRow: React.FC = ({ responsive={false} > - + {name} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 451049846579..e83430504b38 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -192,6 +192,10 @@ export const SOURCE_NAMES = { 'xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.sharePoint', { defaultMessage: 'SharePoint Online' } ), + SHAREPOINT_SERVER: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.sharePointServer', + { defaultMessage: 'SharePoint Server' } + ), SLACK: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.slack', { defaultMessage: 'Slack', }), @@ -357,6 +361,7 @@ export const GITHUB_VIA_APP_SERVICE_TYPE = 'github_via_app'; export const GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE = 'github_enterprise_server_via_app'; export const CUSTOM_SERVICE_TYPE = 'custom'; +export const EXTERNAL_SERVICE_TYPE = 'external'; export const WORKPLACE_SEARCH_URL_PREFIX = '/app/enterprise_search/workplace_search'; 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 4857fa2a158a..cbcd1d885b12 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 @@ -7,11 +7,6 @@ import { generatePath } from 'react-router-dom'; -import { - GITHUB_VIA_APP_SERVICE_TYPE, - GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE, -} from './constants'; - export const SETUP_GUIDE_PATH = '/setup_guide'; export const NOT_FOUND_PATH = '/404'; @@ -40,25 +35,7 @@ export const PRIVATE_SOURCES_PATH = `${PERSONAL_PATH}${SOURCES_PATH}`; export const SOURCE_ADDED_PATH = `${SOURCES_PATH}/added`; export const ADD_SOURCE_PATH = `${SOURCES_PATH}/add`; -export const ADD_BOX_PATH = `${SOURCES_PATH}/add/box`; -export const ADD_CONFLUENCE_PATH = `${SOURCES_PATH}/add/confluence_cloud`; -export const ADD_CONFLUENCE_SERVER_PATH = `${SOURCES_PATH}/add/confluence_server`; -export const ADD_DROPBOX_PATH = `${SOURCES_PATH}/add/dropbox`; -export const ADD_GITHUB_ENTERPRISE_PATH = `${SOURCES_PATH}/add/github_enterprise_server`; -export const ADD_GITHUB_PATH = `${SOURCES_PATH}/add/github`; -export const ADD_GITHUB_VIA_APP_PATH = `${SOURCES_PATH}/add/${GITHUB_VIA_APP_SERVICE_TYPE}`; -export const ADD_GITHUB_ENTERPRISE_SERVER_VIA_APP_PATH = `${SOURCES_PATH}/add/${GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE}`; -export const ADD_GMAIL_PATH = `${SOURCES_PATH}/add/gmail`; -export const ADD_GOOGLE_DRIVE_PATH = `${SOURCES_PATH}/add/google_drive`; -export const ADD_JIRA_PATH = `${SOURCES_PATH}/add/jira_cloud`; -export const ADD_JIRA_SERVER_PATH = `${SOURCES_PATH}/add/jira_server`; -export const ADD_ONEDRIVE_PATH = `${SOURCES_PATH}/add/one_drive`; -export const ADD_SALESFORCE_PATH = `${SOURCES_PATH}/add/salesforce`; -export const ADD_SALESFORCE_SANDBOX_PATH = `${SOURCES_PATH}/add/salesforce_sandbox`; -export const ADD_SERVICENOW_PATH = `${SOURCES_PATH}/add/servicenow`; -export const ADD_SHAREPOINT_PATH = `${SOURCES_PATH}/add/share_point`; -export const ADD_SLACK_PATH = `${SOURCES_PATH}/add/slack`; -export const ADD_ZENDESK_PATH = `${SOURCES_PATH}/add/zendesk`; +export const ADD_EXTERNAL_PATH = `${SOURCES_PATH}/add/external`; export const ADD_CUSTOM_PATH = `${SOURCES_PATH}/add/custom`; export const PERSONAL_SETTINGS_PATH = `${PERSONAL_PATH}/settings`; @@ -83,24 +60,6 @@ export const ORG_SETTINGS_PATH = '/settings'; export const ORG_SETTINGS_CUSTOMIZE_PATH = `${ORG_SETTINGS_PATH}/customize`; export const ORG_SETTINGS_CONNECTORS_PATH = `${ORG_SETTINGS_PATH}/connectors`; export const ORG_SETTINGS_OAUTH_APPLICATION_PATH = `${ORG_SETTINGS_PATH}/oauth`; -export const EDIT_BOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/box/edit`; -export const EDIT_CONFLUENCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence_cloud/edit`; -export const EDIT_CONFLUENCE_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence_server/edit`; -export const EDIT_DROPBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/dropbox/edit`; -export const EDIT_GITHUB_ENTERPRISE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github_enterprise_server/edit`; -export const EDIT_GITHUB_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github/edit`; -export const EDIT_GMAIL_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/gmail/edit`; -export const EDIT_GOOGLE_DRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/google_drive/edit`; -export const EDIT_JIRA_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira_cloud/edit`; -export const EDIT_JIRA_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira_server/edit`; -export const EDIT_ONEDRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/one_drive/edit`; -export const EDIT_SALESFORCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce/edit`; -export const EDIT_SALESFORCE_SANDBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce_sandbox/edit`; -export const EDIT_SERVICENOW_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/servicenow/edit`; -export const EDIT_SHAREPOINT_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/share_point/edit`; -export const EDIT_SLACK_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/slack/edit`; -export const EDIT_ZENDESK_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/zendesk/edit`; -export const EDIT_CUSTOM_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/custom/edit`; export const getContentSourcePath = ( path: string, @@ -118,3 +77,6 @@ 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 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 b01700b8bce3..029ffb8a027a 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 @@ -66,23 +66,27 @@ export interface Configuration { needsConfiguration?: boolean; hasOauthRedirect: boolean; baseUrlTitle?: string; - helpText: string; + helpText?: string; documentationUrl: string; applicationPortalUrl?: string; applicationLinkTitle?: string; + githubRepository?: string; } export interface SourceDataItem { name: string; + iconName: string; + categories?: string[]; serviceType: string; configuration: Configuration; configured?: boolean; connected?: boolean; features?: Features; objTypes?: string[]; - addPath: string; - editPath?: string; // undefined for GitHub apps, as they are configured on a source level, and don't use a connector where you can edit the configuration accountContextOnly: boolean; + internalConnectorAvailable?: boolean; + externalConnectorAvailable?: boolean; + customConnectorAvailable?: boolean; } export interface ContentSource { @@ -109,6 +113,8 @@ export interface ContentSourceDetails extends ContentSource { boost: number; activities: SourceActivity[]; isOauth1: boolean; + altIcon?: string; // base64 encoded png + mainIcon?: string; // base64 encoded png } interface DescriptionList { 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 new file mode 100644 index 000000000000..fbfda1ddf8d5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 92f27500d726..86d3e4f844bb 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,3 +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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/is_not_nullish.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/is_not_nullish.ts new file mode 100644 index 000000000000..d492dad5d52c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/is_not_nullish.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function isNotNullish(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_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.test.tsx new file mode 100644 index 000000000000..b13cc6583cf2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.test.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; 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 { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { staticSourceData } from '../../source_data'; + +import { AddCustomSource } from './add_custom_source'; +import { AddCustomSourceSteps } from './add_custom_source_logic'; +import { ConfigureCustom } from './configure_custom'; +import { SaveCustom } from './save_custom'; + +describe('AddCustomSource', () => { + const props = { + sourceData: staticSourceData[0], + initialValues: undefined, + }; + + const values = { + sourceConfigData, + isOrganization: true, + }; + + beforeEach(() => { + setMockValues({ ...values }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(1); + }); + + it('should show correct layout for personal dashboard', () => { + setMockValues({ isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(0); + expect(wrapper.find(PersonalDashboardLayout)).toHaveLength(1); + }); + + it('should show Configure Custom for custom configuration step', () => { + setMockValues({ currentStep: AddCustomSourceSteps.ConfigureCustomStep }); + 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(); + + 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.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.tsx new file mode 100644 index 000000000000..6f7dc2bcdb34 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { AppLogic } from '../../../../app_logic'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV } from '../../../../constants'; + +import { SourceDataItem } from '../../../../types'; + +import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; +import { ConfigureCustom } from './configure_custom'; +import { SaveCustom } from './save_custom'; + +import './add_source.scss'; + +interface Props { + sourceData: SourceDataItem; + initialValue?: string; +} +export const AddCustomSource: React.FC = ({ sourceData, initialValue = '' }) => { + const addCustomSourceLogic = AddCustomSourceLogic({ sourceData, initialValue }); + const { currentStep } = useValues(addCustomSourceLogic); + const { isOrganization } = useValues(AppLogic); + + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + {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_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts new file mode 100644 index 000000000000..936096798587 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + LogicMounter, + mockFlashMessageHelpers, + mockHttpValues, +} from '../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; + +import { i18n } from '@kbn/i18n'; +import { nextTick } from '@kbn/test-jest-helpers'; + +import { docLinks } from '../../../../../shared/doc_links'; +import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; + +jest.mock('../../../../app_logic', () => ({ + AppLogic: { values: { isOrganization: true } }, +})); +import { AppLogic } from '../../../../app_logic'; + +import { SOURCE_NAMES } from '../../../../constants'; +import { CustomSource, SourceDataItem } 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, + helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', { + defaultMessage: + 'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.', + }), + 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', () => { + const { mount } = new LogicMounter(AddCustomSourceLogic); + const { http } = mockHttpValues; + const { clearFlashMessages } = mockFlashMessageHelpers; + + beforeEach(() => { + jest.clearAllMocks(); + mount({}, MOCK_PROPS); + }); + + it('has expected default values', () => { + expect(AddCustomSourceLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('setButtonNotLoading', () => { + it('turns off the button loading flag', () => { + AddCustomSourceLogic.actions.setButtonNotLoading(); + + expect(AddCustomSourceLogic.values).toEqual({ + ...DEFAULT_VALUES, + buttonLoading: false, + }); + }); + }); + + describe('setCustomSourceNameValue', () => { + it('saves the name', () => { + AddCustomSourceLogic.actions.setCustomSourceNameValue('name'); + + expect(AddCustomSourceLogic.values).toEqual({ + ...DEFAULT_VALUES, + customSourceNameValue: 'name', + }); + }); + }); + + describe('setNewCustomSource', () => { + it('saves the custom source', () => { + const newCustomSource = { + accessToken: 'foo', + key: 'bar', + name: 'source', + id: '123key', + }; + + AddCustomSourceLogic.actions.setNewCustomSource(newCustomSource); + + expect(AddCustomSourceLogic.values).toEqual({ + ...DEFAULT_VALUES, + newCustomSource, + currentStep: AddCustomSourceSteps.SaveCustomStep, + }); + }); + }); + }); + + describe('listeners', () => { + beforeEach(() => { + mount( + { + customSourceNameValue: MOCK_NAME, + }, + MOCK_PROPS + ); + }); + + describe('organization context', () => { + describe('createContentSource', () => { + it('calls API and sets values', async () => { + const setButtonNotLoadingSpy = jest.spyOn( + AddCustomSourceLogic.actions, + 'setButtonNotLoading' + ); + const setNewCustomSourceSpy = jest.spyOn( + AddCustomSourceLogic.actions, + 'setNewCustomSource' + ); + http.post.mockReturnValue(Promise.resolve({ sourceConfigData })); + + AddCustomSourceLogic.actions.createContentSource(); + + expect(clearFlashMessages).toHaveBeenCalled(); + expect(AddCustomSourceLogic.values.buttonLoading).toEqual(true); + expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/org/create_source', { + body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME }), + }); + await nextTick(); + expect(setNewCustomSourceSpy).toHaveBeenCalledWith({ sourceConfigData }); + expect(setButtonNotLoadingSpy).toHaveBeenCalled(); + }); + + itShowsServerErrorAsFlashMessage(http.post, () => { + AddCustomSourceLogic.actions.createContentSource(); + }); + }); + }); + + describe('account context routes', () => { + beforeEach(() => { + AppLogic.values.isOrganization = false; + }); + + describe('createContentSource', () => { + it('sends relevant fields to the API', () => { + AddCustomSourceLogic.actions.createContentSource(); + + expect(http.post).toHaveBeenCalledWith( + '/internal/workplace_search/account/create_source', + { + body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME }), + } + ); + }); + + itShowsServerErrorAsFlashMessage(http.post, () => { + AddCustomSourceLogic.actions.createContentSource(); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_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_logic.ts new file mode 100644 index 000000000000..5bf86f6df41c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +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'; + +export interface AddCustomSourceProps { + sourceData: SourceDataItem; + initialValue: string; +} + +export enum AddCustomSourceSteps { + ConfigureCustomStep = 'Configure Custom', + SaveCustomStep = 'Save Custom', +} + +export interface AddCustomSourceActions { + createContentSource(): void; + setButtonNotLoading(): void; + setCustomSourceNameValue(customSourceNameValue: string): string; + setNewCustomSource(data: CustomSource): CustomSource; +} + +interface AddCustomSourceValues { + buttonLoading: boolean; + currentStep: AddCustomSourceSteps; + customSourceNameValue: string; + newCustomSource: CustomSource; + sourceData: SourceDataItem; +} + +/** + * Workplace Search needs to know the host for the redirect. As of yet, we do not + * have access to this in Kibana. We parse it from the browser and pass it as a param. + */ + +export const AddCustomSourceLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'workplace_search', 'add_custom_source_logic'], + actions: { + createContentSource: true, + setButtonNotLoading: true, + setCustomSourceNameValue: (customSourceNameValue) => customSourceNameValue, + setNewCustomSource: (data) => data, + }, + reducers: ({ props }) => ({ + buttonLoading: [ + false, + { + setButtonNotLoading: () => false, + createContentSource: () => true, + }, + ], + currentStep: [ + AddCustomSourceSteps.ConfigureCustomStep, + { + setNewCustomSource: () => AddCustomSourceSteps.SaveCustomStep, + }, + ], + customSourceNameValue: [ + props.initialValue, + { + setCustomSourceNameValue: (_, customSourceNameValue) => customSourceNameValue, + }, + ], + newCustomSource: [ + {} as CustomSource, + { + setNewCustomSource: (_, newCustomSource) => newCustomSource, + }, + ], + sourceData: [props.sourceData], + }), + listeners: ({ actions, values }) => ({ + createContentSource: async () => { + clearFlashMessages(); + const { isOrganization } = AppLogic.values; + const route = isOrganization + ? '/internal/workplace_search/org/create_source' + : '/internal/workplace_search/account/create_source'; + + const { customSourceNameValue } = values; + + const params = { + service_type: 'custom', + name: customSourceNameValue, + }; + + try { + const response = await HttpLogic.values.http.post(route, { + body: JSON.stringify({ ...params }), + }); + actions.setNewCustomSource(response); + } catch (e) { + flashAPIErrors(e); + } finally { + actions.setButtonNotLoading(); + } + }, + }), +}); 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 0501509b3a8e..76c6c3cfa9d5 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 @@ -22,16 +22,17 @@ 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 { ConfigureCustom } from './configure_custom'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; import { SaveConfig } from './save_config'; -import { SaveCustom } from './save_custom'; describe('AddSourceList', () => { const { navigateToUrl } = mockKibanaValues; @@ -65,23 +66,39 @@ describe('AddSourceList', () => { }); it('renders default state', () => { - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConfigurationIntro).prop('advanceStep')(); expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); }); + it('renders default state correctly when there are multiple connector options', () => { + const wrapper = shallow( + + ); + wrapper.find(ConfigurationIntro).prop('advanceStep')(); + + expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ChoiceStep); + }); + 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); }); @@ -89,7 +106,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', '...']); }); @@ -99,7 +116,7 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep, }); - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConfigCompleted).prop('advanceStep')(); expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); @@ -111,7 +128,7 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.SaveConfigStep, }); - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); saveConfig.prop('advanceStep')(); saveConfig.prop('goBackStep')!(); @@ -126,52 +143,46 @@ 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'); }); - it('renders Configure Custom step', () => { - setMockValues({ - ...mockValues, - addSourceCurrentStep: AddSourceSteps.ConfigureCustomStep, - }); - const wrapper = shallow(); - wrapper.find(ConfigureCustom).prop('advanceStep')(); - - expect(createContentSource).toHaveBeenCalled(); - }); - it('renders Configure Oauth step', () => { setMockValues({ ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigureOauthStep, }); - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConfigureOauth).prop('onFormCreated')('foo'); expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); }); - it('renders Save Custom step', () => { + it('renders Reauthenticate step', () => { setMockValues({ ...mockValues, - addSourceCurrentStep: AddSourceSteps.SaveCustomStep, + addSourceCurrentStep: AddSourceSteps.ReauthenticateStep, }); - const wrapper = shallow(); + const wrapper = shallow(); - expect(wrapper.find(SaveCustom)).toHaveLength(1); + expect(wrapper.find(Reauthenticate)).toHaveLength(1); }); - it('renders Reauthenticate step', () => { + it('renders Config Choice step', () => { setMockValues({ ...mockValues, - addSourceCurrentStep: AddSourceSteps.ReauthenticateStep, + addSourceCurrentStep: AddSourceSteps.ChoiceStep, }); - const wrapper = shallow(); + const wrapper = shallow(); + const advance = wrapper.find(ConfigurationChoice).prop('goToInternalStep'); + expect(advance).toBeDefined(); + if (advance) { + advance(); + } - expect(wrapper.find(Reauthenticate)).toHaveLength(1); + 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 f575ddb19ebd..f03c77290f22 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 @@ -18,49 +18,31 @@ import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../components/layout'; -import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants'; -import { SOURCES_PATH, getSourcesPath } from '../../../../routes'; -import { SourceDataItem } from '../../../../types'; -import { staticSourceData } from '../../source_data'; +import { NAV } from '../../../../constants'; +import { SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; + +import { hasMultipleConnectorOptions } from '../../../../utils'; import { AddSourceHeader } from './add_source_header'; import { AddSourceLogic, AddSourceProps, AddSourceSteps } from './add_source_logic'; import { ConfigCompleted } from './config_completed'; +import { ConfigurationChoice } from './configuration_choice'; import { ConfigurationIntro } from './configuration_intro'; -import { ConfigureCustom } from './configure_custom'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; import { SaveConfig } from './save_config'; -import { SaveCustom } from './save_custom'; import './add_source.scss'; export const AddSource: React.FC = (props) => { - const { - initializeAddSource, - setAddSourceStep, - saveSourceConfig, - createContentSource, - resetSourceState, - } = useActions(AddSourceLogic); - const { - addSourceCurrentStep, - sourceConfigData: { - name, - categories, - needsPermissions, - accountContextOnly, - privateSourcesEnabled, - }, - dataLoading, - newCustomSource, - } = useValues(AddSourceLogic); - - const { serviceType, configuration, features, objTypes, addPath } = staticSourceData[ - props.sourceIndex - ] as SourceDataItem; - + 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); const { isOrganization } = useValues(AppLogic); useEffect(() => { @@ -72,6 +54,7 @@ export const AddSource: React.FC = (props) => { const goToSaveConfig = () => setAddSourceStep(AddSourceSteps.SaveConfigStep); 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', { @@ -85,9 +68,6 @@ export const AddSource: React.FC = (props) => { KibanaLogic.values.navigateToUrl(`${getSourcesPath(addPath, isOrganization)}/connect`); }; - const saveCustomSuccess = () => setAddSourceStep(AddSourceSteps.SaveCustomStep); - const goToSaveCustom = () => createContentSource(CUSTOM_SERVICE_TYPE, saveCustomSuccess); - const goToFormSourceCreated = () => { KibanaLogic.values.navigateToUrl(`${getSourcesPath(SOURCES_PATH, isOrganization)}`); flashSuccessToast(FORM_SOURCE_ADDED_SUCCESS_MESSAGE); @@ -99,7 +79,11 @@ export const AddSource: React.FC = (props) => { return ( {addSourceCurrentStep === AddSourceSteps.ConfigIntroStep && ( - + )} {addSourceCurrentStep === AddSourceSteps.SaveConfigStep && ( = (props) => { header={header} /> )} - {addSourceCurrentStep === AddSourceSteps.ConfigureCustomStep && ( - - )} {addSourceCurrentStep === AddSourceSteps.ConfigureOauthStep && ( )} - {addSourceCurrentStep === AddSourceSteps.SaveCustomStep && ( - - )} {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_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx index 08e002ee432a..15160abb4280 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx @@ -27,7 +27,7 @@ import { } from '../../../../components/layout'; import { ContentSection } from '../../../../components/shared/content_section'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; -import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants'; +import { NAV, CUSTOM_SERVICE_TYPE, EXTERNAL_SERVICE_TYPE } from '../../../../constants'; import { SourceDataItem } from '../../../../types'; import { SourcesLogic } from '../../sources_logic'; @@ -90,12 +90,12 @@ export const AddSourceList: React.FC = () => { const filterConfiguredSources = (source: SourceDataItem) => filterSources(source, configuredSources); - const visibleAvailableSources = availableSources.filter( - filterAvailableSources - ) as SourceDataItem[]; - const visibleConfiguredSources = configuredSources.filter( - filterConfiguredSources - ) as SourceDataItem[]; + const visibleAvailableSources = availableSources + .filter(filterAvailableSources) + .filter((source) => source.serviceType !== EXTERNAL_SERVICE_TYPE); + // The API returns available external sources as a separate entry, but we don't want to present them as options to add + + const visibleConfiguredSources = configuredSources.filter(filterConfiguredSources); const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; 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 65ccd8d95256..a633beac3a1c 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,6 +15,7 @@ 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,13 +23,9 @@ jest.mock('../../../../app_logic', () => ({ })); import { AppLogic } from '../../../../app_logic'; -import { - ADD_GITHUB_PATH, - SOURCES_PATH, - PRIVATE_SOURCES_PATH, - getSourcesPath, -} from '../../../../routes'; -import { CustomSource } from '../../../../types'; +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 { SourcesLogic } from '../../sources_logic'; @@ -38,6 +35,8 @@ import { SourceConfigData, SourceConnectData, OrganizationsMap, + AddSourceValues, + AddSourceProps, } from './add_source_logic'; describe('AddSourceLogic', () => { @@ -46,13 +45,12 @@ describe('AddSourceLogic', () => { const { navigateToUrl } = mockKibanaValues; const { clearFlashMessages, flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers; - const DEFAULT_VALUES = { + const DEFAULT_VALUES: AddSourceValues = { addSourceCurrentStep: AddSourceSteps.ConfigIntroStep, - addSourceProps: {}, + addSourceProps: {} as AddSourceProps, dataLoading: true, sectionLoading: true, buttonLoading: false, - customSourceNameValue: '', clientIdValue: '', clientSecretValue: '', baseUrlValue: '', @@ -62,7 +60,6 @@ describe('AddSourceLogic', () => { indexPermissionsValue: false, sourceConfigData: {} as SourceConfigData, sourceConnectData: {} as SourceConnectData, - newCustomSource: {} as CustomSource, oauthConfigCompleted: false, currentServiceType: '', githubOrganizations: [], @@ -81,8 +78,34 @@ describe('AddSourceLogic', () => { serviceType: 'github', githubOrganizations: ['foo', 'bar'], }; - - const CUSTOM_SERVICE_TYPE_INDEX = 17; + 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, + }; beforeEach(() => { jest.clearAllMocks(); @@ -145,15 +168,6 @@ describe('AddSourceLogic', () => { }); }); - it('setCustomSourceNameValue', () => { - AddSourceLogic.actions.setCustomSourceNameValue('name'); - - expect(AddSourceLogic.values).toEqual({ - ...DEFAULT_VALUES, - customSourceNameValue: 'name', - }); - }); - it('setSourceLoginValue', () => { AddSourceLogic.actions.setSourceLoginValue('login'); @@ -190,22 +204,6 @@ describe('AddSourceLogic', () => { }); }); - it('setCustomSourceData', () => { - const newCustomSource = { - accessToken: 'foo', - key: 'bar', - name: 'source', - id: '123key', - }; - - AddSourceLogic.actions.setCustomSourceData(newCustomSource); - - expect(AddSourceLogic.values).toEqual({ - ...DEFAULT_VALUES, - newCustomSource, - }); - }); - it('setPreContentSourceConfigData', () => { AddSourceLogic.actions.setPreContentSourceConfigData(config); @@ -260,13 +258,14 @@ describe('AddSourceLogic', () => { }); it('handles fallback states', () => { - const { publicKey, privateKey, consumerKey } = sourceConfigData.configuredFields; - const sourceConfigDataMock = { + const { publicKey, privateKey, consumerKey, apiKey } = sourceConfigData.configuredFields; + const sourceConfigDataMock: SourceConfigData = { ...sourceConfigData, configuredFields: { publicKey, privateKey, consumerKey, + apiKey, }, }; AddSourceLogic.actions.setSourceConfigData(sourceConfigDataMock); @@ -284,7 +283,7 @@ describe('AddSourceLogic', () => { describe('listeners', () => { it('initializeAddSource', () => { - const addSourceProps = { sourceIndex: 1 }; + const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; const getSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'getSourceConfigData'); const setAddSourcePropsSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceProps'); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); @@ -293,21 +292,13 @@ describe('AddSourceLogic', () => { expect(setAddSourcePropsSpy).toHaveBeenCalledWith({ addSourceProps }); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigIntroStep); - expect(getSourceConfigDataSpy).toHaveBeenCalledWith('confluence_cloud'); + expect(getSourceConfigDataSpy).toHaveBeenCalledWith('box'); }); describe('getFirstStep', () => { - it('sets custom as first step', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceIndex: CUSTOM_SERVICE_TYPE_INDEX }; - AddSourceLogic.actions.initializeAddSource(addSourceProps); - - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigureCustomStep); - }); - it('sets connect as first step', () => { const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceIndex: 1, connect: true }; + const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, connect: true }; AddSourceLogic.actions.initializeAddSource(addSourceProps); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); @@ -315,7 +306,7 @@ describe('AddSourceLogic', () => { it('sets configure as first step', () => { const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceIndex: 1, configure: true }; + const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, configure: true }; AddSourceLogic.actions.initializeAddSource(addSourceProps); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigureOauthStep); @@ -323,7 +314,7 @@ describe('AddSourceLogic', () => { it('sets reAuthenticate as first step', () => { const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceIndex: 1, reAuthenticate: true }; + const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, reAuthenticate: true }; AddSourceLogic.actions.initializeAddSource(addSourceProps); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ReauthenticateStep); @@ -401,7 +392,7 @@ describe('AddSourceLogic', () => { await nextTick(); expect(setPreContentSourceIdSpy).toHaveBeenCalledWith(preContentSourceId); - expect(navigateToUrl).toHaveBeenCalledWith(`${ADD_GITHUB_PATH}/configure${queryString}`); + expect(navigateToUrl).toHaveBeenCalledWith(`/sources/add/github/configure${queryString}`); }); describe('Github error edge case', () => { @@ -635,7 +626,6 @@ describe('AddSourceLogic', () => { const errorCallback = jest.fn(); const serviceType = 'zendesk'; - const name = 'name'; const login = 'login'; const password = 'password'; const indexPermissions = false; @@ -643,7 +633,6 @@ describe('AddSourceLogic', () => { let params: any; beforeEach(() => { - AddSourceLogic.actions.setCustomSourceNameValue(name); AddSourceLogic.actions.setSourceLoginValue(login); AddSourceLogic.actions.setSourcePasswordValue(password); AddSourceLogic.actions.setPreContentSourceConfigData(config); @@ -652,7 +641,6 @@ describe('AddSourceLogic', () => { params = { service_type: serviceType, - name, login, password, organizations: ['foo'], @@ -661,8 +649,7 @@ describe('AddSourceLogic', () => { it('calls API and sets values', async () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); - const setCustomSourceDataSpy = jest.spyOn(AddSourceLogic.actions, 'setCustomSourceData'); - http.post.mockReturnValue(Promise.resolve({ sourceConfigData })); + http.post.mockReturnValue(Promise.resolve()); AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); @@ -672,17 +659,18 @@ describe('AddSourceLogic', () => { body: JSON.stringify({ ...params }), }); await nextTick(); - expect(setCustomSourceDataSpy).toHaveBeenCalledWith({ sourceConfigData }); expect(successCallback).toHaveBeenCalled(); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); }); it('handles error', async () => { + const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); http.post.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); await nextTick(); + expect(setButtonNotLoadingSpy).toHaveBeenCalled(); expect(errorCallback).toHaveBeenCalled(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/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 6dbac2dcd145..92fab713a3fa 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 @@ -21,20 +21,14 @@ import { import { HttpLogic } from '../../../../../shared/http'; import { KibanaLogic } from '../../../../../shared/kibana'; import { AppLogic } from '../../../../app_logic'; -import { CUSTOM_SERVICE_TYPE, WORKPLACE_SEARCH_URL_PREFIX } from '../../../../constants'; -import { - SOURCES_PATH, - ADD_GITHUB_PATH, - PRIVATE_SOURCES_PATH, - getSourcesPath, -} from '../../../../routes'; -import { CustomSource } from '../../../../types'; +import { WORKPLACE_SEARCH_URL_PREFIX } from '../../../../constants'; +import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; +import { SourceDataItem } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; -import { staticSourceData } from '../../source_data'; import { SourcesLogic } from '../../sources_logic'; export interface AddSourceProps { - sourceIndex: number; + sourceData: SourceDataItem; connect?: boolean; configure?: boolean; reAuthenticate?: boolean; @@ -45,10 +39,9 @@ export enum AddSourceSteps { SaveConfigStep = 'Save Config', ConfigCompletedStep = 'Config Completed', ConnectInstanceStep = 'Connect Instance', - ConfigureCustomStep = 'Configure Custom', ConfigureOauthStep = 'Configure Oauth', - SaveCustomStep = 'Save Custom', ReauthenticateStep = 'Reauthenticate', + ChoiceStep = 'Choice', } export interface OauthParams { @@ -71,12 +64,10 @@ export interface AddSourceActions { setClientIdValue(clientIdValue: string): string; setClientSecretValue(clientSecretValue: string): string; setBaseUrlValue(baseUrlValue: string): string; - setCustomSourceNameValue(customSourceNameValue: string): string; setSourceLoginValue(loginValue: string): string; setSourcePasswordValue(passwordValue: string): string; setSourceSubdomainValue(subdomainValue: string): string; setSourceIndexPermissionsValue(indexPermissionsValue: boolean): boolean; - setCustomSourceData(data: CustomSource): CustomSource; setPreContentSourceConfigData(data: PreContentSourceResponse): PreContentSourceResponse; setPreContentSourceId(preContentSourceId: string): string; setSelectedGithubOrganizations(option: string): string; @@ -119,6 +110,8 @@ export interface SourceConfigData { baseUrl?: string; clientId?: string; clientSecret?: string; + url?: string; + apiKey?: string; }; accountContextOnly?: boolean; } @@ -132,13 +125,12 @@ export interface OrganizationsMap { [key: string]: string | boolean; } -interface AddSourceValues { +export interface AddSourceValues { addSourceProps: AddSourceProps; addSourceCurrentStep: AddSourceSteps; dataLoading: boolean; sectionLoading: boolean; buttonLoading: boolean; - customSourceNameValue: string; clientIdValue: string; clientSecretValue: string; baseUrlValue: string; @@ -148,7 +140,6 @@ interface AddSourceValues { indexPermissionsValue: boolean; sourceConfigData: SourceConfigData; sourceConnectData: SourceConnectData; - newCustomSource: CustomSource; currentServiceType: string; githubOrganizations: string[]; selectedGithubOrganizationsMap: OrganizationsMap; @@ -185,12 +176,10 @@ export const AddSourceLogic = kea clientIdValue, setClientSecretValue: (clientSecretValue: string) => clientSecretValue, setBaseUrlValue: (baseUrlValue: string) => baseUrlValue, - setCustomSourceNameValue: (customSourceNameValue: string) => customSourceNameValue, setSourceLoginValue: (loginValue: string) => loginValue, setSourcePasswordValue: (passwordValue: string) => passwordValue, setSourceSubdomainValue: (subdomainValue: string) => subdomainValue, setSourceIndexPermissionsValue: (indexPermissionsValue: boolean) => indexPermissionsValue, - setCustomSourceData: (data: CustomSource) => data, setPreContentSourceConfigData: (data: PreContentSourceResponse) => data, setPreContentSourceId: (preContentSourceId: string) => preContentSourceId, setSelectedGithubOrganizations: (option: string) => option, @@ -322,20 +311,6 @@ export const AddSourceLogic = kea false, }, ], - customSourceNameValue: [ - '', - { - setCustomSourceNameValue: (_, customSourceNameValue) => customSourceNameValue, - resetSourceState: () => '', - }, - ], - newCustomSource: [ - {} as CustomSource, - { - setCustomSourceData: (_, newCustomSource) => newCustomSource, - resetSourceState: () => ({} as CustomSource), - }, - ], currentServiceType: [ '', { @@ -383,7 +358,7 @@ export const AddSourceLogic = kea ({ initializeAddSource: ({ addSourceProps }) => { - const { serviceType } = staticSourceData[addSourceProps.sourceIndex]; + const { serviceType } = addSourceProps.sourceData; actions.setAddSourceProps({ addSourceProps }); actions.setAddSourceStep(getFirstStep(addSourceProps)); actions.getSourceConfigData(serviceType); @@ -540,7 +515,9 @@ export const AddSourceLogic = kea 0 ? githubOrganizations : undefined, @@ -580,10 +555,9 @@ export const AddSourceLogic = kea params[key] === undefined && delete params[key]); try { - const response = await HttpLogic.values.http.post(route, { + await HttpLogic.values.http.post(route, { body: JSON.stringify({ ...params }), }); - actions.setCustomSourceData(response); successCallback(); } catch (e) { flashAPIErrors(e); @@ -596,11 +570,7 @@ export const AddSourceLogic = kea { - const { sourceIndex, connect, configure, reAuthenticate } = props; - const { serviceType } = staticSourceData[sourceIndex]; - const isCustom = serviceType === CUSTOM_SERVICE_TYPE; - - if (isCustom) return AddSourceSteps.ConfigureCustomStep; + const { connect, configure, reAuthenticate } = props; if (connect) return AddSourceSteps.ConnectInstanceStep; if (configure) return AddSourceSteps.ConfigureOauthStep; if (reAuthenticate) return AddSourceSteps.ReauthenticateStep; 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 f168dfbea91c..fbcb8685f7ff 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(11); + expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(20); expect(wrapper.find('[data-test-subj="CustomAPISourceLink"]')).toHaveLength(1); }); @@ -34,7 +34,7 @@ describe('AvailableSourcesList', () => { setMockValues({ hasPlatinumLicense: false }); const wrapper = shallow(); - expect(wrapper.find(EuiToolTip)).toHaveLength(1); + expect(wrapper.find(EuiToolTip)).toHaveLength(2); }); it('handles empty state', () => { 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 13f0f41643e1..7dc9ad9ca0f6 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 @@ -24,9 +24,11 @@ import { i18n } from '@kbn/i18n'; import { LicensingLogic } from '../../../../../shared/licensing'; import { EuiButtonEmptyTo, EuiLinkTo } from '../../../../../shared/react_router_helpers'; import { SourceIcon } from '../../../../components/shared/source_icon'; -import { ADD_CUSTOM_PATH, getSourcesPath } from '../../../../routes'; +import { ADD_CUSTOM_PATH, getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; +import { staticCustomSourceData } from '../../source_data'; + import { AVAILABLE_SOURCE_EMPTY_STATE, AVAILABLE_SOURCE_TITLE, @@ -41,7 +43,8 @@ interface AvailableSourcesListProps { export const AvailableSourcesList: React.FC = ({ sources }) => { const { hasPlatinumLicense } = useValues(LicensingLogic); - const getSourceCard = ({ name, serviceType, addPath, accountContextOnly }: SourceDataItem) => { + const getSourceCard = ({ name, serviceType, accountContextOnly }: SourceDataItem) => { + const addPath = getAddPath(serviceType); const disabled = !hasPlatinumLicense && accountContextOnly; const connectButton = () => { @@ -105,6 +108,15 @@ export const AvailableSourcesList: React.FC = ({ sour ))} + + + {getSourceCard(staticCustomSourceData)} + + ); 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 new file mode 100644 index 000000000000..392ce175d271 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx @@ -0,0 +1,131 @@ +/* + * Copyright 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 { mockKibanaValues, setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiText, EuiButton } from '@elastic/eui'; + +import { staticSourceData } from '../../source_data'; + +import { ConfigurationChoice } from './configuration_choice'; + +describe('ConfigurationChoice', () => { + const { navigateToUrl } = mockKibanaValues; + const props = { + sourceData: staticSourceData[0], + }; + const mockValues = { + isOrganization: true, + }; + + beforeEach(() => { + setMockValues(mockValues); + jest.clearAllMocks(); + }); + + it('renders internal connector if available', () => { + const wrapper = shallow(); + + expect(wrapper.find('EuiPanel')).toHaveLength(1); + expect(wrapper.find(EuiText)).toHaveLength(3); + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + it('should navigate to internal connector on internal connector click', () => { + const wrapper = shallow(); + 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 = shallow( + + ); + const button = wrapper.find(EuiButton); + button.simulate('click'); + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(advanceSpy).toHaveBeenCalled(); + }); + + it('renders external connector if available', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('EuiPanel')).toHaveLength(1); + expect(wrapper.find(EuiText)).toHaveLength(3); + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + it('should navigate to external connector on external connector click', () => { + const wrapper = shallow( + + ); + const button = wrapper.find(EuiButton); + button.simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/external/'); + }); + + it('renders custom connector if available', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('EuiPanel')).toHaveLength(1); + expect(wrapper.find(EuiText)).toHaveLength(3); + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + it('should navigate to custom connector on internal connector click', () => { + const wrapper = shallow( + + ); + 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 new file mode 100644 index 000000000000..f5d6d51651dd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx @@ -0,0 +1,233 @@ +/* + * Copyright 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 { useValues } from 'kea'; + +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { AppLogic } from '../../../../app_logic'; +import { getAddPath, getSourcesPath } from '../../../../routes'; +import { SourceDataItem } from '../../../../types'; + +import { AddSourceHeader } from './add_source_header'; + +interface ConfigurationIntroProps { + sourceData: SourceDataItem; + goToInternalStep?: () => void; +} + +export const ConfigurationChoice: React.FC = ({ + sourceData: { + name, + serviceType, + externalConnectorAvailable, + internalConnectorAvailable, + customConnectorAvailable, + }, + goToInternalStep, +}) => { + const { isOrganization } = useValues(AppLogic); + 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 + )}/` + ); + + return ( + <> + + + {internalConnectorAvailable && ( + + + + + +

{name}

+
+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.title', + { + defaultMessage: 'Default connector', + } + )} +

+
+
+ + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.description', + { + defaultMessage: 'Use our out-of-the-box connector to get started quickly.', + } + )} + + + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.button', + { + defaultMessage: 'Connect', + } + )} + + +
+
+
+ )} + {externalConnectorAvailable && ( + + + + + +

{name}

+
+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.title', + { + defaultMessage: 'Custom connector', + } + )} +

+
+
+ + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.description', + { + defaultMessage: + 'Set up a custom connector for more configurability and control.', + } + )} + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.button', + { + defaultMessage: 'Instructions', + } + )} + + + +
+
+
+ )} + {customConnectorAvailable && ( + + + + + +

{name}

+
+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.custom.title', + { + defaultMessage: 'Custom connector', + } + )} +

+
+
+ + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.custom.description', + { + defaultMessage: + 'Set up a custom connector for more configurability and control.', + } + )} + + +
+ + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.custom.button', + { + defaultMessage: 'Instructions', + } + )} + + + +
+
+ )} +
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx index 6c0d87b7696e..645226c546f1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx @@ -14,45 +14,45 @@ import { shallow } from 'enzyme'; import { EuiForm, EuiFieldText } from '@elastic/eui'; +import { staticSourceData } from '../../source_data'; + import { ConfigureCustom } from './configure_custom'; describe('ConfigureCustom', () => { - const advanceStep = jest.fn(); const setCustomSourceNameValue = jest.fn(); - - const props = { - header:

Header

, - helpText: 'I bet you could use a hand.', - advanceStep, - }; + const createContentSource = jest.fn(); beforeEach(() => { - setMockActions({ setCustomSourceNameValue }); - setMockValues({ customSourceNameValue: 'name', buttonLoading: false }); + 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 TEXT = 'changed for the better'; + const wrapper = shallow(); + const text = 'changed for the better'; const input = wrapper.find(EuiFieldText); - input.simulate('change', { target: { value: TEXT } }); + input.simulate('change', { target: { value: text } }); - expect(setCustomSourceNameValue).toHaveBeenCalledWith(TEXT); + expect(setCustomSourceNameValue).toHaveBeenCalledWith(text); }); it('handles form submission', () => { - const wrapper = shallow(); + const wrapper = shallow(); const preventDefault = jest.fn(); wrapper.find('form').simulate('submit', { preventDefault }); expect(preventDefault).toHaveBeenCalled(); - expect(advanceStep).toHaveBeenCalled(); + expect(createContentSource).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx index e794323dc169..bf5a7fea2133 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx @@ -24,51 +24,64 @@ import { docLinks } from '../../../../../shared/doc_links'; import { SOURCE_NAME_LABEL } from '../../constants'; -import { AddSourceLogic } from './add_source_logic'; +import { AddCustomSourceLogic } from './add_custom_source_logic'; +import { AddSourceHeader } from './add_source_header'; import { CONFIG_CUSTOM_BUTTON, CONFIG_CUSTOM_LINK_TEXT } from './constants'; -interface ConfigureCustomProps { - header: React.ReactNode; - helpText: string; - advanceStep(): void; -} - -export const ConfigureCustom: React.FC = ({ - helpText, - advanceStep, - header, -}) => { - const { setCustomSourceNameValue } = useActions(AddSourceLogic); - const { customSourceNameValue, buttonLoading } = useValues(AddSourceLogic); +export const ConfigureCustom: React.FC = () => { + const { setCustomSourceNameValue, createContentSource } = useActions(AddCustomSourceLogic); + const { customSourceNameValue, buttonLoading, sourceData } = useValues(AddCustomSourceLogic); const handleFormSubmit = (e: FormEvent) => { e.preventDefault(); - advanceStep(); + createContentSource(); }; const handleNameChange = (e: ChangeEvent) => setCustomSourceNameValue(e.target.value); + const { + serviceType, + configuration: { documentationUrl, helpText }, + name, + categories = [], + } = sourceData; + return ( <> - {header} +

{helpText}

- - {CONFIG_CUSTOM_LINK_TEXT} - - ), - }} - /> + {serviceType === 'custom' ? ( + + {CONFIG_CUSTOM_LINK_TEXT} + + ), + }} + /> + ) : ( + + {CONFIG_CUSTOM_LINK_TEXT} + + ), + name, + }} + /> + )}

@@ -90,7 +103,17 @@ export const ConfigureCustom: React.FC = ({ isLoading={buttonLoading} data-test-subj="CreateCustomButton" > - {CONFIG_CUSTOM_BUTTON} + {serviceType === 'custom' ? ( + CONFIG_CUSTOM_BUTTON + ) : ( + + )}
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 a1169cd582cb..a13558469cc0 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 @@ -22,9 +22,9 @@ describe('ConfiguredSourcesList', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(5); - expect(wrapper.find('[data-test-subj="AccountOnlyTooltip"]')).toHaveLength(1); - expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(6); + expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(16); + expect(wrapper.find('[data-test-subj="AccountOnlyTooltip"]')).toHaveLength(2); + expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(19); }); 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 ac465c43643a..d4157caae2d1 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 @@ -20,15 +20,17 @@ import { EuiToolTip, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + import { EuiButtonEmptyTo } from '../../../../../shared/react_router_helpers'; import { SourceIcon } from '../../../../components/shared/source_icon'; -import { getSourcesPath } from '../../../../routes'; +import { getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; +import { hasMultipleConnectorOptions } from '../../../../utils'; import { CONFIGURED_SOURCES_LIST_UNCONNECTED_TOOLTIP, CONFIGURED_SOURCES_LIST_ACCOUNT_ONLY_TOOLTIP, - CONFIGURED_SOURCES_CONNECT_BUTTON, CONFIGURED_SOURCES_EMPTY_STATE, CONFIGURED_SOURCES_TITLE, CONFIGURED_SOURCES_EMPTY_BODY, @@ -68,54 +70,74 @@ export const ConfiguredSourcesList: React.FC = ({ const visibleSources = ( - {sources.map(({ name, serviceType, addPath, connected, accountContextOnly }, i) => ( - - - - - - - - - - -

- {name} - {!connected && !accountContextOnly && isOrganization && unConnectedTooltip} - {accountContextOnly && isOrganization && accountOnlyTooltip} -

-
-
-
-
- - {((!isOrganization || (isOrganization && !accountContextOnly)) && ( - { + const { connected, accountContextOnly, name, serviceType } = sourceData; + return ( + + + + + - {CONFIGURED_SOURCES_CONNECT_BUTTON} - - )) || ( - - {ADD_SOURCE_ORG_SOURCES_TITLE} - - )} - -
-
-
- ))} + + + + + +

+ {name} + {!connected && + !accountContextOnly && + isOrganization && + unConnectedTooltip} + {accountContextOnly && isOrganization && accountOnlyTooltip} +

+
+
+ + + + {((!isOrganization || (isOrganization && !accountContextOnly)) && ( + + {!connected + ? i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configuredSources.connectButton', + { + defaultMessage: 'Connect', + } + ) + : i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configuredSources.connectAnotherButton', + { + defaultMessage: 'Connect another', + } + )} + + )) || ( + + {ADD_SOURCE_ORG_SOURCES_TITLE} + + )} + + + + + ); + })}
); 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 c967b20e0450..0ae176dbef01 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 @@ -36,14 +36,13 @@ describe('ConnectInstance', () => { const getSourceConnectData = jest.fn((_, redirectOauth) => { redirectOauth(); }); - const createContentSource = jest.fn((_, redirectFormCreated, handleFormSubmitError) => { + const createContentSource = jest.fn((_, redirectFormCreated) => { redirectFormCreated(); - handleFormSubmitError(); }); const credentialsSourceData = staticSourceData[13]; const oauthSourceData = staticSourceData[0]; - const subdomainSourceData = staticSourceData[16]; + const subdomainSourceData = staticSourceData[18]; const props = { ...credentialsSourceData, 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 a9e24c7b944a..352addd8176d 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useEffect, FormEvent } from 'react'; +import React, { useEffect, FormEvent } from 'react'; import { useActions, useValues } from 'kea'; @@ -51,8 +51,6 @@ export const ConnectInstance: React.FC = ({ onFormCreated, header, }) => { - const [formLoading, setFormLoading] = useState(false); - const { hasPlatinumLicense } = useValues(LicensingLogic); const { @@ -64,7 +62,7 @@ export const ConnectInstance: React.FC = ({ setSourceIndexPermissionsValue, } = useActions(AddSourceLogic); - const { loginValue, passwordValue, indexPermissionsValue, subdomainValue } = + const { buttonLoading, loginValue, passwordValue, indexPermissionsValue, subdomainValue } = useValues(AddSourceLogic); const { isOrganization } = useValues(AppLogic); @@ -77,12 +75,9 @@ export const ConnectInstance: React.FC = ({ const redirectOauth = (oauthUrl: string) => window.location.replace(oauthUrl); const redirectFormCreated = () => onFormCreated(name); const onOauthFormSubmit = () => getSourceConnectData(serviceType, redirectOauth); - const handleFormSubmitError = () => setFormLoading(false); - const onCredentialsFormSubmit = () => - createContentSource(serviceType, redirectFormCreated, handleFormSubmitError); + const onCredentialsFormSubmit = () => createContentSource(serviceType, redirectFormCreated); const handleFormSubmit = (e: FormEvent) => { - setFormLoading(true); e.preventDefault(); const onSubmit = hasOauthRedirect ? onOauthFormSubmit : onCredentialsFormSubmit; onSubmit(); @@ -145,7 +140,7 @@ export const ConnectInstance: React.FC = ({ {permissionsExcluded && !hasPlatinumLicense && } - + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.contentSource.connect.button', { defaultMessage: 'Connect {name}', values: { name }, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts index 3ce4f930b7a3..5963f4cb2563 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts @@ -223,13 +223,6 @@ export const CONFIGURED_SOURCES_LIST_ACCOUNT_ONLY_TOOLTIP = i18n.translate( } ); -export const CONFIGURED_SOURCES_CONNECT_BUTTON = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.configuredSources.connectButton', - { - defaultMessage: 'Connect another', - } -); - export const CONFIGURED_SOURCES_EMPTY_STATE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configuredSources.emptyState', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx new file mode 100644 index 000000000000..6288a5fc7912 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiSteps } from '@elastic/eui'; + +import { staticSourceData } from '../../source_data'; + +import { ExternalConnectorConfig } from './external_connector_config'; + +describe('ExternalConnectorConfig', () => { + const goBack = jest.fn(); + const onDeleteConfig = jest.fn(); + const setExternalConnectorApiKey = jest.fn(); + const setExternalConnectorUrl = jest.fn(); + const saveExternalConnectorConfig = jest.fn(); + const fetchExternalSource = jest.fn(); + + const props = { + sourceData: staticSourceData[0], + goBack, + onDeleteConfig, + }; + + const values = { + sourceConfigData, + buttonLoading: false, + clientIdValue: 'foo', + clientSecretValue: 'bar', + baseUrlValue: 'http://foo.baz', + hasPlatinumLicense: true, + }; + + beforeEach(() => { + setMockActions({ + setExternalConnectorApiKey, + setExternalConnectorUrl, + saveExternalConnectorConfig, + fetchExternalSource, + }); + setMockValues({ ...values }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiSteps)).toHaveLength(1); + }); + + it('handles form submission', () => { + const wrapper = shallow(); + + const preventDefault = jest.fn(); + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(preventDefault).toHaveBeenCalled(); + expect(saveExternalConnectorConfig).toHaveBeenCalled(); + }); + + describe('external connector configuration', () => { + it('handles url change', () => { + const wrapper = shallow(); + const steps = wrapper.find(EuiSteps); + const input = steps.dive().find('[name="external-connector-url"]'); + input.simulate('change', { target: { value: 'url' } }); + + expect(setExternalConnectorUrl).toHaveBeenCalledWith('url'); + }); + + it('handles Client secret change', () => { + const wrapper = shallow(); + const steps = wrapper.find(EuiSteps); + const input = steps.dive().find('[name="external-connector-api-key"]'); + input.simulate('change', { target: { value: 'api-key' } }); + + expect(setExternalConnectorApiKey).toHaveBeenCalledWith('api-key'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx new file mode 100644 index 000000000000..1f0528f492b9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx @@ -0,0 +1,168 @@ +/* + * Copyright 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, { FormEvent, useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiSteps, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { AppLogic } from '../../../../app_logic'; +import { + PersonalDashboardLayout, + WorkplaceSearchPageTemplate, +} from '../../../../components/layout'; +import { NAV, REMOVE_BUTTON } from '../../../../constants'; +import { SourceDataItem } from '../../../../types'; + +import { AddSourceHeader } from './add_source_header'; +import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './constants'; +import { ExternalConnectorLogic } from './external_connector_logic'; + +interface SaveConfigProps { + sourceData: SourceDataItem; + goBack?: () => void; + onDeleteConfig?: () => void; +} + +export const ExternalConnectorConfig: React.FC = ({ goBack, onDeleteConfig }) => { + const serviceType = 'external'; + const { + fetchExternalSource, + setExternalConnectorApiKey, + setExternalConnectorUrl, + saveExternalConnectorConfig, + } = useActions(ExternalConnectorLogic); + + const { buttonLoading, externalConnectorUrl, externalConnectorApiKey, sourceConfigData } = + useValues(ExternalConnectorLogic); + + useEffect(() => { + fetchExternalSource(); + }, []); + + const handleFormSubmission = (e: FormEvent) => { + e.preventDefault(); + saveExternalConnectorConfig({ url: externalConnectorUrl, apiKey: externalConnectorApiKey }); + }; + + const { name, categories } = sourceConfigData; + const { isOrganization } = useValues(AppLogic); + + const saveButton = ( + + {OAUTH_SAVE_CONFIG_BUTTON} + + ); + + const deleteButton = ( + + {REMOVE_BUTTON} + + ); + + const backButton = {OAUTH_BACK_BUTTON}; + + const formActions = ( + + + {saveButton} + + {goBack && backButton} + {onDeleteConfig && deleteButton} + + + + ); + + const connectorForm = ( + + {/* TODO: get a docs link in here for the external connector + */} + + + + setExternalConnectorUrl(e.target.value)} + name="external-connector-url" + /> + + + setExternalConnectorApiKey(e.target.value)} + name="external-connector-api-key" + /> + + + {formActions} + + + ); + + const configSteps = [ + { + title: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.addSource.externalConnectorConfig.stepTitle', + { + defaultMessage: 'Provide the appropriate configuration information', + } + ), + children: connectorForm, + }, + ]; + + 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/external_connector_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts new file mode 100644 index 000000000000..22a36deeeccd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright 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 { LogicMounter, mockHttpValues, mockKibanaValues } from '../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; + +import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; + +jest.mock('../../../../app_logic', () => ({ + AppLogic: { values: { isOrganization: true } }, +})); + +import { ExternalConnectorLogic, ExternalConnectorValues } from './external_connector_logic'; + +describe('ExternalConnectorLogic', () => { + const { mount } = new LogicMounter(ExternalConnectorLogic); + const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; + + const DEFAULT_VALUES: ExternalConnectorValues = { + dataLoading: true, + buttonLoading: false, + externalConnectorUrl: '', + externalConnectorApiKey: '', + sourceConfigData: { + name: '', + categories: [], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(ExternalConnectorLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('fetchExternalSourceSuccess', () => { + beforeEach(() => { + ExternalConnectorLogic.actions.fetchExternalSourceSuccess(sourceConfigData); + }); + + it('turns off the data loading flag', () => { + expect(ExternalConnectorLogic.values.dataLoading).toEqual(false); + }); + + it('saves the external url', () => { + expect(ExternalConnectorLogic.values.externalConnectorUrl).toEqual( + sourceConfigData.configuredFields.url + ); + }); + + it('saves the source config', () => { + expect(ExternalConnectorLogic.values.sourceConfigData).toEqual(sourceConfigData); + }); + + it('sets undefined url to empty string', () => { + ExternalConnectorLogic.actions.fetchExternalSourceSuccess({ + ...sourceConfigData, + configuredFields: { ...sourceConfigData.configuredFields, url: undefined }, + }); + expect(ExternalConnectorLogic.values.externalConnectorUrl).toEqual(''); + }); + it('sets undefined api key to empty string', () => { + ExternalConnectorLogic.actions.fetchExternalSourceSuccess({ + ...sourceConfigData, + configuredFields: { ...sourceConfigData.configuredFields, apiKey: undefined }, + }); + expect(ExternalConnectorLogic.values.externalConnectorApiKey).toEqual(''); + }); + }); + + describe('saveExternalConnectorConfigSuccess', () => { + it('turns off the button loading flag', () => { + mount({ + buttonLoading: true, + }); + + ExternalConnectorLogic.actions.saveExternalConnectorConfigSuccess('external'); + + expect(ExternalConnectorLogic.values.buttonLoading).toEqual(false); + }); + }); + + describe('setExternalConnectorApiKey', () => { + it('updates the api key', () => { + ExternalConnectorLogic.actions.setExternalConnectorApiKey('abcd1234'); + + expect(ExternalConnectorLogic.values.externalConnectorApiKey).toEqual('abcd1234'); + }); + }); + + describe('setExternalConnectorUrl', () => { + it('updates the url', () => { + ExternalConnectorLogic.actions.setExternalConnectorUrl('https://www.elastic.co'); + + expect(ExternalConnectorLogic.values.externalConnectorUrl).toEqual( + 'https://www.elastic.co' + ); + }); + }); + }); + + describe('listeners', () => { + describe('fetchExternalSource', () => { + it('retrieves config info on the "external" connector', () => { + const promise = Promise.resolve(); + http.get.mockReturnValue(promise); + ExternalConnectorLogic.actions.fetchExternalSource(); + + expect(http.get).toHaveBeenCalledWith( + '/internal/workplace_search/org/settings/connectors/external' + ); + }); + + itShowsServerErrorAsFlashMessage(http.get, () => { + mount(); + ExternalConnectorLogic.actions.fetchExternalSource(); + }); + }); + + describe('saveExternalConnectorConfig', () => { + it('saves the external connector config', () => { + const saveExternalConnectorConfigSuccess = jest.spyOn( + ExternalConnectorLogic.actions, + 'saveExternalConnectorConfigSuccess' + ); + ExternalConnectorLogic.actions.saveExternalConnectorConfig({ + url: 'url', + apiKey: 'apiKey', + }); + expect(saveExternalConnectorConfigSuccess).toHaveBeenCalled(); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/external'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts new file mode 100644 index 000000000000..13c0b9167310 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { + flashAPIErrors, + flashSuccessToast, + clearFlashMessages, +} from '../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../shared/http'; +import { KibanaLogic } from '../../../../../shared/kibana'; +import { AppLogic } from '../../../../app_logic'; + +import { getAddPath, getSourcesPath } from '../../../../routes'; + +import { SourceConfigData } from './add_source_logic'; + +export interface ExternalConnectorActions { + fetchExternalSource: () => true; + fetchExternalSourceSuccess(sourceConfigData: SourceConfigData): SourceConfigData; + saveExternalConnectorConfigSuccess(externalConnectorId: string): string; + setExternalConnectorApiKey(externalConnectorApiKey: string): string; + saveExternalConnectorConfig(config: ExternalConnectorConfig): ExternalConnectorConfig; + setExternalConnectorUrl(externalConnectorUrl: string): string; + resetSourceState: () => true; +} + +export interface ExternalConnectorConfig { + url: string; + apiKey: string; +} + +export interface ExternalConnectorValues { + buttonLoading: boolean; + dataLoading: boolean; + externalConnectorApiKey: string; + externalConnectorUrl: string; + sourceConfigData: SourceConfigData | Pick; +} + +export const ExternalConnectorLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'workplace_search', 'external_connector_logic'], + actions: { + fetchExternalSource: true, + fetchExternalSourceSuccess: (sourceConfigData) => sourceConfigData, + saveExternalConnectorConfigSuccess: (externalConnectorId) => externalConnectorId, + saveExternalConnectorConfig: (config) => config, + setExternalConnectorApiKey: (externalConnectorApiKey: string) => externalConnectorApiKey, + setExternalConnectorUrl: (externalConnectorUrl: string) => externalConnectorUrl, + }, + reducers: { + dataLoading: [ + true, + { + fetchExternalSourceSuccess: () => false, + }, + ], + buttonLoading: [ + false, + { + saveExternalConnectorConfigSuccess: () => false, + saveExternalConnectorConfig: () => true, + }, + ], + externalConnectorUrl: [ + '', + { + fetchExternalSourceSuccess: (_, { configuredFields: { url } }) => url || '', + setExternalConnectorUrl: (_, url) => url, + }, + ], + externalConnectorApiKey: [ + '', + { + fetchExternalSourceSuccess: (_, { configuredFields: { apiKey } }) => apiKey || '', + setExternalConnectorApiKey: (_, apiKey) => apiKey, + }, + ], + sourceConfigData: [ + { name: '', categories: [] }, + { + fetchExternalSourceSuccess: (_, sourceConfigData) => sourceConfigData, + }, + ], + }, + listeners: ({ actions }) => ({ + fetchExternalSource: async () => { + const route = '/internal/workplace_search/org/settings/connectors/external'; + + try { + const response = await HttpLogic.values.http.get(route); + actions.fetchExternalSourceSuccess(response); + } catch (e) { + flashAPIErrors(e); + } + }, + saveExternalConnectorConfig: async () => { + clearFlashMessages(); + // const route = '/internal/workplace_search/org/settings/connectors'; + // const http = HttpLogic.values.http.post; + // const params = { + // url, + // api_key: apiKey, + // service_type: 'external', + // }; + try { + // const response = await http(route, { + // body: JSON.stringify(params), + // }); + + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.externalConnectorCreated', + { + defaultMessage: 'Successfully updated configuration.', + } + ) + ); + // TODO: use response data instead + actions.saveExternalConnectorConfigSuccess('external'); + KibanaLogic.values.navigateToUrl( + getSourcesPath(`${getAddPath('external')}`, AppLogic.values.isOrganization) + ); + } catch (e) { + // flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx index b62648348ed8..c0e72d3b7a5a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx @@ -61,7 +61,8 @@ export const GitHubViaApp: React.FC = ({ isGithubEnterpriseSe const { hasPlatinumLicense } = useValues(LicensingLogic); const name = isGithubEnterpriseServer ? SOURCE_NAMES.GITHUB_ENTERPRISE : SOURCE_NAMES.GITHUB; - const data = staticSourceData.find((source) => source.name === name); + const serviceType = isGithubEnterpriseServer ? 'github_enterprise_server' : 'github'; + const data = staticSourceData.find((source) => source.serviceType === serviceType); const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; const handleSubmit = (e: FormEvent) => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx index 4715c50e4233..c05110bd4e6a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx @@ -11,40 +11,45 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiLink, EuiPanel, EuiTitle } from '@elastic/eui'; +import { EuiPanel, EuiTitle } from '@elastic/eui'; import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; import { LicenseBadge } from '../../../../components/shared/license_badge'; +import { staticCustomSourceData } from '../../source_data'; import { SaveCustom } from './save_custom'; describe('SaveCustom', () => { - const props = { - documentationUrl: 'http://string.boolean', + const mockValues = { newCustomSource: { - accessToken: 'dsgfsd', - key: 'sdfs', - name: 'source', - id: '12e1', + id: 'id', + accessToken: 'token', + name: 'name', }, + sourceData: staticCustomSourceData, isOrganization: true, - header:

Header

, + hasPlatinumLicense: true, }; + + beforeEach(() => { + setMockValues(mockValues); + }); + it('renders', () => { - setMockValues({ hasPlatinumLicense: true }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiPanel)).toHaveLength(1); expect(wrapper.find(EuiTitle)).toHaveLength(4); expect(wrapper.find(EuiLinkTo)).toHaveLength(1); + expect(wrapper.find(LicenseBadge)).toHaveLength(0); }); - - it('renders platinum LicenseBadge and link', () => { - setMockValues({ hasPlatinumLicense: false }); - const wrapper = shallow(); + it('renders platinum license badge if license is not present', () => { + setMockValues({ ...mockValues, hasPlatinumLicense: false }); + const wrapper = shallow(); expect(wrapper.find(LicenseBadge)).toHaveLength(1); - expect(wrapper.find(EuiLink)).toHaveLength(1); + expect(wrapper.find(EuiTitle)).toHaveLength(4); + expect(wrapper.find(EuiLinkTo)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx index c136f22d91d3..14d088f377f5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx @@ -20,6 +20,7 @@ import { EuiTitle, EuiLink, EuiPanel, + EuiCode, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -27,6 +28,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../../shared/doc_links'; import { LicensingLogic } from '../../../../../shared/licensing'; import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { AppLogic } from '../../../../app_logic'; import { LicenseBadge } from '../../../../components/shared/license_badge'; import { SOURCES_PATH, @@ -34,11 +36,12 @@ import { getContentSourcePath, getSourcesPath, } from '../../../../routes'; -import { CustomSource } from '../../../../types'; import { LEARN_CUSTOM_FEATURES_BUTTON } from '../../constants'; import { SourceIdentifier } from '../source_identifier'; +import { AddCustomSourceLogic } from './add_custom_source_logic'; +import { AddSourceHeader } from './add_source_header'; import { SAVE_CUSTOM_BODY1, SAVE_CUSTOM_BODY2, @@ -51,23 +54,20 @@ import { SAVE_CUSTOM_DOC_PERMISSIONS_LINK, } from './constants'; -interface SaveCustomProps { - documentationUrl: string; - newCustomSource: CustomSource; - isOrganization: boolean; - header: React.ReactNode; -} - -export const SaveCustom: React.FC = ({ - documentationUrl, - newCustomSource: { id, name }, - isOrganization, - header, -}) => { +export const SaveCustom: React.FC = () => { + const { newCustomSource, sourceData } = useValues(AddCustomSourceLogic); + const { isOrganization } = useValues(AppLogic); const { hasPlatinumLicense } = useValues(LicensingLogic); + const { + serviceType, + configuration: { githubRepository, documentationUrl }, + name, + categories = [], + } = sourceData; + return ( <> - {header} + @@ -84,7 +84,7 @@ export const SaveCustom: React.FC = ({ 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.heading', { defaultMessage: '{name} Created', - values: { name }, + values: { name: newCustomSource.name }, } )} @@ -93,7 +93,22 @@ export const SaveCustom: React.FC = ({ {SAVE_CUSTOM_BODY1} -
+ + {serviceType !== 'custom' && githubRepository && ( + <> + +
+ + + {githubRepository} + + + + + )} {SAVE_CUSTOM_BODY2}
@@ -105,7 +120,7 @@ export const SaveCustom: React.FC = ({
- + @@ -119,17 +134,32 @@ export const SaveCustom: React.FC = ({

- - {SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK} - - ), - }} - /> + {serviceType === 'custom' ? ( + + {SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK} + + ), + }} + /> + ) : ( + + {SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK} + + ), + name, + }} + /> + )}

@@ -149,7 +179,7 @@ export const SaveCustom: React.FC = ({ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.test.tsx index c779d76af5e7..05218c837a11 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.test.tsx @@ -13,18 +13,20 @@ import { EuiBadge, EuiHealth, EuiText, EuiTitle } from '@elastic/eui'; import { SourceIcon } from '../../../components/shared/source_icon'; +import { ContentSourceFullData } from '../../../types'; + import { SourceInfoCard } from './source_info_card'; describe('SourceInfoCard', () => { - const props = { - sourceName: 'source', - sourceType: 'custom', - dateCreated: '2021-01-20', + const contentSource = { + name: 'source', + serviceType: 'custom', + createdAt: '2021-01-20', isFederatedSource: true, - }; + } as ContentSourceFullData; it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(SourceIcon)).toHaveLength(1); expect(wrapper.find(EuiBadge)).toHaveLength(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx index e2c9cc05b04c..b24f1edc3cbd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx @@ -7,6 +7,8 @@ import React from 'react'; +import moment from 'moment'; + import { EuiBadge, EuiFlexGroup, @@ -18,30 +20,25 @@ import { } from '@elastic/eui'; import { SourceIcon } from '../../../components/shared/source_icon'; +import { ContentSourceFullData } from '../../../types'; import { REMOTE_SOURCE_LABEL, CREATED_LABEL, STATUS_LABEL, READY_TEXT } from '../constants'; interface SourceInfoCardProps { - sourceName: string; - sourceType: string; - dateCreated: string; - isFederatedSource: boolean; + contentSource: ContentSourceFullData; } export const SourceInfoCard: React.FC = ({ - sourceName, - sourceType, - dateCreated, - isFederatedSource, + contentSource: { createdAt, name, serviceType, isFederatedSource, mainIcon }, }) => ( - + -

{sourceName}

+

{name}

@@ -60,7 +57,7 @@ export const SourceInfoCard: React.FC = ({ {CREATED_LABEL} - {dateCreated} + {moment(createdAt).format('MMMM D, YYYY')} {isFederatedSource && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx index 62d1bff27dd7..fbd291d08778 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx @@ -46,10 +46,10 @@ describe('SourceLayout', () => { expect(wrapper.find('.testChild')).toHaveLength(1); }); - it('passes a source name to SourceInfoCard', () => { + it('passes a content source to SourceInfoCard', () => { const wrapper = shallow(); - expect(wrapper.find(SourceInfoCard).prop('sourceName')).toEqual('Jira'); + expect(wrapper.find(SourceInfoCard).prop('contentSource')).toEqual(contentSource); }); it('renders the default Workplace Search layout when on an organization view', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx index 727e171d1073..ffbcd67a63f4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { useValues } from 'kea'; -import moment from 'moment'; import { EuiButton, EuiCallOut, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; @@ -37,16 +36,11 @@ export const SourceLayout: React.FC = ({ const { contentSource, dataLoading, diagnosticDownloadButtonVisible } = useValues(SourceLogic); const { isOrganization } = useValues(AppLogic); - const { name, createdAt, serviceType, isFederatedSource, supportedByLicense } = contentSource; + const { name, supportedByLicense } = contentSource; const pageHeader = ( <> - + ); 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 484a9ca14b4e..d57dc4968327 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 @@ -41,7 +41,7 @@ import { SAVE_CHANGES_BUTTON, REMOVE_BUTTON, } from '../../../constants'; -import { SourceDataItem } from '../../../types'; +import { getEditPath } from '../../../routes'; import { handlePrivateKeyUpload } from '../../../utils'; import { AddSourceLogic } from '../components/add_source/add_source_logic'; import { @@ -57,7 +57,6 @@ import { SYNC_DIAGNOSTICS_DESCRIPTION, SYNC_DIAGNOSTICS_BUTTON, } from '../constants'; -import { staticSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; import { DownloadDiagnosticsButton } from './download_diagnostics_button'; @@ -96,8 +95,7 @@ export const SourceSettings: React.FC = () => { const editPath = isGithubApp ? undefined // undefined for GitHub apps, as they are configured source-wide, and don't use a connector where you can edit the configuration - : (staticSourceData.find((source) => source.serviceType === serviceType) as SourceDataItem) - .editPath; + : getEditPath(serviceType); const [inputValue, setValue] = useState(name); const [confirmModalVisible, setModalVisibility] = useState(false); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts index 547ff1e84973..54cfa2163ef1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts @@ -254,7 +254,7 @@ export const SynchronizationLogic = kea< return schedule; }, - addBlockedWindow: (state, _) => { + addBlockedWindow: (state) => { const schedule = cloneDeep(state); const blockedWindows = schedule.blockedWindows || []; blockedWindows.push(emptyBlockedWindow); 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 20a0673709b5..f99af4183641 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 @@ -10,52 +10,13 @@ import { i18n } from '@kbn/i18n'; import { docLinks } from '../../../shared/doc_links'; import { SOURCE_NAMES, SOURCE_OBJ_TYPES, GITHUB_LINK_TITLE } from '../../constants'; -import { - ADD_BOX_PATH, - ADD_CONFLUENCE_PATH, - ADD_CONFLUENCE_SERVER_PATH, - ADD_DROPBOX_PATH, - ADD_GITHUB_ENTERPRISE_PATH, - ADD_GITHUB_PATH, - ADD_GMAIL_PATH, - ADD_GOOGLE_DRIVE_PATH, - ADD_JIRA_PATH, - ADD_JIRA_SERVER_PATH, - ADD_ONEDRIVE_PATH, - ADD_SALESFORCE_PATH, - ADD_SALESFORCE_SANDBOX_PATH, - ADD_SERVICENOW_PATH, - ADD_SHAREPOINT_PATH, - ADD_SLACK_PATH, - ADD_ZENDESK_PATH, - ADD_CUSTOM_PATH, - EDIT_BOX_PATH, - EDIT_CONFLUENCE_PATH, - EDIT_CONFLUENCE_SERVER_PATH, - EDIT_DROPBOX_PATH, - EDIT_GITHUB_ENTERPRISE_PATH, - EDIT_GITHUB_PATH, - EDIT_GMAIL_PATH, - EDIT_GOOGLE_DRIVE_PATH, - EDIT_JIRA_PATH, - EDIT_JIRA_SERVER_PATH, - EDIT_ONEDRIVE_PATH, - EDIT_SALESFORCE_PATH, - EDIT_SALESFORCE_SANDBOX_PATH, - EDIT_SERVICENOW_PATH, - EDIT_SHAREPOINT_PATH, - EDIT_SLACK_PATH, - EDIT_ZENDESK_PATH, - EDIT_CUSTOM_PATH, -} from '../../routes'; import { FeatureIds, SourceDataItem } from '../../types'; -export const staticSourceData = [ +export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.BOX, + iconName: SOURCE_NAMES.BOX, serviceType: 'box', - addPath: ADD_BOX_PATH, - editPath: EDIT_BOX_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -79,12 +40,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE, + iconName: SOURCE_NAMES.CONFLUENCE, serviceType: 'confluence_cloud', - addPath: ADD_CONFLUENCE_PATH, - editPath: EDIT_CONFLUENCE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -113,12 +74,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE_SERVER, + iconName: SOURCE_NAMES.CONFLUENCE_SERVER, serviceType: 'confluence_server', - addPath: ADD_CONFLUENCE_SERVER_PATH, - editPath: EDIT_CONFLUENCE_SERVER_PATH, configuration: { isPublicKey: true, hasOauthRedirect: true, @@ -145,12 +106,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.DROPBOX, + iconName: SOURCE_NAMES.DROPBOX, serviceType: 'dropbox', - addPath: ADD_DROPBOX_PATH, - editPath: EDIT_DROPBOX_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -174,12 +135,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB, + iconName: SOURCE_NAMES.GITHUB, serviceType: 'github', - addPath: ADD_GITHUB_PATH, - editPath: EDIT_GITHUB_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -210,12 +171,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB_ENTERPRISE, + iconName: SOURCE_NAMES.GITHUB_ENTERPRISE, serviceType: 'github_enterprise_server', - addPath: ADD_GITHUB_ENTERPRISE_PATH, - editPath: EDIT_GITHUB_ENTERPRISE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -252,12 +213,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GMAIL, + iconName: SOURCE_NAMES.GMAIL, serviceType: 'gmail', - addPath: ADD_GMAIL_PATH, - editPath: EDIT_GMAIL_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -273,9 +234,8 @@ export const staticSourceData = [ }, { name: SOURCE_NAMES.GOOGLE_DRIVE, + iconName: SOURCE_NAMES.GOOGLE_DRIVE, serviceType: 'google_drive', - addPath: ADD_GOOGLE_DRIVE_PATH, - editPath: EDIT_GOOGLE_DRIVE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -303,12 +263,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA, + iconName: SOURCE_NAMES.JIRA, serviceType: 'jira_cloud', - addPath: ADD_JIRA_PATH, - editPath: EDIT_JIRA_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -339,12 +299,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA_SERVER, + iconName: SOURCE_NAMES.JIRA_SERVER, serviceType: 'jira_server', - addPath: ADD_JIRA_SERVER_PATH, - editPath: EDIT_JIRA_SERVER_PATH, configuration: { isPublicKey: true, hasOauthRedirect: true, @@ -374,12 +334,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.ONEDRIVE, + iconName: SOURCE_NAMES.ONEDRIVE, serviceType: 'one_drive', - addPath: ADD_ONEDRIVE_PATH, - editPath: EDIT_ONEDRIVE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -403,12 +363,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SALESFORCE, + iconName: SOURCE_NAMES.SALESFORCE, serviceType: 'salesforce', - addPath: ADD_SALESFORCE_PATH, - editPath: EDIT_SALESFORCE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -439,12 +399,13 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, + { name: SOURCE_NAMES.SALESFORCE_SANDBOX, + iconName: SOURCE_NAMES.SALESFORCE_SANDBOX, serviceType: 'salesforce_sandbox', - addPath: ADD_SALESFORCE_SANDBOX_PATH, - editPath: EDIT_SALESFORCE_SANDBOX_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -475,12 +436,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SERVICENOW, + iconName: SOURCE_NAMES.SERVICENOW, serviceType: 'service_now', - addPath: ADD_SERVICENOW_PATH, - editPath: EDIT_SERVICENOW_PATH, configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -508,12 +469,44 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SHAREPOINT, + iconName: SOURCE_NAMES.SHAREPOINT, serviceType: 'share_point', - addPath: ADD_SHAREPOINT_PATH, - editPath: EDIT_SHAREPOINT_PATH, + configuration: { + isPublicKey: false, + hasOauthRedirect: true, + needsBaseUrl: false, + documentationUrl: docLinks.workplaceSearchSharePoint, + applicationPortalUrl: 'https://portal.azure.com/', + }, + objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, 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, + internalConnectorAvailable: true, + externalConnectorAvailable: true, + }, + // TODO: temporary hack until backend sends us stuff + { + name: SOURCE_NAMES.SHAREPOINT, + iconName: SOURCE_NAMES.SHAREPOINT, + serviceType: 'external', configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -537,12 +530,54 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, + externalConnectorAvailable: false, + customConnectorAvailable: false, + }, + { + name: SOURCE_NAMES.SHAREPOINT_SERVER, + iconName: SOURCE_NAMES.SHAREPOINT_SERVER, + categories: [ + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.fileSharing', { + defaultMessage: 'File Sharing', + }), + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.storage', { + defaultMessage: 'Storage', + }), + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.cloud', { + defaultMessage: 'Cloud', + }), + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.microsoft', { + defaultMessage: 'Microsoft', + }), + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.office', { + defaultMessage: 'Office 365', + }), + ], + serviceType: 'share_point_server', // this doesn't exist on the BE + configuration: { + isPublicKey: false, + hasOauthRedirect: false, + needsBaseUrl: false, + // helpText: i18n.translate( // TODO updatae this + // 'xpack.enterpriseSearch.workplaceSearch.sources.helpText.sharepointServer', + // { + // defaultMessage: + // "Here is some help text. It should probably give the user a heads up that they're going to have to deploy some code.", + // } + // ), + documentationUrl: docLinks.workplaceSearchCustomSources, // TODO update this + applicationPortalUrl: '', + githubRepository: 'elastic/enterprise-search-sharepoint-server-connector', + }, + accountContextOnly: false, + internalConnectorAvailable: false, + customConnectorAvailable: true, }, { name: SOURCE_NAMES.SLACK, + iconName: SOURCE_NAMES.SLACK, serviceType: 'slack', - addPath: ADD_SLACK_PATH, - editPath: EDIT_SLACK_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -559,12 +594,13 @@ export const staticSourceData = [ platinumPrivateContext: [FeatureIds.Remote, FeatureIds.Private, FeatureIds.SearchableContent], }, accountContextOnly: true, + internalConnectorAvailable: true, }, + { name: SOURCE_NAMES.ZENDESK, + iconName: SOURCE_NAMES.ZENDESK, serviceType: 'zendesk', - addPath: ADD_ZENDESK_PATH, - editPath: EDIT_ZENDESK_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -588,23 +624,26 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, - { - name: SOURCE_NAMES.CUSTOM, - serviceType: 'custom', - addPath: ADD_CUSTOM_PATH, - editPath: EDIT_CUSTOM_PATH, - configuration: { - isPublicKey: false, - hasOauthRedirect: false, - needsBaseUrl: false, - helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', { - defaultMessage: - 'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.', - }), - documentationUrl: docLinks.workplaceSearchCustomSources, - applicationPortalUrl: '', - }, - accountContextOnly: false, +]; + +export const staticCustomSourceData: SourceDataItem = { + name: SOURCE_NAMES.CUSTOM, + iconName: SOURCE_NAMES.CUSTOM, + categories: ['API', 'Custom'], + serviceType: 'custom', + configuration: { + isPublicKey: false, + hasOauthRedirect: false, + needsBaseUrl: false, + helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', { + defaultMessage: + 'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.', + }), + documentationUrl: docLinks.workplaceSearchCustomSources, + applicationPortalUrl: '', }, -] as SourceDataItem[]; + accountContextOnly: false, + customConnectorAvailable: true, +}; 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 f7e41f651201..a007d31ff67c 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 @@ -18,6 +18,7 @@ jest.mock('../../app_logic', () => ({ import { itShowsServerErrorAsFlashMessage } from '../../../test_helpers'; import { AppLogic } from '../../app_logic'; +import { staticSourceData } from './source_data'; import { SourcesLogic, fetchSourceStatuses, POLLING_INTERVAL } from './sources_logic'; describe('SourcesLogic', () => { @@ -32,8 +33,8 @@ describe('SourcesLogic', () => { const defaultValues = { contentSources: [], privateContentSources: [], - sourceData: [], - availableSources: [], + sourceData: staticSourceData.map((data) => ({ ...data, connected: false })), + availableSources: staticSourceData.map((data) => ({ ...data, connected: false })), configuredSources: [], serviceTypes: [], permissionsModal: null, @@ -316,7 +317,7 @@ describe('SourcesLogic', () => { it('availableSources & configuredSources have correct length', () => { SourcesLogic.actions.onInitializeSources(serverResponse); - expect(SourcesLogic.values.availableSources).toHaveLength(1); + expect(SourcesLogic.values.availableSources).toHaveLength(14); expect(SourcesLogic.values.configuredSources).toHaveLength(5); }); }); 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 90b1f83281e9..b7bdef52fceb 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 @@ -178,7 +178,7 @@ export const SourcesLogic = kea>( if (isOrganization && !values.serverStatuses) { // We want to get the initial statuses from the server to compare our polling results to. const sourceStatuses = await fetchSourceStatuses(isOrganization, breakpoint); - actions.setServerSourceStatuses(sourceStatuses); + actions.setServerSourceStatuses(sourceStatuses ?? []); } }, // We poll the server and if the status update, we trigger a new fetch of the sources. @@ -190,7 +190,7 @@ export const SourcesLogic = kea>( pollingInterval = window.setInterval(async () => { const sourceStatuses = await fetchSourceStatuses(isOrganization, breakpoint); - sourceStatuses.some((source: ContentSourceStatus) => { + (sourceStatuses ?? []).some((source: ContentSourceStatus) => { if (serverStatuses && serverStatuses[source.id] !== source.status.status) { return actions.initializeSources(); } @@ -249,7 +249,7 @@ export const SourcesLogic = kea>( export const fetchSourceStatuses = async ( isOrganization: boolean, breakpoint: BreakPointFunction -) => { +): Promise => { const route = isOrganization ? '/internal/workplace_search/org/sources/status' : '/internal/workplace_search/account/sources/status'; @@ -267,8 +267,7 @@ export const fetchSourceStatuses = async ( } } - // TODO: remove casting. return type should be ContentSourceStatus[] | undefined - return response as ContentSourceStatus[]; + return response; }; const updateSourcesOnToggle = ( @@ -293,7 +292,7 @@ const updateSourcesOnToggle = ( * The second is the base list of available sources that the server sends back in the collection, * `availableTypes` that is the source of truth for the name and whether the source has been configured. * - * Fnally, also in the collection response is the current set of connected sources. We check for the + * Finally, also in the collection response is the current set of connected sources. We check for the * existence of a `connectedSource` of the type in the loop and set `connected` to true so that the UI * can diplay "Add New" instead of "Connect", the latter of which is displated only when a connector * has been configured but there are no connected sources yet. @@ -304,13 +303,13 @@ export const mergeServerAndStaticData = ( contentSources: ContentSourceDetails[] ) => { const combined = [] as CombinedDataItem[]; - serverData.forEach((serverItem) => { - const type = serverItem.serviceType; - const staticItem = staticData.find(({ serviceType }) => serviceType === type); + staticData.forEach((staticItem) => { + const type = staticItem.serviceType; + const serverItem = serverData.find(({ serviceType }) => serviceType === type); const connectedSource = contentSources.find(({ serviceType }) => serviceType === type); combined.push({ - ...serverItem, ...staticItem, + ...serverItem, connected: !!connectedSource, } as CombinedDataItem); }); 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 cf5dc48682ae..49c8ebbbebc0 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 @@ -34,7 +34,7 @@ describe('SourcesRouter', () => { }); it('renders sources routes', () => { - const TOTAL_ROUTES = 63; + const TOTAL_ROUTES = 86; const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); @@ -45,8 +45,8 @@ describe('SourcesRouter', () => { setMockValues({ ...mockValues, hasPlatinumLicense: false }); const wrapper = shallow(); - expect(wrapper.find(Redirect).first().prop('from')).toEqual(ADD_SOURCE_PATH); - expect(wrapper.find(Redirect).first().prop('to')).toEqual(SOURCES_PATH); + expect(wrapper.find(Redirect).last().prop('from')).toEqual(ADD_SOURCE_PATH); + expect(wrapper.find(Redirect).last().prop('to')).toEqual(SOURCES_PATH); }); 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 23109506b364..e735119f687c 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 @@ -14,19 +14,27 @@ import { useActions, useValues } from 'kea'; import { LicensingLogic } from '../../../shared/licensing'; import { AppLogic } from '../../app_logic'; import { - ADD_GITHUB_VIA_APP_PATH, - ADD_GITHUB_ENTERPRISE_SERVER_VIA_APP_PATH, + GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE, + GITHUB_VIA_APP_SERVICE_TYPE, +} from '../../constants'; +import { ADD_SOURCE_PATH, SOURCE_DETAILS_PATH, PRIVATE_SOURCES_PATH, 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 { ConfigurationChoice } from './components/add_source/configuration_choice'; +import { ExternalConnectorConfig } from './components/add_source/external_connector_config'; import { OrganizationSources } from './organization_sources'; import { PrivateSources } from './private_sources'; -import { staticSourceData } from './source_data'; +import { staticCustomSourceData, staticSourceData as sources } from './source_data'; import { SourceRouter } from './source_router'; import { SourcesLogic } from './sources_logic'; @@ -68,36 +76,122 @@ export const SourcesRouter: React.FC = () => { - + - + - {staticSourceData.map(({ addPath, accountContextOnly }, i) => ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ))} - {staticSourceData.map(({ addPath }, i) => ( - - + {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) => ( + + ))} - {staticSourceData.map(({ addPath }, i) => ( - - + {sources.map((sourceData, i) => ( + + ))} - {staticSourceData.map(({ addPath, configuration: { needsConfiguration } }, i) => { - if (needsConfiguration) + {sources.map((sourceData, i) => { + if (sourceData.configuration.needsConfiguration) return ( - - + + ); })} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx index 85f91f769cc7..be139fd6b38e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx @@ -33,9 +33,7 @@ import { PRIVATE_SOURCE, UPDATE_BUTTON, } from '../../../constants'; -import { getSourcesPath } from '../../../routes'; -import { SourceDataItem } from '../../../types'; -import { staticSourceData } from '../../content_sources/source_data'; +import { getAddPath, getEditPath, getSourcesPath } from '../../../routes'; import { SettingsLogic } from '../settings_logic'; export const Connectors: React.FC = () => { @@ -52,9 +50,9 @@ export const Connectors: React.FC = () => { ); const getRowActions = (configured: boolean, serviceType: string, supportedByLicense: boolean) => { - const { addPath, editPath } = staticSourceData.find( - (s) => s.serviceType === serviceType - ) as SourceDataItem; + const addPath = getAddPath(serviceType); + const editPath = getEditPath(serviceType); + const configurePath = getSourcesPath(addPath, true); const updateButtons = ( 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 35619d2b2d56..af8b8fe461f1 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 @@ -18,6 +18,8 @@ import { 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', () => { @@ -31,7 +33,7 @@ describe('SourceConfig', () => { }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -42,13 +44,13 @@ describe('SourceConfig', () => { 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 @@ -60,7 +62,7 @@ describe('SourceConfig', () => { }); it('saves source config', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -72,7 +74,7 @@ describe('SourceConfig', () => { }); it('cancels and closes modal', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility 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 c2a0b60e1eca..ea63f3bab77d 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 @@ -18,16 +18,15 @@ 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 { staticSourceData } from '../../content_sources/source_data'; import { SettingsLogic } from '../settings_logic'; interface SourceConfigProps { - sourceIndex: number; + sourceData: SourceDataItem; } -export const SourceConfig: React.FC = ({ sourceIndex }) => { +export const SourceConfig: React.FC = ({ sourceData }) => { const [confirmModalVisible, setConfirmModalVisibility] = useState(false); - const { configuration, serviceType } = staticSourceData[sourceIndex] as SourceDataItem; + const { configuration, serviceType } = sourceData; const { deleteSourceConfig } = useActions(SettingsLogic); const { saveSourceConfig, getSourceConfigData } = useActions(AddSourceLogic); const { 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 d9aeba361d24..7c5e501d6a2a 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 @@ -14,6 +14,7 @@ import { ORG_SETTINGS_CUSTOMIZE_PATH, ORG_SETTINGS_CONNECTORS_PATH, ORG_SETTINGS_OAUTH_APPLICATION_PATH, + getEditPath, } from '../../routes'; import { staticSourceData } from '../content_sources/source_data'; @@ -41,9 +42,9 @@ export const SettingsRouter: React.FC = () => { - {staticSourceData.map(({ editPath }, i) => ( - - + {staticSourceData.map((sourceData, i) => ( + + ))} diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/sharepoint_server.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/sharepoint_server.svg new file mode 100644 index 000000000000..aebfd7a8e49c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/assets/source_icons/sharepoint_server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 1cc96be1b40f..5b193d3e8096 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -26,7 +26,7 @@ import { SecurityPluginSetup, SecurityPluginStart } from '../../security/public' import { APP_SEARCH_PLUGIN, - ENTERPRISE_SEARCH_PLUGIN, + ENTERPRISE_SEARCH_OVERVIEW_PLUGIN, WORKPLACE_SEARCH_PLUGIN, } from '../common/constants'; import { InitialAppData } from '../common/types'; @@ -67,30 +67,32 @@ export class EnterpriseSearchPlugin implements Plugin { const { cloud } = plugins; core.application.register({ - id: ENTERPRISE_SEARCH_PLUGIN.ID, - title: ENTERPRISE_SEARCH_PLUGIN.NAV_TITLE, - euiIconType: ENTERPRISE_SEARCH_PLUGIN.LOGO, - appRoute: ENTERPRISE_SEARCH_PLUGIN.URL, + id: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.ID, + title: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.NAV_TITLE, + euiIconType: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.LOGO, + appRoute: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.URL, category: DEFAULT_APP_CATEGORIES.enterpriseSearch, mount: async (params: AppMountParameters) => { const kibanaDeps = await this.getKibanaDeps(core, params, cloud); const { chrome, http } = kibanaDeps.core; - chrome.docTitle.change(ENTERPRISE_SEARCH_PLUGIN.NAME); + chrome.docTitle.change(ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.NAME); await this.getInitialData(http); const pluginData = this.getPluginData(); const { renderApp } = await import('./applications'); - const { EnterpriseSearch } = await import('./applications/enterprise_search'); + const { EnterpriseSearchOverview } = await import( + './applications/enterprise_search_overview' + ); - return renderApp(EnterpriseSearch, kibanaDeps, pluginData); + return renderApp(EnterpriseSearchOverview, kibanaDeps, pluginData); }, }); core.application.register({ id: APP_SEARCH_PLUGIN.ID, title: APP_SEARCH_PLUGIN.NAME, - euiIconType: ENTERPRISE_SEARCH_PLUGIN.LOGO, + euiIconType: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.LOGO, appRoute: APP_SEARCH_PLUGIN.URL, category: DEFAULT_APP_CATEGORIES.enterpriseSearch, mount: async (params: AppMountParameters) => { @@ -111,7 +113,7 @@ export class EnterpriseSearchPlugin implements Plugin { core.application.register({ id: WORKPLACE_SEARCH_PLUGIN.ID, title: WORKPLACE_SEARCH_PLUGIN.NAME, - euiIconType: ENTERPRISE_SEARCH_PLUGIN.LOGO, + euiIconType: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.LOGO, appRoute: WORKPLACE_SEARCH_PLUGIN.URL, category: DEFAULT_APP_CATEGORIES.enterpriseSearch, mount: async (params: AppMountParameters) => { @@ -134,11 +136,11 @@ export class EnterpriseSearchPlugin implements Plugin { if (plugins.home) { plugins.home.featureCatalogue.registerSolution({ - id: ENTERPRISE_SEARCH_PLUGIN.ID, - title: ENTERPRISE_SEARCH_PLUGIN.NAME, + id: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.ID, + title: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.NAME, icon: 'logoEnterpriseSearch', - description: ENTERPRISE_SEARCH_PLUGIN.DESCRIPTION, - path: ENTERPRISE_SEARCH_PLUGIN.URL, + description: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.DESCRIPTION, + path: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.URL, order: 100, }); diff --git a/x-pack/plugins/enterprise_search/server/integrations.ts b/x-pack/plugins/enterprise_search/server/integrations.ts index fe8e584b65be..ebe98d4e805a 100644 --- a/x-pack/plugins/enterprise_search/server/integrations.ts +++ b/x-pack/plugins/enterprise_search/server/integrations.ts @@ -235,6 +235,24 @@ const workplaceSearchIntegrations: WorkplaceSearchIntegration[] = [ categories: ['file_storage'], uiInternalPath: '/app/enterprise_search/workplace_search/sources/add/share_point', }, + { + id: 'sharepoint_server', + title: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.integrations.sharepointServerName', + { + defaultMessage: 'SharePoint Server', + } + ), + description: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.integrations.sharepointServerDescription', + { + defaultMessage: + 'Search over your files stored on Microsoft SharePoint Server with Workplace Search.', + } + ), + categories: ['enterprise_search', 'file_storage', 'microsoft_365'], + uiInternalPath: '/app/enterprise_search/workplace_search/sources/add/sharepoint_server', + }, { id: 'slack', title: i18n.translate('xpack.enterpriseSearch.workplaceSearch.integrations.slackName', { diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 29a744e487f4..4ed6e74dccd9 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -23,10 +23,11 @@ import { SecurityPluginSetup } from '../../security/server'; import { SpacesPluginStart } from '../../spaces/server'; import { - ENTERPRISE_SEARCH_PLUGIN, + ENTERPRISE_SEARCH_OVERVIEW_PLUGIN, APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN, - LOGS_SOURCE_ID, + ENTERPRISE_SEARCH_RELEVANCE_LOGS_SOURCE_ID, + ENTERPRISE_SEARCH_AUDIT_LOGS_SOURCE_ID, } from '../common/constants'; import { registerTelemetryUsageCollector as registerASTelemetryUsageCollector } from './collectors/app_search/telemetry'; @@ -101,17 +102,21 @@ export class EnterpriseSearchPlugin implements Plugin { * Register space/feature control */ features.registerKibanaFeature({ - id: ENTERPRISE_SEARCH_PLUGIN.ID, - name: ENTERPRISE_SEARCH_PLUGIN.NAME, + id: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.ID, + name: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.NAME, order: 0, category: DEFAULT_APP_CATEGORIES.enterpriseSearch, app: [ 'kibana', - ENTERPRISE_SEARCH_PLUGIN.ID, + ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.ID, + APP_SEARCH_PLUGIN.ID, + WORKPLACE_SEARCH_PLUGIN.ID, + ], + catalogue: [ + ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.ID, APP_SEARCH_PLUGIN.ID, WORKPLACE_SEARCH_PLUGIN.ID, ], - catalogue: [ENTERPRISE_SEARCH_PLUGIN.ID, APP_SEARCH_PLUGIN.ID, WORKPLACE_SEARCH_PLUGIN.ID], privileges: null, }); @@ -174,11 +179,19 @@ export class EnterpriseSearchPlugin implements Plugin { * Register logs source configuration, used by LogStream components * @see https://github.com/elastic/kibana/blob/main/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx#with-a-source-configuration */ - infra.defineInternalSourceConfiguration(LOGS_SOURCE_ID, { - name: 'Enterprise Search Logs', + infra.defineInternalSourceConfiguration(ENTERPRISE_SEARCH_RELEVANCE_LOGS_SOURCE_ID, { + name: 'Enterprise Search Search Relevance Logs', + logIndices: { + type: 'index_name', + indexName: 'logs-app_search.search_relevance_suggestions-*', + }, + }); + + infra.defineInternalSourceConfiguration(ENTERPRISE_SEARCH_AUDIT_LOGS_SOURCE_ID, { + name: 'Enterprise Search Audit Logs', logIndices: { type: 'index_name', - indexName: '.ent-search-*', + indexName: 'logs-enterprise_search*', }, }); } diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index 222288d369fd..d10630212820 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -38,6 +38,14 @@ const oauthConfigSchema = schema.object({ consumer_key: schema.maybe(schema.string()), }); +const externalConnectorSchema = schema.object({ + url: schema.string(), + api_key: schema.string(), + service_type: schema.string(), +}); + +const postConnectorSchema = schema.oneOf([externalConnectorSchema, oauthConfigSchema]); + const displayFieldSchema = schema.object({ fieldName: schema.string(), label: schema.string(), @@ -872,7 +880,7 @@ export function registerOrgSourceOauthConfigurationsRoute({ { path: '/internal/workplace_search/org/settings/connectors', validate: { - body: oauthConfigSchema, + body: postConnectorSchema, }, }, enterpriseSearchRequestHandler.createRequest({ diff --git a/x-pack/plugins/event_log/common/index.ts b/x-pack/plugins/event_log/common/index.ts index 79ecd4762871..5910dbe2c5ad 100644 --- a/x-pack/plugins/event_log/common/index.ts +++ b/x-pack/plugins/event_log/common/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export const BASE_EVENT_LOG_API_PATH = '/api/event_log'; +export const BASE_EVENT_LOG_API_PATH = '/internal/event_log'; diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.mock.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.mock.ts index 667512ea13f6..53a1b9501b43 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.mock.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.mock.ts @@ -24,6 +24,7 @@ const createClusterClientMock = () => { getExistingIndexAliases: jest.fn(), setIndexAliasToHidden: jest.fn(), queryEventsBySavedObjects: jest.fn(), + aggregateEventsBySavedObjects: jest.fn(), shutdown: jest.fn(), }; return mock; diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts index 22898ac54db5..56a708ef51b6 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts @@ -10,10 +10,13 @@ import { ClusterClientAdapter, IClusterClientAdapter, EVENT_BUFFER_LENGTH, + getQueryBody, + FindEventsOptionsBySavedObjectFilter, + AggregateEventsOptionsBySavedObjectFilter, } from './cluster_client_adapter'; -import { findOptionsSchema } from '../event_log_client'; +import { AggregateOptionsType, queryOptionsSchema } from '../event_log_client'; import { delay } from '../lib/delay'; -import { times } from 'lodash'; +import { pick, times } from 'lodash'; import type * as estypes from '@elastic/elasticsearch/lib/api/types'; type MockedLogger = ReturnType; @@ -567,10 +570,93 @@ describe('createIndex', () => { }); describe('queryEventsBySavedObject', () => { - const DEFAULT_OPTIONS = findOptionsSchema.validate({}); + const DEFAULT_OPTIONS = queryOptionsSchema.validate({}); - test('should call cluster with proper arguments with non-default namespace', async () => { + test('should call cluster with correct options', async () => { clusterClient.search.mockResponse({ + hits: { + hits: [{ _index: 'index-name-00001', _id: '1', _source: { foo: 'bar' } }], + total: { relation: 'eq', value: 1 }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + successful: 0, + total: 0, + skipped: 0, + }, + }); + const options = { + index: 'index-name', + namespace: 'namespace', + type: 'saved-object-type', + ids: ['saved-object-id'], + findOptions: { + ...DEFAULT_OPTIONS, + page: 3, + per_page: 6, + sort: [ + { sort_field: '@timestamp', sort_order: 'asc' }, + { sort_field: 'event.end', sort_order: 'desc' }, + ], + }, + }; + const result = await clusterClientAdapter.queryEventsBySavedObjects(options); + + const [query] = clusterClient.search.mock.calls[0]; + expect(query).toEqual({ + index: 'index-name', + track_total_hits: true, + body: { + size: 6, + from: 12, + query: getQueryBody(logger, options, pick(options.findOptions, ['start', 'end', 'filter'])), + sort: [{ '@timestamp': { order: 'asc' } }, { 'event.end': { order: 'desc' } }], + }, + }); + expect(result).toEqual({ + page: 3, + per_page: 6, + total: 1, + data: [{ foo: 'bar' }], + }); + }); +}); + +describe('aggregateEventsBySavedObject', () => { + const DEFAULT_OPTIONS = { + ...queryOptionsSchema.validate({}), + aggs: { + genericAgg: { + term: { + field: 'event.action', + size: 10, + }, + }, + }, + }; + + test('should call cluster with correct options', async () => { + clusterClient.search.mockResponse({ + aggregations: { + genericAgg: { + buckets: [ + { + key: 'execute', + doc_count: 10, + }, + { + key: 'execute-start', + doc_count: 10, + }, + { + key: 'new-instance', + doc_count: 2, + }, + ], + }, + }, hits: { hits: [], total: { relation: 'eq', value: 0 }, @@ -584,85 +670,130 @@ describe('queryEventsBySavedObject', () => { skipped: 0, }, }); - await clusterClientAdapter.queryEventsBySavedObjects({ + const options: AggregateEventsOptionsBySavedObjectFilter = { index: 'index-name', namespace: 'namespace', type: 'saved-object-type', ids: ['saved-object-id'], - findOptions: DEFAULT_OPTIONS, - }); + aggregateOptions: DEFAULT_OPTIONS as AggregateOptionsType, + }; + const result = await clusterClientAdapter.aggregateEventsBySavedObjects(options); const [query] = clusterClient.search.mock.calls[0]; - expect(query).toMatchInlineSnapshot( - { - body: { - from: 0, - query: { - bool: { - filter: [], - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { + expect(query).toEqual({ + index: 'index-name', + body: { + size: 0, + query: getQueryBody( + logger, + options, + pick(options.aggregateOptions, ['start', 'end', 'filter']) + ), + aggs: { + genericAgg: { + term: { + field: 'event.action', + size: 10, + }, + }, + }, + }, + }); + expect(result).toEqual({ + aggregations: { + genericAgg: { + buckets: [ + { + key: 'execute', + doc_count: 10, + }, + { + key: 'execute-start', + doc_count: 10, + }, + { + key: 'new-instance', + doc_count: 2, + }, + ], + }, + }, + }); + }); +}); + +describe('getQueryBody', () => { + const options = { + index: 'index-name', + namespace: undefined, + type: 'saved-object-type', + ids: ['saved-object-id'], + }; + test('should correctly build query with namespace filter when namespace is undefined', () => { + expect(getQueryBody(logger, options as FindEventsOptionsBySavedObjectFilter, {})).toEqual({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, + }, + }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { bool: { - must: [ - { - term: { - 'kibana.saved_objects.rel': { - value: 'primary', - }, - }, - }, - { - term: { - 'kibana.saved_objects.type': { - value: 'saved-object-type', - }, - }, - }, - { - term: { - 'kibana.saved_objects.namespace': { - value: 'namespace', - }, - }, + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', }, - ], + }, }, }, - }, + ], }, + }, + }, + }, + { + bool: { + should: [ { bool: { - should: [ + must: [ { - bool: { - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - terms: { - 'kibana.saved_objects.id': ['saved-object-id'], - }, - }, - ], + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], }, }, - }, - }, - { - range: { - 'kibana.version': { - gte: '8.0.0', - }, - }, + ], }, - ], + }, + }, + }, + { + range: { + 'kibana.version': { + gte: '8.0.0', + }, }, }, ], @@ -671,90 +802,80 @@ describe('queryEventsBySavedObject', () => { ], }, }, - size: 10, - sort: [ - { - '@timestamp': { - order: 'asc', - }, - }, - ], - }, - index: 'index-name', - track_total_hits: true, + ], }, - ` - Object { - "body": Object { - "from": 0, - "query": Object { - "bool": Object { - "filter": Array [], - "must": Array [ - Object { - "nested": Object { - "path": "kibana.saved_objects", - "query": Object { - "bool": Object { - "must": Array [ - Object { - "term": Object { - "kibana.saved_objects.rel": Object { - "value": "primary", - }, - }, - }, - Object { - "term": Object { - "kibana.saved_objects.type": Object { - "value": "saved-object-type", - }, - }, - }, - Object { - "term": Object { - "kibana.saved_objects.namespace": Object { - "value": "namespace", - }, - }, - }, - ], + }); + }); + + test('should correctly build query with namespace filter when namespace is specified', () => { + expect( + getQueryBody( + logger, + { ...options, namespace: 'namespace' } as FindEventsOptionsBySavedObjectFilter, + {} + ) + ).toEqual({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, }, }, - }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { + term: { + 'kibana.saved_objects.namespace': { + value: 'namespace', + }, + }, + }, + ], }, - Object { - "bool": Object { - "should": Array [ - Object { - "bool": Object { - "must": Array [ - Object { - "nested": Object { - "path": "kibana.saved_objects", - "query": Object { - "bool": Object { - "must": Array [ - Object { - "terms": Object { - "kibana.saved_objects.id": Array [ - "saved-object-id", - ], - }, - }, - ], + }, + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], }, }, - }, - }, - Object { - "range": Object { - "kibana.version": Object { - "gte": "8.0.0", - }, - }, + ], }, - ], + }, + }, + }, + { + range: { + 'kibana.version': { + gte: '8.0.0', + }, }, }, ], @@ -763,117 +884,40 @@ describe('queryEventsBySavedObject', () => { ], }, }, - "size": 10, - "sort": Array [ - Object { - "@timestamp": Object { - "order": "asc", - }, - }, - ], - }, - "index": "index-name", - "track_total_hits": true, - } - ` - ); - }); - - test('should call cluster with proper arguments with default namespace', async () => { - clusterClient.search.mockResponse({ - hits: { - hits: [], - total: { relation: 'eq', value: 0 }, - }, - took: 0, - timed_out: false, - _shards: { - failed: 0, - successful: 0, - total: 0, - skipped: 0, + ], }, }); - await clusterClientAdapter.queryEventsBySavedObjects({ - index: 'index-name', - namespace: undefined, - type: 'saved-object-type', - ids: ['saved-object-id'], - findOptions: DEFAULT_OPTIONS, - }); + }); - const [query] = clusterClient.search.mock.calls[0]; - expect(query).toMatchObject({ - body: { - from: 0, - query: { + test('should correctly build query when filter is specified', () => { + expect( + getQueryBody(logger, options as FindEventsOptionsBySavedObjectFilter, { + filter: 'event.provider: alerting AND event.action:execute', + }) + ).toEqual({ + bool: { + filter: { bool: { - filter: [], - must: [ + filter: [ { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - term: { - 'kibana.saved_objects.rel': { - value: 'primary', - }, - }, - }, - { - term: { - 'kibana.saved_objects.type': { - value: 'saved-object-type', - }, - }, - }, - { - bool: { - must_not: { - exists: { - field: 'kibana.saved_objects.namespace', - }, - }, - }, - }, - ], + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'event.provider': 'alerting', + }, }, - }, + ], }, }, { bool: { + minimum_should_match: 1, should: [ { - bool: { - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - terms: { - 'kibana.saved_objects.id': ['saved-object-id'], - }, - }, - ], - }, - }, - }, - }, - { - range: { - 'kibana.version': { - gte: '8.0.0', - }, - }, - }, - ], + match: { + 'event.action': 'execute', }, }, ], @@ -882,352 +926,481 @@ describe('queryEventsBySavedObject', () => { ], }, }, - size: 10, - sort: [ + must: [ { - '@timestamp': { - order: 'asc', + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, + }, + }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { + bool: { + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', + }, + }, + }, + }, + ], + }, + }, }, }, - ], - }, - index: 'index-name', - track_total_hits: true, - }); - }); - - test('should call cluster with sort', async () => { - clusterClient.search.mockResponse({ - hits: { - hits: [], - total: { relation: 'eq', value: 0 }, - }, - took: 0, - timed_out: false, - _shards: { - failed: 0, - successful: 0, - total: 0, - skipped: 0, - }, - }); - await clusterClientAdapter.queryEventsBySavedObjects({ - index: 'index-name', - namespace: 'namespace', - type: 'saved-object-type', - ids: ['saved-object-id'], - findOptions: { ...DEFAULT_OPTIONS, sort_field: 'event.end', sort_order: 'desc' }, - }); - - const [query] = clusterClient.search.mock.calls[0]; - expect(query).toMatchObject({ - index: 'index-name', - body: { - sort: [{ 'event.end': { order: 'desc' } }], + { + bool: { + should: [ + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], + }, + }, + ], + }, + }, + }, + }, + { + range: { + 'kibana.version': { + gte: '8.0.0', + }, + }, + }, + ], + }, + }, + ], + }, + }, + ], }, }); }); - test('supports open ended date', async () => { - clusterClient.search.mockResponse({ - hits: { - hits: [], - total: { relation: 'eq', value: 0 }, - }, - took: 0, - timed_out: false, - _shards: { - failed: 0, - successful: 0, - total: 0, - skipped: 0, - }, - }); - - const start = '2020-07-08T00:52:28.350Z'; - - await clusterClientAdapter.queryEventsBySavedObjects({ - index: 'index-name', - namespace: 'namespace', - type: 'saved-object-type', - ids: ['saved-object-id'], - findOptions: { ...DEFAULT_OPTIONS, start }, - }); - - const [query] = clusterClient.search.mock.calls[0]; - expect(query).toMatchObject({ - body: { - from: 0, - query: { - bool: { - filter: [], - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - term: { - 'kibana.saved_objects.rel': { - value: 'primary', - }, + test('should correctly build query when legacyIds are specified', () => { + expect( + getQueryBody( + logger, + { ...options, legacyIds: ['legacy-id-1'] } as FindEventsOptionsBySavedObjectFilter, + {} + ) + ).toEqual({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, + }, + }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { + bool: { + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', }, }, - { - term: { - 'kibana.saved_objects.type': { - value: 'saved-object-type', + }, + }, + ], + }, + }, + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], + }, + }, + ], }, }, }, - { - term: { - 'kibana.saved_objects.namespace': { - value: 'namespace', - }, + }, + { + range: { + 'kibana.version': { + gte: '8.0.0', }, }, - ], - }, + }, + ], }, }, - }, - { - bool: { - should: [ - { - bool: { - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - terms: { - 'kibana.saved_objects.id': ['saved-object-id'], - }, - }, - ], + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['legacy-id-1'], + }, }, - }, + ], }, }, - { - range: { - 'kibana.version': { - gte: '8.0.0', + }, + }, + { + bool: { + should: [ + { + range: { + 'kibana.version': { + lt: '8.0.0', + }, + }, + }, + { + bool: { + must_not: { + exists: { + field: 'kibana.version', + }, + }, }, }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }); + }); + + test('should correctly build query when start is specified', () => { + expect( + getQueryBody(logger, options as FindEventsOptionsBySavedObjectFilter, { + start: '2020-07-08T00:52:28.350Z', + }) + ).toEqual({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, + }, + }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { + bool: { + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', }, - ], + }, }, }, ], }, }, - { - range: { - '@timestamp': { - gte: '2020-07-08T00:52:28.350Z', + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], + }, + }, + ], + }, + }, + }, + }, + { + range: { + 'kibana.version': { + gte: '8.0.0', + }, + }, + }, + ], }, }, - }, - ], + ], + }, }, - }, - size: 10, - sort: [ { - '@timestamp': { - order: 'asc', + range: { + '@timestamp': { + gte: '2020-07-08T00:52:28.350Z', + }, }, }, ], }, - index: 'index-name', - track_total_hits: true, }); }); - test('supports optional date range', async () => { - clusterClient.search.mockResponse({ - hits: { - hits: [], - total: { relation: 'eq', value: 0 }, - }, - took: 0, - timed_out: false, - _shards: { - failed: 0, - successful: 0, - total: 0, - skipped: 0, - }, - }); - - const start = '2020-07-08T00:52:28.350Z'; - const end = '2020-07-08T00:00:00.000Z'; - - await clusterClientAdapter.queryEventsBySavedObjects({ - index: 'index-name', - namespace: 'namespace', - type: 'saved-object-type', - ids: ['saved-object-id'], - findOptions: { ...DEFAULT_OPTIONS, start, end }, - legacyIds: ['legacy-id'], - }); - - const [query] = clusterClient.search.mock.calls[0]; - expect(query).toMatchObject({ - body: { - from: 0, - query: { - bool: { - filter: [], - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - term: { - 'kibana.saved_objects.rel': { - value: 'primary', - }, + test('should correctly build query when end is specified', () => { + expect( + getQueryBody(logger, options as FindEventsOptionsBySavedObjectFilter, { + end: '2020-07-10T00:52:28.350Z', + }) + ).toEqual({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, + }, + }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { + bool: { + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', }, }, - { - term: { - 'kibana.saved_objects.type': { - value: 'saved-object-type', + }, + }, + ], + }, + }, + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], + }, + }, + ], }, }, }, - { - term: { - 'kibana.saved_objects.namespace': { - value: 'namespace', - }, + }, + { + range: { + 'kibana.version': { + gte: '8.0.0', }, }, - ], - }, + }, + ], }, }, + ], + }, + }, + { + range: { + '@timestamp': { + lte: '2020-07-10T00:52:28.350Z', }, - { + }, + }, + ], + }, + }); + }); + + test('should correctly build query when start and end are specified', () => { + expect( + getQueryBody(logger, options as FindEventsOptionsBySavedObjectFilter, { + start: '2020-07-08T00:52:28.350Z', + end: '2020-07-10T00:52:28.350Z', + }) + ).toEqual({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { bool: { - should: [ + must: [ { - bool: { - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - terms: { - 'kibana.saved_objects.id': ['saved-object-id'], - }, - }, - ], - }, - }, - }, - }, - { - range: { - 'kibana.version': { - gte: '8.0.0', - }, - }, - }, - ], + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, + }, + }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, }, }, { bool: { - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - terms: { - 'kibana.saved_objects.id': ['legacy-id'], - }, - }, - ], - }, - }, - }, + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', }, - { + }, + }, + }, + ], + }, + }, + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { bool: { - should: [ + must: [ { - range: { - 'kibana.version': { - lt: '8.0.0', - }, - }, - }, - { - bool: { - must_not: { - exists: { - field: 'kibana.version', - }, - }, + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], }, }, ], }, }, - ], + }, }, - }, - ], - }, - }, - { - range: { - '@timestamp': { - gte: '2020-07-08T00:52:28.350Z', - }, - }, - }, - { - range: { - '@timestamp': { - lte: '2020-07-08T00:00:00.000Z', + { + range: { + 'kibana.version': { + gte: '8.0.0', + }, + }, + }, + ], }, }, + ], + }, + }, + { + range: { + '@timestamp': { + gte: '2020-07-08T00:52:28.350Z', }, - ], + }, }, - }, - size: 10, - sort: [ { - '@timestamp': { - order: 'asc', + range: { + '@timestamp': { + lte: '2020-07-10T00:52:28.350Z', + }, }, }, ], }, - index: 'index-name', - track_total_hits: true, }); }); }); diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts index bb958c3ce2b5..502e48795f0c 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts @@ -14,7 +14,7 @@ import util from 'util'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { IEvent, IValidatedEvent, SAVED_OBJECT_REL_PRIMARY } from '../types'; -import { FindOptionsType } from '../event_log_client'; +import { AggregateOptionsType, FindOptionsType, QueryOptionsType } from '../event_log_client'; import { ParsedIndexAlias } from './init'; export const EVENT_BUFFER_TIME = 1000; // milliseconds @@ -47,10 +47,21 @@ interface QueryOptionsEventsBySavedObjectFilter { namespace: string | undefined; type: string; ids: string[]; - findOptions: FindOptionsType; legacyIds?: string[]; } +export type FindEventsOptionsBySavedObjectFilter = QueryOptionsEventsBySavedObjectFilter & { + findOptions: FindOptionsType; +}; + +export type AggregateEventsOptionsBySavedObjectFilter = QueryOptionsEventsBySavedObjectFilter & { + aggregateOptions: AggregateOptionsType; +}; + +export interface AggregateEventsBySavedObjectResult { + aggregations: Record | undefined; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any type AliasAny = any; @@ -327,75 +338,192 @@ export class ClusterClientAdapter { - const { index, namespace, type, ids, findOptions, legacyIds } = queryOptions; - // eslint-disable-next-line @typescript-eslint/naming-convention - const { page, per_page: perPage, start, end, sort_field, sort_order, filter } = findOptions; + const { index, type, ids, findOptions } = queryOptions; + const { page, per_page: perPage, sort } = findOptions; - const defaultNamespaceQuery = { - bool: { - must_not: { - exists: { - field: 'kibana.saved_objects.namespace', - }, - }, - }, - }; - const namedNamespaceQuery = { - term: { - 'kibana.saved_objects.namespace': { - value: namespace, - }, - }, + const esClient = await this.elasticsearchClientPromise; + + const query = getQueryBody( + this.logger, + queryOptions, + pick(queryOptions.findOptions, ['start', 'end', 'filter']) + ); + + const body: estypes.SearchRequest['body'] = { + size: perPage, + from: (page - 1) * perPage, + query, + ...(sort + ? { sort: sort.map((s) => ({ [s.sort_field]: { order: s.sort_order } })) as estypes.Sort } + : {}), }; - const namespaceQuery = namespace === undefined ? defaultNamespaceQuery : namedNamespaceQuery; + + try { + const { + hits: { hits, total }, + } = await esClient.search({ + index, + track_total_hits: true, + body, + }); + return { + page, + per_page: perPage, + total: isNumber(total) ? total : total!.value, + data: hits.map((hit) => hit._source), + }; + } catch (err) { + throw new Error( + `querying for Event Log by for type "${type}" and ids "${ids}" failed with: ${err.message}` + ); + } + } + + public async aggregateEventsBySavedObjects( + queryOptions: AggregateEventsOptionsBySavedObjectFilter + ): Promise { + const { index, type, ids, aggregateOptions } = queryOptions; + const { aggs } = aggregateOptions; const esClient = await this.elasticsearchClientPromise; - let dslFilterQuery: estypes.QueryDslBoolQuery['filter']; + + const query = getQueryBody( + this.logger, + queryOptions, + pick(queryOptions.aggregateOptions, ['start', 'end', 'filter']) + ); + + const body: estypes.SearchRequest['body'] = { + size: 0, + query, + aggs, + }; + try { - dslFilterQuery = filter ? toElasticsearchQuery(fromKueryExpression(filter)) : []; + const { aggregations } = await esClient.search({ + index, + body, + }); + return { + aggregations, + }; } catch (err) { - this.debug(`Invalid kuery syntax for the filter (${filter}) error:`, { + throw new Error( + `querying for Event Log by for type "${type}" and ids "${ids}" failed with: ${err.message}` + ); + } + } +} + +function getNamespaceQuery(namespace?: string) { + const defaultNamespaceQuery = { + bool: { + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', + }, + }, + }, + }; + const namedNamespaceQuery = { + term: { + 'kibana.saved_objects.namespace': { + value: namespace, + }, + }, + }; + return namespace === undefined ? defaultNamespaceQuery : namedNamespaceQuery; +} + +export function getQueryBody( + logger: Logger, + opts: FindEventsOptionsBySavedObjectFilter | AggregateEventsOptionsBySavedObjectFilter, + queryOptions: QueryOptionsType +) { + const { namespace, type, ids, legacyIds } = opts; + const { start, end, filter } = queryOptions ?? {}; + + const namespaceQuery = getNamespaceQuery(namespace); + let dslFilterQuery: estypes.QueryDslBoolQuery['filter']; + try { + dslFilterQuery = filter ? toElasticsearchQuery(fromKueryExpression(filter)) : undefined; + } catch (err) { + logger.debug( + `esContext: Invalid kuery syntax for the filter (${filter}) error: ${JSON.stringify({ message: err.message, statusCode: err.statusCode, - }); - throw err; - } - const savedObjectsQueryMust: estypes.QueryDslQueryContainer[] = [ - { - term: { - 'kibana.saved_objects.rel': { - value: SAVED_OBJECT_REL_PRIMARY, - }, + })}` + ); + throw err; + } + + const savedObjectsQueryMust: estypes.QueryDslQueryContainer[] = [ + { + term: { + 'kibana.saved_objects.rel': { + value: SAVED_OBJECT_REL_PRIMARY, }, }, - { - term: { - 'kibana.saved_objects.type': { - value: type, + }, + { + term: { + 'kibana.saved_objects.type': { + value: type, + }, + }, + }, + // @ts-expect-error undefined is not assignable as QueryDslTermQuery value + namespaceQuery, + ]; + + const musts: estypes.QueryDslQueryContainer[] = [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: reject(savedObjectsQueryMust, isUndefined), }, }, }, - // @ts-expect-error undefined is not assignable as QueryDslTermQuery value - namespaceQuery, - ]; - - const musts: estypes.QueryDslQueryContainer[] = [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: reject(savedObjectsQueryMust, isUndefined), + }, + ]; + + const shouldQuery = []; + shouldQuery.push({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + // default maximum of 65,536 terms, configurable by index.max_terms_count + 'kibana.saved_objects.id': ids, + }, + }, + ], + }, }, }, }, - }, - ]; - - const shouldQuery = []; + { + range: { + 'kibana.version': { + gte: LEGACY_ID_CUTOFF_VERSION, + }, + }, + }, + ], + }, + }); + if (legacyIds && legacyIds.length > 0) { shouldQuery.push({ bool: { must: [ @@ -408,7 +536,7 @@ export class ClusterClientAdapter 0) { - shouldQuery.push({ - bool: { - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - terms: { - // default maximum of 65,536 terms, configurable by index.max_terms_count - 'kibana.saved_objects.id': legacyIds, - }, - }, - ], - }, - }, - }, - }, - { - bool: { - should: [ - { - range: { - 'kibana.version': { - lt: LEGACY_ID_CUTOFF_VERSION, - }, + bool: { + should: [ + { + range: { + 'kibana.version': { + lt: LEGACY_ID_CUTOFF_VERSION, }, }, - { - bool: { - must_not: { - exists: { - field: 'kibana.version', - }, + }, + { + bool: { + must_not: { + exists: { + field: 'kibana.version', }, }, }, - ], - }, + }, + ], }, - ], - }, - }); - } - - musts.push({ - bool: { - should: shouldQuery, + }, + ], }, }); + } - if (start) { - musts.push({ - range: { - '@timestamp': { - gte: start, - }, - }, - }); - } - if (end) { - musts.push({ - range: { - '@timestamp': { - lte: end, - }, - }, - }); - } + musts.push({ + bool: { + should: shouldQuery, + }, + }); - const body: estypes.SearchRequest['body'] = { - size: perPage, - from: (page - 1) * perPage, - sort: [{ [sort_field]: { order: sort_order } }], - query: { - bool: { - filter: dslFilterQuery, - must: reject(musts, isUndefined), + if (start) { + musts.push({ + range: { + '@timestamp': { + gte: start, }, }, - }; - - try { - const { - hits: { hits, total }, - } = await esClient.search({ - index, - track_total_hits: true, - body, - }); - return { - page, - per_page: perPage, - total: isNumber(total) ? total : total!.value, - data: hits.map((hit) => hit._source), - }; - } catch (err) { - throw new Error( - `querying for Event Log by for type "${type}" and ids "${ids}" failed with: ${err.message}` - ); - } + }); } - - private debug(message: string, object?: unknown) { - const objectString = object == null ? '' : JSON.stringify(object); - this.logger.debug(`esContext: ${message} ${objectString}`); + if (end) { + musts.push({ + range: { + '@timestamp': { + lte: end, + }, + }, + }); } + + return { + bool: { + ...(dslFilterQuery ? { filter: dslFilterQuery } : {}), + must: reject(musts, isUndefined), + }, + }; } diff --git a/x-pack/plugins/event_log/server/event_log_client.mock.ts b/x-pack/plugins/event_log/server/event_log_client.mock.ts index f3071b4a1d07..7129bb951385 100644 --- a/x-pack/plugins/event_log/server/event_log_client.mock.ts +++ b/x-pack/plugins/event_log/server/event_log_client.mock.ts @@ -10,6 +10,7 @@ import { IEventLogClient } from './types'; const createEventLogClientMock = () => { const mock: jest.Mocked = { findEventsBySavedObjectIds: jest.fn(), + aggregateEventsBySavedObjectIds: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/event_log/server/event_log_client.test.ts b/x-pack/plugins/event_log/server/event_log_client.test.ts index 0acb53e93b81..9ee75dd97ed1 100644 --- a/x-pack/plugins/event_log/server/event_log_client.test.ts +++ b/x-pack/plugins/event_log/server/event_log_client.test.ts @@ -7,101 +7,82 @@ import { KibanaRequest } from 'src/core/server'; import { EventLogClient } from './event_log_client'; +import { EsContext } from './es'; import { contextMock } from './es/context.mock'; import { merge } from 'lodash'; import moment from 'moment'; +import { IClusterClientAdapter } from './es/cluster_client_adapter'; + +const expectedSavedObject = { + id: 'saved-object-id', + type: 'saved-object-type', + attributes: {}, + references: [], +}; + +const expectedEvents = [ + fakeEvent({ + kibana: { + saved_objects: [ + { + id: 'saved-object-id', + type: 'saved-object-type', + }, + { + type: 'action', + id: '1', + }, + ], + }, + }), + fakeEvent({ + kibana: { + saved_objects: [ + { + id: 'saved-object-id', + type: 'saved-object-type', + }, + { + type: 'action', + id: '2', + }, + ], + }, + }), +]; describe('EventLogStart', () => { + const savedObjectGetter = jest.fn(); + let esContext: jest.Mocked & { + esAdapter: jest.Mocked; + }; + let eventLogClient: EventLogClient; + beforeEach(() => { + esContext = contextMock.create(); + eventLogClient = new EventLogClient({ + esContext, + savedObjectGetter, + request: FakeRequest(), + }); + }); + afterEach(() => { + jest.resetAllMocks(); + }); + describe('findEventsBySavedObjectIds', () => { test('verifies that the user can access the specified saved object', async () => { - const esContext = contextMock.create(); - const savedObjectGetter = jest.fn(); - - const eventLogClient = new EventLogClient({ - esContext, - savedObjectGetter, - request: FakeRequest(), - }); - - savedObjectGetter.mockResolvedValueOnce({ - id: 'saved-object-id', - type: 'saved-object-type', - attributes: {}, - references: [], - }); - + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); await eventLogClient.findEventsBySavedObjectIds('saved-object-type', ['saved-object-id']); - expect(savedObjectGetter).toHaveBeenCalledWith('saved-object-type', ['saved-object-id']); }); - test('throws when the user doesnt have permission to access the specified saved object', async () => { - const esContext = contextMock.create(); - - const savedObjectGetter = jest.fn(); - - const eventLogClient = new EventLogClient({ - esContext, - savedObjectGetter, - request: FakeRequest(), - }); - savedObjectGetter.mockRejectedValue(new Error('Fail')); - - expect( + await expect( eventLogClient.findEventsBySavedObjectIds('saved-object-type', ['saved-object-id']) ).rejects.toMatchInlineSnapshot(`[Error: Fail]`); }); - test('fetches all event that reference the saved object', async () => { - const esContext = contextMock.create(); - - const savedObjectGetter = jest.fn(); - - const eventLogClient = new EventLogClient({ - esContext, - savedObjectGetter, - request: FakeRequest(), - }); - - savedObjectGetter.mockResolvedValueOnce({ - id: 'saved-object-id', - type: 'saved-object-type', - attributes: {}, - references: [], - }); - - const expectedEvents = [ - fakeEvent({ - kibana: { - saved_objects: [ - { - id: 'saved-object-id', - type: 'saved-object-type', - }, - { - type: 'action', - id: '1', - }, - ], - }, - }), - fakeEvent({ - kibana: { - saved_objects: [ - { - id: 'saved-object-id', - type: 'saved-object-type', - }, - { - type: 'action', - id: '2', - }, - ], - }, - }), - ]; - + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); const result = { page: 0, per_page: 10, @@ -109,7 +90,6 @@ describe('EventLogStart', () => { data: expectedEvents, }; esContext.esAdapter.queryEventsBySavedObjects.mockResolvedValue(result); - expect( await eventLogClient.findEventsBySavedObjectIds( 'saved-object-type', @@ -118,7 +98,6 @@ describe('EventLogStart', () => { ['legacy-id'] ) ).toEqual(result); - expect(esContext.esAdapter.queryEventsBySavedObjects).toHaveBeenCalledWith({ index: esContext.esNames.indexPattern, namespace: undefined, @@ -127,62 +106,18 @@ describe('EventLogStart', () => { findOptions: { page: 1, per_page: 10, - sort_field: '@timestamp', - sort_order: 'asc', + sort: [ + { + sort_field: '@timestamp', + sort_order: 'asc', + }, + ], }, legacyIds: ['legacy-id'], }); }); - test('fetches all events in time frame that reference the saved object', async () => { - const esContext = contextMock.create(); - - const savedObjectGetter = jest.fn(); - - const eventLogClient = new EventLogClient({ - esContext, - savedObjectGetter, - request: FakeRequest(), - }); - - savedObjectGetter.mockResolvedValueOnce({ - id: 'saved-object-id', - type: 'saved-object-type', - attributes: {}, - references: [], - }); - - const expectedEvents = [ - fakeEvent({ - kibana: { - saved_objects: [ - { - id: 'saved-object-id', - type: 'saved-object-type', - }, - { - type: 'action', - id: '1', - }, - ], - }, - }), - fakeEvent({ - kibana: { - saved_objects: [ - { - id: 'saved-object-id', - type: 'saved-object-type', - }, - { - type: 'action', - id: '2', - }, - ], - }, - }), - ]; - + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); const result = { page: 0, per_page: 10, @@ -190,10 +125,8 @@ describe('EventLogStart', () => { data: expectedEvents, }; esContext.esAdapter.queryEventsBySavedObjects.mockResolvedValue(result); - const start = moment().subtract(1, 'days').toISOString(); const end = moment().add(1, 'days').toISOString(); - expect( await eventLogClient.findEventsBySavedObjectIds( 'saved-object-type', @@ -205,7 +138,6 @@ describe('EventLogStart', () => { ['legacy-id'] ) ).toEqual(result); - expect(esContext.esAdapter.queryEventsBySavedObjects).toHaveBeenCalledWith({ index: esContext.esNames.indexPattern, namespace: undefined, @@ -214,72 +146,40 @@ describe('EventLogStart', () => { findOptions: { page: 1, per_page: 10, - sort_field: '@timestamp', - sort_order: 'asc', + sort: [ + { + sort_field: '@timestamp', + sort_order: 'asc', + }, + ], start, end, }, legacyIds: ['legacy-id'], }); }); - test('validates that the start date is valid', async () => { - const esContext = contextMock.create(); - - const savedObjectGetter = jest.fn(); - - const eventLogClient = new EventLogClient({ - esContext, - savedObjectGetter, - request: FakeRequest(), - }); - - savedObjectGetter.mockResolvedValueOnce({ - id: 'saved-object-id', - type: 'saved-object-type', - attributes: {}, - references: [], - }); - + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); esContext.esAdapter.queryEventsBySavedObjects.mockResolvedValue({ page: 0, per_page: 0, total: 0, data: [], }); - expect( eventLogClient.findEventsBySavedObjectIds('saved-object-type', ['saved-object-id'], { start: 'not a date string', }) ).rejects.toMatchInlineSnapshot(`[Error: [start]: Invalid Date]`); }); - test('validates that the end date is valid', async () => { - const esContext = contextMock.create(); - - const savedObjectGetter = jest.fn(); - - const eventLogClient = new EventLogClient({ - esContext, - savedObjectGetter, - request: FakeRequest(), - }); - - savedObjectGetter.mockResolvedValueOnce({ - id: 'saved-object-id', - type: 'saved-object-type', - attributes: {}, - references: [], - }); - + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); esContext.esAdapter.queryEventsBySavedObjects.mockResolvedValue({ page: 0, per_page: 0, total: 0, data: [], }); - expect( eventLogClient.findEventsBySavedObjectIds('saved-object-type', ['saved-object-id'], { end: 'not a date string', @@ -287,6 +187,59 @@ describe('EventLogStart', () => { ).rejects.toMatchInlineSnapshot(`[Error: [end]: Invalid Date]`); }); }); + + describe('aggregateEventsBySavedObjectIds', () => { + test('verifies that the user can access the specified saved object', async () => { + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); + await eventLogClient.aggregateEventsBySavedObjectIds( + 'saved-object-type', + ['saved-object-id'], + { aggs: {} } + ); + expect(savedObjectGetter).toHaveBeenCalledWith('saved-object-type', ['saved-object-id']); + }); + test('throws when no aggregation is defined in options', async () => { + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); + await expect( + eventLogClient.aggregateEventsBySavedObjectIds('saved-object-type', ['saved-object-id']) + ).rejects.toMatchInlineSnapshot(`[Error: No aggregation defined!]`); + }); + test('throws when the user doesnt have permission to access the specified saved object', async () => { + savedObjectGetter.mockRejectedValue(new Error('Fail')); + await expect( + eventLogClient.aggregateEventsBySavedObjectIds('saved-object-type', ['saved-object-id'], { + aggs: {}, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Fail]`); + }); + test('calls aggregateEventsBySavedObjects with given aggregation', async () => { + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); + await eventLogClient.aggregateEventsBySavedObjectIds( + 'saved-object-type', + ['saved-object-id'], + { aggs: { myAgg: {} } } + ); + expect(savedObjectGetter).toHaveBeenCalledWith('saved-object-type', ['saved-object-id']); + expect(esContext.esAdapter.aggregateEventsBySavedObjects).toHaveBeenCalledWith({ + index: esContext.esNames.indexPattern, + namespace: undefined, + type: 'saved-object-type', + ids: ['saved-object-id'], + aggregateOptions: { + aggs: { myAgg: {} }, + page: 1, + per_page: 10, + sort: [ + { + sort_field: '@timestamp', + sort_order: 'asc', + }, + ], + }, + legacyIds: undefined, + }); + }); + }); }); function fakeEvent(overrides = {}) { diff --git a/x-pack/plugins/event_log/server/event_log_client.ts b/x-pack/plugins/event_log/server/event_log_client.ts index 39b78296e387..c832ab9056be 100644 --- a/x-pack/plugins/event_log/server/event_log_client.ts +++ b/x-pack/plugins/event_log/server/event_log_client.ts @@ -5,9 +5,11 @@ * 2.0. */ +import { omit } from 'lodash'; import { Observable } from 'rxjs'; import { schema, TypeOf } from '@kbn/config-schema'; import { IClusterClient, KibanaRequest } from 'src/core/server'; +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { SpacesServiceStart } from '../../spaces/server'; import { EsContext } from './es'; @@ -27,37 +29,44 @@ const optionalDateFieldSchema = schema.maybe( }) ); -export const findOptionsSchema = schema.object({ +const sortSchema = schema.object({ + sort_field: schema.oneOf([ + schema.literal('@timestamp'), + schema.literal('event.start'), + schema.literal('event.end'), + schema.literal('event.provider'), + schema.literal('event.duration'), + schema.literal('event.action'), + schema.literal('message'), + ]), + sort_order: schema.oneOf([schema.literal('asc'), schema.literal('desc')]), +}); + +export const queryOptionsSchema = schema.object({ per_page: schema.number({ defaultValue: 10, min: 0 }), page: schema.number({ defaultValue: 1, min: 1 }), start: optionalDateFieldSchema, end: optionalDateFieldSchema, - sort_field: schema.oneOf( - [ - schema.literal('@timestamp'), - schema.literal('event.start'), - schema.literal('event.end'), - schema.literal('event.provider'), - schema.literal('event.duration'), - schema.literal('event.action'), - schema.literal('message'), - ], - { - defaultValue: '@timestamp', - } - ), - sort_order: schema.oneOf([schema.literal('asc'), schema.literal('desc')], { - defaultValue: 'asc', + sort: schema.arrayOf(sortSchema, { + defaultValue: [{ sort_field: '@timestamp', sort_order: 'asc' }], }), filter: schema.maybe(schema.string()), }); + +export type QueryOptionsType = Pick, 'start' | 'end' | 'filter'>; + // page & perPage are required, other fields are optional // using schema.maybe allows us to set undefined, but not to make the field optional export type FindOptionsType = Pick< - TypeOf, - 'page' | 'per_page' | 'sort_field' | 'sort_order' | 'filter' + TypeOf, + 'page' | 'per_page' | 'sort' | 'filter' > & - Partial>; + Partial>; + +export type AggregateOptionsType = Pick, 'filter'> & + Partial> & { + aggs: Record; + }; interface EventLogServiceCtorParams { esContext: EsContext; @@ -80,27 +89,56 @@ export class EventLogClient implements IEventLogClient { this.request = request; } - async findEventsBySavedObjectIds( + public async findEventsBySavedObjectIds( type: string, ids: string[], options?: Partial, legacyIds?: string[] ): Promise { - const findOptions = findOptionsSchema.validate(options ?? {}); + const findOptions = queryOptionsSchema.validate(options ?? {}); - const space = await this.spacesService?.getActiveSpace(this.request); - const namespace = space && this.spacesService?.spaceIdToNamespace(space.id); - - // verify the user has the required permissions to view this saved objects + // verify the user has the required permissions to view this saved object await this.savedObjectGetter(type, ids); return await this.esContext.esAdapter.queryEventsBySavedObjects({ index: this.esContext.esNames.indexPattern, - namespace, + namespace: await this.getNamespace(), type, ids, findOptions, legacyIds, }); } + + public async aggregateEventsBySavedObjectIds( + type: string, + ids: string[], + options?: AggregateOptionsType, + legacyIds?: string[] + ) { + const aggs = options?.aggs; + if (!aggs) { + throw new Error('No aggregation defined!'); + } + + // validate other query options separately from + const aggregateOptions = queryOptionsSchema.validate(omit(options, 'aggs') ?? {}); + + // verify the user has the required permissions to view this saved object + await this.savedObjectGetter(type, ids); + + return await this.esContext.esAdapter.aggregateEventsBySavedObjects({ + index: this.esContext.esNames.indexPattern, + namespace: await this.getNamespace(), + type, + ids, + aggregateOptions: { ...aggregateOptions, aggs } as AggregateOptionsType, + legacyIds, + }); + } + + private async getNamespace() { + const space = await this.spacesService?.getActiveSpace(this.request); + return space && this.spacesService?.spaceIdToNamespace(space.id); + } } diff --git a/x-pack/plugins/event_log/server/index.ts b/x-pack/plugins/event_log/server/index.ts index 877c39a02edc..42fc2e979201 100644 --- a/x-pack/plugins/event_log/server/index.ts +++ b/x-pack/plugins/event_log/server/index.ts @@ -17,6 +17,7 @@ export type { IValidatedEvent, IEventLogClient, QueryEventsBySavedObjectResult, + AggregateEventsBySavedObjectResult, } from './types'; export { SAVED_OBJECT_REL_PRIMARY } from './types'; diff --git a/x-pack/plugins/event_log/server/routes/find.test.ts b/x-pack/plugins/event_log/server/routes/find.test.ts index b823d21a6c1f..c51c8f3adf1e 100644 --- a/x-pack/plugins/event_log/server/routes/find.test.ts +++ b/x-pack/plugins/event_log/server/routes/find.test.ts @@ -26,7 +26,7 @@ describe('find', () => { const [config, handler] = router.get.mock.calls[0]; - expect(config.path).toMatchInlineSnapshot(`"/api/event_log/{type}/{id}/_find"`); + expect(config.path).toMatchInlineSnapshot(`"/internal/event_log/{type}/{id}/_find"`); const events = [fakeEvent(), fakeEvent()]; const result = { diff --git a/x-pack/plugins/event_log/server/routes/find.ts b/x-pack/plugins/event_log/server/routes/find.ts index cbbd2eedd2db..fdb699b70e26 100644 --- a/x-pack/plugins/event_log/server/routes/find.ts +++ b/x-pack/plugins/event_log/server/routes/find.ts @@ -14,7 +14,7 @@ import type { } from 'src/core/server'; import type { EventLogRouter, EventLogRequestHandlerContext } from '../types'; import { BASE_EVENT_LOG_API_PATH } from '../../common'; -import { findOptionsSchema, FindOptionsType } from '../event_log_client'; +import { queryOptionsSchema, FindOptionsType } from '../event_log_client'; const paramSchema = schema.object({ type: schema.string(), @@ -27,7 +27,7 @@ export const findRoute = (router: EventLogRouter, systemLogger: Logger) => { path: `${BASE_EVENT_LOG_API_PATH}/{type}/{id}/_find`, validate: { params: paramSchema, - query: findOptionsSchema, + query: queryOptionsSchema, }, }, router.handleLegacyErrors(async function ( diff --git a/x-pack/plugins/event_log/server/routes/find_by_ids.test.ts b/x-pack/plugins/event_log/server/routes/find_by_ids.test.ts index 4685306e869d..065174abcd9f 100644 --- a/x-pack/plugins/event_log/server/routes/find_by_ids.test.ts +++ b/x-pack/plugins/event_log/server/routes/find_by_ids.test.ts @@ -26,7 +26,7 @@ describe('find_by_ids', () => { const [config, handler] = router.post.mock.calls[0]; - expect(config.path).toMatchInlineSnapshot(`"/api/event_log/{type}/_find"`); + expect(config.path).toMatchInlineSnapshot(`"/internal/event_log/{type}/_find"`); const events = [fakeEvent(), fakeEvent()]; const result = { diff --git a/x-pack/plugins/event_log/server/routes/find_by_ids.ts b/x-pack/plugins/event_log/server/routes/find_by_ids.ts index 378b9516631a..324dbc7f568b 100644 --- a/x-pack/plugins/event_log/server/routes/find_by_ids.ts +++ b/x-pack/plugins/event_log/server/routes/find_by_ids.ts @@ -15,7 +15,7 @@ import type { import type { EventLogRouter, EventLogRequestHandlerContext } from '../types'; import { BASE_EVENT_LOG_API_PATH } from '../../common'; -import { findOptionsSchema, FindOptionsType } from '../event_log_client'; +import { queryOptionsSchema, FindOptionsType } from '../event_log_client'; const paramSchema = schema.object({ type: schema.string(), @@ -32,7 +32,7 @@ export const findByIdsRoute = (router: EventLogRouter, systemLogger: Logger) => path: `${BASE_EVENT_LOG_API_PATH}/{type}/_find`, validate: { params: paramSchema, - query: findOptionsSchema, + query: queryOptionsSchema, body: bodySchema, }, }, diff --git a/x-pack/plugins/event_log/server/types.ts b/x-pack/plugins/event_log/server/types.ts index 54305803b090..34aa67f313ee 100644 --- a/x-pack/plugins/event_log/server/types.ts +++ b/x-pack/plugins/event_log/server/types.ts @@ -11,9 +11,15 @@ import type { IRouter, KibanaRequest, RequestHandlerContext } from 'src/core/ser export type { IEvent, IValidatedEvent } from '../generated/schemas'; export { EventSchema, ECS_VERSION } from '../generated/schemas'; import { IEvent } from '../generated/schemas'; -import { FindOptionsType } from './event_log_client'; -import { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter'; -export type { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter'; +import { AggregateOptionsType, FindOptionsType } from './event_log_client'; +import { + AggregateEventsBySavedObjectResult, + QueryEventsBySavedObjectResult, +} from './es/cluster_client_adapter'; +export type { + QueryEventsBySavedObjectResult, + AggregateEventsBySavedObjectResult, +} from './es/cluster_client_adapter'; import { SavedObjectProvider } from './saved_object_provider_registry'; export const SAVED_OBJECT_REL_PRIMARY = 'primary'; @@ -49,6 +55,12 @@ export interface IEventLogClient { options?: Partial, legacyIds?: string[] ): Promise; + aggregateEventsBySavedObjectIds( + type: string, + ids: string[], + options?: Partial, + legacyIds?: string[] + ): Promise; } export interface IEventLogger { diff --git a/x-pack/plugins/file_upload/public/importer/geo/geojson_importer/geojson_importer.ts b/x-pack/plugins/file_upload/public/importer/geo/geojson_importer/geojson_importer.ts index 8cc39bf0eebd..c3de1ac2e949 100644 --- a/x-pack/plugins/file_upload/public/importer/geo/geojson_importer/geojson_importer.ts +++ b/x-pack/plugins/file_upload/public/importer/geo/geojson_importer/geojson_importer.ts @@ -14,8 +14,15 @@ import { AbstractGeoFileImporter } from '../abstract_geo_file_importer'; export const GEOJSON_FILE_TYPES = ['.json', '.geojson']; +interface LoaderBatch { + bytesUsed?: number; + batchType?: string; + container?: Feature; + data?: Feature[]; +} + export class GeoJsonImporter extends AbstractGeoFileImporter { - private _iterator?: Iterator; + private _iterator?: AsyncIterator; private _prevBatchLastFeature?: Feature; protected async _readNext(prevTotalFeaturesRead: number, prevTotalBytesRead: number) { @@ -49,24 +56,28 @@ export class GeoJsonImporter extends AbstractGeoFileImporter { return results; } - if ('bytesUsed' in batch) { + if (batch.bytesUsed) { results.bytesRead = batch.bytesUsed - prevTotalBytesRead; } - const features: unknown[] = this._prevBatchLastFeature ? [this._prevBatchLastFeature] : []; + const features: Feature[] = this._prevBatchLastFeature ? [this._prevBatchLastFeature] : []; this._prevBatchLastFeature = undefined; const isLastBatch = batch.batchType === 'root-object-batch-complete'; if (isLastBatch) { // Handle single feature geoJson if (featureIndex === 0) { - features.push(batch.container); + if (batch.container) { + features.push(batch.container); + } } } else { - features.push(...batch.data); + if (batch.data) { + features.push(...batch.data); + } } for (let i = 0; i < features.length; i++) { - const feature = features[i] as Feature; + const feature = features[i]; if (!isLastBatch && i === features.length - 1) { // Do not process last feature until next batch is read, features on batch boundary may be incomplete. this._prevBatchLastFeature = feature; diff --git a/x-pack/plugins/fleet/.storybook/context/execution_context.ts b/x-pack/plugins/fleet/.storybook/context/execution_context.ts new file mode 100644 index 000000000000..d3a15e200129 --- /dev/null +++ b/x-pack/plugins/fleet/.storybook/context/execution_context.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 type { ExecutionContextSetup } from 'kibana/public'; +import { of } from 'rxjs'; + +export const getExecutionContext = () => { + const exec: ExecutionContextSetup = { + context$: of({}), + get: () => { + return {}; + }, + clear: () => {}, + set: (context: Record) => {}, + getAsLabels: () => { + return {}; + }, + withGlobalContext: () => { + return {}; + }, + }; + + return exec; +}; diff --git a/x-pack/plugins/fleet/.storybook/context/index.tsx b/x-pack/plugins/fleet/.storybook/context/index.tsx index eb19a1145ba7..fbcbd4fd3a08 100644 --- a/x-pack/plugins/fleet/.storybook/context/index.tsx +++ b/x-pack/plugins/fleet/.storybook/context/index.tsx @@ -31,6 +31,7 @@ import { stubbedStartServices } from './stubs'; import { getDocLinks } from './doc_links'; import { getCloud } from './cloud'; import { getShare } from './share'; +import { getExecutionContext } from './execution_context'; // TODO: clintandrewhall - this is not ideal, or complete. The root context of Fleet applications // requires full start contracts of its dependencies. As a result, we have to mock all of those contracts @@ -52,6 +53,7 @@ export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({ () => ({ ...stubbedStartServices, application: getApplication(), + executionContext: getExecutionContext(), chrome: getChrome(), cloud: { ...getCloud({ isCloudEnabled }), diff --git a/x-pack/plugins/fleet/.storybook/context/stubs.tsx b/x-pack/plugins/fleet/.storybook/context/stubs.tsx index 65485a31d376..0f4f81b58f95 100644 --- a/x-pack/plugins/fleet/.storybook/context/stubs.tsx +++ b/x-pack/plugins/fleet/.storybook/context/stubs.tsx @@ -11,7 +11,6 @@ type Stubs = | 'licensing' | 'storage' | 'data' - | 'fieldFormats' | 'deprecations' | 'fatalErrors' | 'navigation' @@ -24,7 +23,6 @@ export const stubbedStartServices: StubbedStartServices = { licensing: {} as FleetStartServices['licensing'], storage: {} as FleetStartServices['storage'], data: {} as FleetStartServices['data'], - fieldFormats: {} as FleetStartServices['fieldFormats'], deprecations: {} as FleetStartServices['deprecations'], fatalErrors: {} as FleetStartServices['fatalErrors'], navigation: {} as FleetStartServices['navigation'], diff --git a/x-pack/plugins/fleet/common/constants/output.ts b/x-pack/plugins/fleet/common/constants/output.ts index e41e3c526951..318712d22885 100644 --- a/x-pack/plugins/fleet/common/constants/output.ts +++ b/x-pack/plugins/fleet/common/constants/output.ts @@ -23,3 +23,5 @@ export const DEFAULT_OUTPUT: NewOutput = { type: outputType.Elasticsearch, hosts: [''], }; + +export const LICENCE_FOR_PER_POLICY_OUTPUT = 'platinum'; diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index 46cd3e998ea7..f16ea62aa1b0 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -621,6 +621,19 @@ "type" ] } + }, + "_meta": { + "type": "object", + "properties": { + "install_source": { + "type": "string", + "enum": [ + "registry", + "upload", + "bundled" + ] + } + } } }, "required": [ @@ -3825,6 +3838,14 @@ "logs" ] } + }, + "data_output_id": { + "type": "string", + "nullable": true + }, + "monitoring_output_id": { + "type": "string", + "nullable": true } }, "required": [ @@ -3981,6 +4002,12 @@ "updated_by": { "type": "string" }, + "data_output_id": { + "type": "string" + }, + "monitoring_output_id": { + "type": "string" + }, "revision": { "type": "number" }, @@ -4220,6 +4247,9 @@ }, "description": { "type": "string" + }, + "force": { + "type": "boolean" } }, "required": [ diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index ae8fdb3b87d4..28040efa5f41 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -382,6 +382,15 @@ paths: required: - id - type + _meta: + type: object + properties: + install_source: + type: string + enum: + - registry + - upload + - bundled required: - items operationId: install-package @@ -2402,6 +2411,12 @@ components: enum: - metrics - logs + data_output_id: + type: string + nullable: true + monitoring_output_id: + type: string + nullable: true required: - name - namespace @@ -2501,6 +2516,10 @@ components: format: date-time updated_by: type: string + data_output_id: + type: string + monitoring_output_id: + type: string revision: type: number agents: @@ -2657,6 +2676,8 @@ components: type: string description: type: string + force: + type: boolean required: - name - namespace diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy.yaml index 7eed85eb2e3b..c2cebb183ed8 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy.yaml @@ -22,6 +22,10 @@ allOf: format: date-time updated_by: type: string + data_output_id: + type: string + monitoring_output_id: + type: string revision: type: number agents: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/data_stream.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/data_stream.yaml index 3d717ae91090..8cee31f95f84 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/data_stream.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/data_stream.yaml @@ -14,8 +14,10 @@ properties: package_version: type: string last_activity_ms: - type: string + type: number size_in_bytes: + type: number + size_in_bytes_formatted: type: string dashboard: type: array diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/new_agent_policy.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/new_agent_policy.yaml index 7b9e7f43c8ab..7ad8988f1b0e 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/new_agent_policy.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/new_agent_policy.yaml @@ -16,6 +16,12 @@ properties: enum: - metrics - logs + data_output_id: + type: string + nullable: true + monitoring_output_id: + type: string + nullable: true required: - name - namespace \ No newline at end of file diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/update_package_policy.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/update_package_policy.yaml index 7502a27e11da..bd14910303a0 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/update_package_policy.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/update_package_policy.yaml @@ -53,6 +53,8 @@ properties: type: string description: type: string + force: + type: boolean required: - name - namespace diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml index ef0964b66e04..6ef61788acd6 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml @@ -64,6 +64,15 @@ post: required: - id - type + _meta: + type: object + properties: + install_source: + type: string + enum: + - registry + - upload + - bundled required: - items operationId: install-package diff --git a/x-pack/plugins/fleet/common/services/license.ts b/x-pack/plugins/fleet/common/services/license.ts index d7e64f484474..a5fdfb1e7414 100644 --- a/x-pack/plugins/fleet/common/services/license.ts +++ b/x-pack/plugins/fleet/common/services/license.ts @@ -40,18 +40,10 @@ export class LicenseService { } public isGoldPlus() { - return ( - this.licenseInformation?.isAvailable && - this.licenseInformation?.isActive && - this.licenseInformation?.hasAtLeast('gold') - ); + return this.hasAtLeast('gold'); } public isEnterprise() { - return ( - this.licenseInformation?.isAvailable && - this.licenseInformation?.isActive && - this.licenseInformation?.hasAtLeast('enterprise') - ); + return this.hasAtLeast('enterprise'); } public hasAtLeast(licenseType: LicenseType) { return ( diff --git a/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.test.ts b/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.test.ts index a5acd823c20f..3b1ea6dfd422 100644 --- a/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.test.ts +++ b/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.test.ts @@ -77,6 +77,36 @@ describe('Fleet - storedPackagePoliciesToAgentInputs', () => { ], }; + const mockInput2: PackagePolicyInput = { + type: 'test-metrics', + policy_template: 'some-template', + enabled: true, + vars: { + inputVar: { value: 'input-value' }, + inputVar2: { value: undefined }, + inputVar3: { + type: 'yaml', + value: 'testField: test', + }, + inputVar4: { value: '' }, + }, + streams: [ + { + id: 'test-metrics-foo', + enabled: true, + data_stream: { dataset: 'foo', type: 'metrics' }, + vars: { + fooVar: { value: 'foo-value' }, + fooVar2: { value: [1, 2] }, + }, + compiled_stream: { + fooKey: 'fooValue1', + fooKey2: ['fooValue2'], + }, + }, + ], + }; + it('returns no inputs for package policy with no inputs, or only disabled inputs', () => { expect(storedPackagePoliciesToAgentInputs([mockPackagePolicy])).toEqual([]); @@ -118,7 +148,7 @@ describe('Fleet - storedPackagePoliciesToAgentInputs', () => { ]) ).toEqual([ { - id: 'some-uuid', + id: 'test-logs-some-uuid', name: 'mock-package-policy', revision: 1, type: 'test-logs', @@ -146,6 +176,71 @@ describe('Fleet - storedPackagePoliciesToAgentInputs', () => { ]); }); + it('returns unique agent inputs IDs, with policy template name if one exists', () => { + expect( + storedPackagePoliciesToAgentInputs([ + { + ...mockPackagePolicy, + package: { + name: 'mock-package', + title: 'Mock package', + version: '0.0.0', + }, + inputs: [mockInput, mockInput2], + }, + ]) + ).toEqual([ + { + id: 'test-logs-some-uuid', + name: 'mock-package-policy', + revision: 1, + type: 'test-logs', + data_stream: { namespace: 'default' }, + use_output: 'default', + meta: { + package: { + name: 'mock-package', + version: '0.0.0', + }, + }, + streams: [ + { + id: 'test-logs-foo', + data_stream: { dataset: 'foo', type: 'logs' }, + fooKey: 'fooValue1', + fooKey2: ['fooValue2'], + }, + { + id: 'test-logs-bar', + data_stream: { dataset: 'bar', type: 'logs' }, + }, + ], + }, + { + id: 'test-metrics-some-template-some-uuid', + name: 'mock-package-policy', + revision: 1, + type: 'test-metrics', + data_stream: { namespace: 'default' }, + use_output: 'default', + meta: { + package: { + name: 'mock-package', + version: '0.0.0', + }, + }, + streams: [ + { + id: 'test-metrics-foo', + data_stream: { dataset: 'foo', type: 'metrics' }, + fooKey: 'fooValue1', + fooKey2: ['fooValue2'], + }, + ], + }, + ]); + }); + it('returns agent inputs without streams', () => { expect( storedPackagePoliciesToAgentInputs([ @@ -169,7 +264,7 @@ describe('Fleet - storedPackagePoliciesToAgentInputs', () => { ]) ).toEqual([ { - id: 'some-uuid', + id: 'test-logs-some-uuid', name: 'mock-package-policy', revision: 1, type: 'test-logs', @@ -201,7 +296,7 @@ describe('Fleet - storedPackagePoliciesToAgentInputs', () => { ]) ).toEqual([ { - id: 'some-uuid', + id: 'test-logs-some-uuid', name: 'mock-package-policy', revision: 1, type: 'test-logs', @@ -263,7 +358,7 @@ describe('Fleet - storedPackagePoliciesToAgentInputs', () => { ]) ).toEqual([ { - id: 'some-uuid', + id: 'test-logs-some-uuid', revision: 1, name: 'mock-package-policy', type: 'test-logs', diff --git a/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.ts b/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.ts index 119bb04af5ca..b9b2c70815fa 100644 --- a/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.ts +++ b/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.ts @@ -26,7 +26,9 @@ export const storedPackagePoliciesToAgentInputs = ( } const fullInput: FullAgentPolicyInput = { - id: packagePolicy.id || packagePolicy.name, + id: `${input.type}${input.policy_template ? `-${input.policy_template}-` : '-'}${ + packagePolicy.id + }`, revision: packagePolicy.revision, name: packagePolicy.name, type: input.type, diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index b3a53cd05da4..25c0680645f9 100644 --- a/x-pack/plugins/fleet/common/services/routes.ts +++ b/x-pack/plugins/fleet/common/services/routes.ts @@ -176,6 +176,7 @@ export const outputRoutesService = { getDeletePath: (outputId: string) => OUTPUT_API_ROUTES.DELETE_PATTERN.replace('{outputId}', outputId), getCreatePath: () => OUTPUT_API_ROUTES.CREATE_PATTERN, + getCreateLogstashApiKeyPath: () => OUTPUT_API_ROUTES.LOGSTASH_API_KEY_PATTERN, }; export const settingsRoutesService = { diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts index fe34b36f7781..d04855480dd6 100644 --- a/x-pack/plugins/fleet/common/types/index.ts +++ b/x-pack/plugins/fleet/common/types/index.ts @@ -35,6 +35,7 @@ export interface FleetConfigType { developer?: { disableRegistryVersionCheck?: boolean; allowAgentUpgradeSourceUri?: boolean; + bundledPackageLocation?: string; }; } diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index 6fbb423507c3..4d87d1038561 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -25,8 +25,9 @@ export interface NewAgentPolicy { monitoring_enabled?: MonitoringType; unenroll_timeout?: number; is_preconfigured?: boolean; - data_output_id?: string; - monitoring_output_id?: string; + // Nullable to allow user to reset to default outputs + data_output_id?: string | null; + monitoring_output_id?: string | null; } export interface AgentPolicy extends Omit { diff --git a/x-pack/plugins/fleet/common/types/models/data_stream.ts b/x-pack/plugins/fleet/common/types/models/data_stream.ts index 91c7069dfd5f..7bf6d0f93dc5 100644 --- a/x-pack/plugins/fleet/common/types/models/data_stream.ts +++ b/x-pack/plugins/fleet/common/types/models/data_stream.ts @@ -14,6 +14,7 @@ export interface DataStream { package_version: string; last_activity_ms: number; size_in_bytes: number; + size_in_bytes_formatted: number | string; dashboards: Array<{ id: string; title: string; diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 64ea5665241e..dcff9f503bfe 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -43,7 +43,7 @@ export interface DefaultPackagesInstallationError { } export type InstallType = 'reinstall' | 'reupdate' | 'rollback' | 'update' | 'install' | 'unknown'; -export type InstallSource = 'registry' | 'upload'; +export type InstallSource = 'registry' | 'upload' | 'bundled'; export type EpmPackageInstallStatus = | 'installed' @@ -305,6 +305,7 @@ export enum RegistryDataStreamKeys { } export interface RegistryDataStream { + [key: string]: any; [RegistryDataStreamKeys.type]: string; [RegistryDataStreamKeys.ilm_policy]?: string; [RegistryDataStreamKeys.hidden]?: boolean; @@ -323,6 +324,7 @@ export interface RegistryElasticsearch { privileges?: RegistryDataStreamPrivileges; 'index_template.settings'?: estypes.IndicesIndexSettings; 'index_template.mappings'?: estypes.MappingTypeMapping; + 'ingest_pipeline.name'?: string; } export interface RegistryDataStreamPrivileges { @@ -481,6 +483,25 @@ export interface IndexTemplate { _meta: object; } +export interface ESAssetMetadata { + package?: { + name: string; + }; + managed_by: string; + managed: boolean; +} +export interface TemplateMapEntry { + _meta: ESAssetMetadata; + template: + | { + mappings: NonNullable; + } + | { + settings: NonNullable | object; + }; +} + +export type TemplateMap = Record; export interface IndexTemplateEntry { templateName: string; indexTemplate: IndexTemplate; diff --git a/x-pack/plugins/fleet/common/types/models/package_spec.ts b/x-pack/plugins/fleet/common/types/models/package_spec.ts index 57aaa0230e64..f4021b087912 100644 --- a/x-pack/plugins/fleet/common/types/models/package_spec.ts +++ b/x-pack/plugins/fleet/common/types/models/package_spec.ts @@ -70,4 +70,5 @@ export interface PackageSpecScreenshot { title: string; size?: string; type?: string; + path?: string; } diff --git a/x-pack/plugins/fleet/common/types/rest_spec/epm.ts b/x-pack/plugins/fleet/common/types/rest_spec/epm.ts index 6a72792e780e..f1ccaae05487 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/epm.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/epm.ts @@ -12,6 +12,7 @@ import type { PackageInfo, PackageUsageStats, InstallType, + InstallSource, } from '../models/epm'; export interface GetCategoriesRequest { @@ -108,6 +109,9 @@ export interface InstallPackageRequest { export interface InstallPackageResponse { items: AssetReference[]; + _meta: { + install_source: InstallSource; + }; // deprecated in 8.0 response?: AssetReference[]; } @@ -123,6 +127,7 @@ export interface InstallResult { status?: 'installed' | 'already_installed'; error?: Error; installType: InstallType; + installSource: InstallSource; } export interface BulkInstallPackageInfo { diff --git a/x-pack/plugins/fleet/cypress/integration/fleet_settings.spec.ts b/x-pack/plugins/fleet/cypress/integration/fleet_settings.spec.ts index ab4bf6b4a66a..76c8f129584b 100644 --- a/x-pack/plugins/fleet/cypress/integration/fleet_settings.spec.ts +++ b/x-pack/plugins/fleet/cypress/integration/fleet_settings.spec.ts @@ -28,7 +28,7 @@ describe('Edit settings', () => { cy.getBySel('toastCloseButton').click(); }); - it('should update hosts', () => { + it('should update Fleet server hosts', () => { cy.getBySel('editHostsBtn').click(); cy.get('[placeholder="Specify host URL"').type('http://localhost:8220'); @@ -50,6 +50,7 @@ describe('Edit settings', () => { it('should update outputs', () => { cy.getBySel('editOutputBtn').click(); cy.get('[placeholder="Specify name"').clear().type('output-1'); + cy.get('[placeholder="Specify host URL"').clear().type('http://elasticsearch:9200'); cy.intercept('/api/fleet/outputs', { items: [ @@ -65,6 +66,7 @@ describe('Edit settings', () => { cy.intercept('PUT', '/api/fleet/outputs/fleet-default-output', { name: 'output-1', type: 'elasticsearch', + hosts: ['http://elasticsearch:9200'], is_default: true, is_default_monitoring: true, }).as('updateOutputs'); @@ -76,4 +78,42 @@ describe('Edit settings', () => { expect(interception.request.body.name).to.equal('output-1'); }); }); + + it('should allow to create a logstash output', () => { + cy.getBySel('addOutputBtn').click(); + cy.get('[placeholder="Specify name"]').clear().type('output-logstash-1'); + cy.get('[placeholder="Specify type"]').select('logstash'); + cy.get('[placeholder="Specify host"').clear().type('logstash:5044'); + cy.get('[placeholder="Specify ssl certificate"]').clear().type('SSL CERTIFICATE'); + cy.get('[placeholder="Specify certificate key"]').clear().type('SSL KEY'); + + cy.intercept('/api/fleet/outputs', { + items: [ + { + id: 'fleet-default-output', + name: 'output-1', + type: 'elasticsearch', + is_default: true, + is_default_monitoring: true, + }, + ], + }); + cy.intercept('POST', '/api/fleet/outputs', { + name: 'output-logstash-1', + type: 'logstash', + hosts: ['logstash:5044'], + is_default: false, + is_default_monitoring: false, + ssl: { + certificate: "SSL CERTIFICATE');", + key: 'SSL KEY', + }, + }).as('postLogstashOutput'); + + cy.getBySel('saveApplySettingsBtn').click(); + + cy.wait('@postLogstashOutput').then((interception) => { + expect(interception.request.body.name).to.equal('output-logstash-1'); + }); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx new file mode 100644 index 000000000000..88072b327d9f --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createFleetTestRendererMock } from '../../../../../../mock'; +import type { MockedFleetStartServices } from '../../../../../../mock'; +import { useLicense } from '../../../../../../hooks/use_license'; +import type { LicenseService } from '../../../../services'; + +import { useOutputOptions } from './hooks'; + +jest.mock('../../../../../../hooks/use_license'); + +const mockedUseLicence = useLicense as jest.MockedFunction; + +function defaultHttpClientGetImplementation(path: any) { + if (typeof path !== 'string') { + throw new Error('Invalid request'); + } + const err = new Error(`API [GET ${path}] is not MOCKED!`); + // eslint-disable-next-line no-console + console.log(err); + throw err; +} + +const mockApiCallsWithOutputs = (http: MockedFleetStartServices['http']) => { + http.get.mockImplementation(async (path) => { + if (typeof path !== 'string') { + throw new Error('Invalid request'); + } + if (path === '/api/fleet/outputs') { + return { + data: { + items: [ + { + id: 'output1', + name: 'Output 1', + is_default: true, + is_default_monitoring: true, + }, + { + id: 'output2', + name: 'Output 2', + is_default: true, + is_default_monitoring: true, + }, + { + id: 'output3', + name: 'Output 3', + is_default: true, + is_default_monitoring: true, + }, + ], + }, + }; + } + + return defaultHttpClientGetImplementation(path); + }); +}; + +describe('useOutputOptions', () => { + it('should generate enabled options if the licence is platinium', async () => { + const testRenderer = createFleetTestRendererMock(); + mockedUseLicence.mockReturnValue({ + hasAtLeast: () => true, + } as unknown as LicenseService); + mockApiCallsWithOutputs(testRenderer.startServices.http); + const { result, waitForNextUpdate } = testRenderer.renderHook(() => useOutputOptions()); + expect(result.current.isLoading).toBeTruthy(); + + await waitForNextUpdate(); + expect(result.current.dataOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "inputDisplay": "Default (currently Output 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": false, + "inputDisplay": "Output 1", + "value": "output1", + }, + Object { + "disabled": false, + "inputDisplay": "Output 2", + "value": "output2", + }, + Object { + "disabled": false, + "inputDisplay": "Output 3", + "value": "output3", + }, + ] + `); + expect(result.current.monitoringOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "inputDisplay": "Default (currently Output 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": false, + "inputDisplay": "Output 1", + "value": "output1", + }, + Object { + "disabled": false, + "inputDisplay": "Output 2", + "value": "output2", + }, + Object { + "disabled": false, + "inputDisplay": "Output 3", + "value": "output3", + }, + ] + `); + }); + + it('should only enable the default options if the licence is not platinium', async () => { + const testRenderer = createFleetTestRendererMock(); + mockedUseLicence.mockReturnValue({ + hasAtLeast: () => false, + } as unknown as LicenseService); + mockApiCallsWithOutputs(testRenderer.startServices.http); + const { result, waitForNextUpdate } = testRenderer.renderHook(() => useOutputOptions()); + expect(result.current.isLoading).toBeTruthy(); + + await waitForNextUpdate(); + expect(result.current.dataOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "inputDisplay": "Default (currently Output 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": true, + "inputDisplay": "Output 1", + "value": "output1", + }, + Object { + "disabled": true, + "inputDisplay": "Output 2", + "value": "output2", + }, + Object { + "disabled": true, + "inputDisplay": "Output 3", + "value": "output3", + }, + ] + `); + expect(result.current.monitoringOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "inputDisplay": "Default (currently Output 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": true, + "inputDisplay": "Output 1", + "value": "output1", + }, + Object { + "disabled": true, + "inputDisplay": "Output 2", + "value": "output2", + }, + Object { + "disabled": true, + "inputDisplay": "Output 3", + "value": "output3", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.tsx new file mode 100644 index 000000000000..b09222387999 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { EuiSuperSelectOption } from '@elastic/eui'; + +import { useGetOutputs, useLicense } from '../../../../hooks'; +import { LICENCE_FOR_PER_POLICY_OUTPUT } from '../../../../../../../common'; + +// The super select component do not support null or '' as a value +export const DEFAULT_OUTPUT_VALUE = '@@##DEFAULT_OUTPUT_VALUE##@@'; + +function getDefaultOutput(defaultOutputName?: string) { + return { + inputDisplay: i18n.translate('xpack.fleet.agentPolicy.outputOptions.defaultOutputText', { + defaultMessage: 'Default (currently {defaultOutputName})', + values: { defaultOutputName }, + }), + value: DEFAULT_OUTPUT_VALUE, + }; +} + +export function useOutputOptions() { + const outputsRequest = useGetOutputs(); + const licenseService = useLicense(); + + const isLicenceAllowingPolicyPerOutput = licenseService.hasAtLeast(LICENCE_FOR_PER_POLICY_OUTPUT); + + const outputOptions: Array> = useMemo(() => { + if (outputsRequest.isLoading || !outputsRequest.data) { + return []; + } + + return outputsRequest.data.items.map((item) => ({ + value: item.id, + inputDisplay: item.name, + disabled: !isLicenceAllowingPolicyPerOutput, + })); + }, [outputsRequest, isLicenceAllowingPolicyPerOutput]); + + const dataOutputOptions = useMemo(() => { + if (outputsRequest.isLoading || !outputsRequest.data) { + return []; + } + + const defaultOutputName = outputsRequest.data.items.find((item) => item.is_default)?.name; + return [getDefaultOutput(defaultOutputName), ...outputOptions]; + }, [outputsRequest, outputOptions]); + + const monitoringOutputOptions = useMemo(() => { + if (outputsRequest.isLoading || !outputsRequest.data) { + return []; + } + + const defaultOutputName = outputsRequest.data.items.find( + (item) => item.is_default_monitoring + )?.name; + return [getDefaultOutput(defaultOutputName), ...outputOptions]; + }, [outputsRequest, outputOptions]); + + return useMemo( + () => ({ + dataOutputOptions, + monitoringOutputOptions, + isLoading: outputsRequest.isLoading, + }), + [dataOutputOptions, monitoringOutputOptions, outputsRequest.isLoading] + ); +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx similarity index 77% rename from x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx index d26dc83084a2..305008513d01 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx @@ -17,20 +17,23 @@ import { EuiLink, EuiFieldNumber, EuiFieldText, + EuiSuperSelect, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; -import { dataTypes } from '../../../../../../common'; -import type { NewAgentPolicy, AgentPolicy } from '../../../types'; -import { useStartServices } from '../../../hooks'; +import { dataTypes } from '../../../../../../../common'; +import type { NewAgentPolicy, AgentPolicy } from '../../../../types'; +import { useStartServices } from '../../../../hooks'; -import { AgentPolicyPackageBadge } from '../../../components'; +import { AgentPolicyPackageBadge } from '../../../../components'; -import { policyHasFleetServer } from '../../agents/services/has_fleet_server'; +import { policyHasFleetServer } from '../../../agents/services/has_fleet_server'; -import { AgentPolicyDeleteProvider } from './agent_policy_delete_provider'; -import type { ValidationResults } from './agent_policy_validation'; +import { AgentPolicyDeleteProvider } from '../agent_policy_delete_provider'; +import type { ValidationResults } from '../agent_policy_validation'; + +import { useOutputOptions, DEFAULT_OUTPUT_VALUE } from './hooks'; interface Props { agentPolicy: Partial; @@ -49,6 +52,11 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent = }) => { const { docLinks } = useStartServices(); const [touchedFields, setTouchedFields] = useState<{ [key: string]: boolean }>({}); + const { + dataOutputOptions, + monitoringOutputOptions, + isLoading: isLoadingOptions, + } = useOutputOptions(); // agent monitoring checkbox group can appear multiple times in the DOM, ids have to be unique to work correctly const monitoringCheckboxIdSuffix = Date.now(); @@ -275,6 +283,82 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent = /> + + + + } + description={ + + } + > + + { + updateAgentPolicy({ + data_output_id: e !== DEFAULT_OUTPUT_VALUE ? e : null, + }); + }} + options={dataOutputOptions} + /> + + + + + + } + description={ + + } + > + + { + updateAgentPolicy({ + monitoring_output_id: e !== DEFAULT_OUTPUT_VALUE ? e : null, + }); + }} + options={monitoringOutputOptions} + /> + + {isEditing && 'id' in agentPolicy && !agentPolicy.is_managed ? ( ( const submitUpdateAgentPolicy = async () => { setIsLoading(true); try { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { name, description, namespace, monitoring_enabled, unenroll_timeout } = agentPolicy; + const { + name, + description, + namespace, + // eslint-disable-next-line @typescript-eslint/naming-convention + monitoring_enabled, + // eslint-disable-next-line @typescript-eslint/naming-convention + unenroll_timeout, + // eslint-disable-next-line @typescript-eslint/naming-convention + data_output_id, + // eslint-disable-next-line @typescript-eslint/naming-convention + monitoring_output_id, + } = agentPolicy; const { data, error } = await sendUpdateAgentPolicy(agentPolicy.id, { name, description, namespace, monitoring_enabled, unenroll_timeout, + data_output_id, + monitoring_output_id, }); if (data) { notifications.toasts.addSuccess( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/input_type_utils.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/input_type_utils.ts index dd1197a8ee2d..3f3660582d49 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/input_type_utils.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/input_type_utils.ts @@ -11,6 +11,10 @@ import { STATE_DATASET_FIELD, AGENT_DATASET_FILEBEAT, AGENT_DATASET_METRICBEAT, + AGENT_DATASET_APM_SERVER, + AGENT_DATASET_ENDPOINT_SECURITY, + AGENT_DATASET_OSQUERYBEAT, + AGENT_DATASET_HEARTBEAT, } from '../agent_logs/constants'; export function displayInputType(inputType: string): string { @@ -40,6 +44,18 @@ export function getLogsQueryByInputType(inputType: string) { if (inputType.match(/\/metrics$/)) { return `(${STATE_DATASET_FIELD}:!(${AGENT_DATASET_METRICBEAT}))`; } + if (inputType === 'osquery') { + return `(${STATE_DATASET_FIELD}:!(${AGENT_DATASET_OSQUERYBEAT}))`; + } + if (inputType.match(/^synthetics\//)) { + return `(${STATE_DATASET_FIELD}:!(${AGENT_DATASET_HEARTBEAT}))`; + } + if (inputType === 'apm') { + return `(${STATE_DATASET_FIELD}:!(${AGENT_DATASET_APM_SERVER}))`; + } + if (inputType === 'endpoint') { + return `(${STATE_DATASET_FIELD}:!(${AGENT_DATASET_ENDPOINT_SECURITY}))`; + } return ''; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx index f4c6e19f0932..18af09c48f22 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx @@ -11,6 +11,10 @@ export const AGENT_LOG_INDEX_PATTERN = 'logs-elastic_agent-*,logs-elastic_agent. export const AGENT_DATASET = 'elastic_agent'; export const AGENT_DATASET_FILEBEAT = 'elastic_agent.filebeat'; export const AGENT_DATASET_METRICBEAT = 'elastic_agent.metricbeat'; +export const AGENT_DATASET_OSQUERYBEAT = 'elastic_agent.osquerybeat'; +export const AGENT_DATASET_HEARTBEAT = 'elastic_agent.heartbeat'; +export const AGENT_DATASET_APM_SERVER = 'elastic_agent.apm_server'; +export const AGENT_DATASET_ENDPOINT_SECURITY = 'elastic_agent.endpoint_security'; export const AGENT_DATASET_PATTERN = 'elastic_agent.*'; export const AGENT_ID_FIELD = { name: 'elastic_agent.id', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/data_stream/list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/data_stream/list_page/index.tsx index 29ea4102bc1e..a4693407569e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/data_stream/list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/data_stream/list_page/index.tsx @@ -16,10 +16,10 @@ import { EuiInMemoryTable, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage, FormattedDate } from '@kbn/i18n-react'; +import { FormattedMessage, FormattedDate, FormattedTime } from '@kbn/i18n-react'; import type { DataStream } from '../../../types'; -import { useGetDataStreams, useStartServices, usePagination, useBreadcrumbs } from '../../../hooks'; +import { useGetDataStreams, usePagination, useBreadcrumbs } from '../../../hooks'; import { PackageIcon } from '../../../components'; import { DataStreamRowActions } from './components/data_stream_row_actions'; @@ -27,8 +27,6 @@ import { DataStreamRowActions } from './components/data_stream_row_actions'; export const DataStreamListPage: React.FunctionComponent<{}> = () => { useBreadcrumbs('data_streams'); - const { fieldFormats } = useStartServices(); - const { pagination, pageSizeOptions } = usePagination(); // Fetch data streams @@ -97,30 +95,21 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => { defaultMessage: 'Last activity', }), render: (date: DataStream['last_activity_ms']) => { - try { - const formatter = fieldFormats.getInstance('date', { - pattern: 'MMM D, YYYY @ HH:mm:ss', - }); - return formatter.convert(date); - } catch (e) { - return ; - } + return ( + <> + + <> @ + + + ); }, }, { - field: 'size_in_bytes', + field: 'size_in_bytes_formatted', sortable: true, name: i18n.translate('xpack.fleet.dataStreamList.sizeColumnTitle', { defaultMessage: 'Size', }), - render: (size: DataStream['size_in_bytes']) => { - try { - const formatter = fieldFormats.getInstance('bytes'); - return formatter.convert(size); - } catch (e) { - return `${size}b`; - } - }, }, { name: i18n.translate('xpack.fleet.dataStreamList.actionsColumnTitle', { @@ -134,7 +123,7 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => { }, ]; return cols; - }, [fieldFormats]); + }, []); const emptyPrompt = useMemo( () => ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/confirm_update.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/confirm_update.tsx new file mode 100644 index 000000000000..61addb3e6d3e --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/confirm_update.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 type { Output } from '../../../../types'; +import type { useConfirmModal } from '../../hooks/use_confirm_modal'; +import { getAgentAndPolicyCountForOutput } from '../../services/agent_and_policies_count'; + +const ConfirmTitle = () => ( + +); + +interface ConfirmDescriptionProps { + output: Output; + agentCount: number; + agentPolicyCount: number; +} + +const ConfirmDescription: React.FunctionComponent = ({ + output, + agentCount, + agentPolicyCount, +}) => ( + {output.name}, + agents: ( + + + + ), + policies: ( + + + + ), + }} + /> +); + +export async function confirmUpdate( + output: Output, + confirm: ReturnType['confirm'] +) { + const { agentCount, agentPolicyCount } = await getAgentAndPolicyCountForOutput(output); + return confirm( + , + + ); +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx new file mode 100644 index 000000000000..46bdc6d5b178 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import type { Output } from '../../../../types'; +import { createFleetTestRendererMock } from '../../../../../../mock'; + +import { EditOutputFlyout } from '.'; + +// mock yaml code editor +jest.mock('../../../../../../../../../../src/plugins/kibana_react/public/code_editor', () => ({ + CodeEditor: () => <>CODE EDITOR, +})); +jest.mock('../../../../../../hooks/use_fleet_status', () => ({ + FleetStatusProvider: (props: any) => { + return props.children; + }, +})); + +function renderFlyout(output?: Output) { + const renderer = createFleetTestRendererMock(); + + const utils = renderer.render( {}} />); + + return { utils }; +} +describe('EditOutputFlyout', () => { + it('should render the flyout if there is not output provided', async () => { + renderFlyout(); + }); + + it('should render the flyout if the output provided is a ES output', async () => { + const { utils } = renderFlyout({ + type: 'elasticsearch', + name: 'elasticsearch output', + id: 'output123', + is_default: false, + is_default_monitoring: false, + }); + + expect( + utils.queryByLabelText('Elasticsearch CA trusted fingerprint (optional)') + ).not.toBeNull(); + // Does not show logstash SSL inputs + expect(utils.queryByLabelText('Client SSL certificate key')).toBeNull(); + expect(utils.queryByLabelText('Client SSL certificate')).toBeNull(); + expect(utils.queryByLabelText('Server SSL certificate authorities')).toBeNull(); + }); + + it('should render the flyout if the output provided is a logstash output', async () => { + const { utils } = renderFlyout({ + type: 'logstash', + name: 'logstash output', + id: 'output123', + is_default: false, + is_default_monitoring: false, + }); + + // Show logstash SSL inputs + expect(utils.queryByLabelText('Client SSL certificate key')).not.toBeNull(); + expect(utils.queryByLabelText('Client SSL certificate')).not.toBeNull(); + expect(utils.queryByLabelText('Server SSL certificate authorities')).not.toBeNull(); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx index 6190d2b13c8f..f366f32a36b8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx @@ -20,16 +20,20 @@ import { EuiForm, EuiFormRow, EuiFieldText, + EuiTextArea, EuiSelect, EuiSwitch, EuiCallOut, EuiSpacer, + EuiLink, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { HostsInput } from '../hosts_input'; +import { MultiRowInput } from '../multi_row_input'; import type { Output } from '../../../../types'; import { FLYOUT_MAX_WIDTH } from '../../constants'; +import { LogstashInstructions } from '../logstash_instructions'; +import { useBreadcrumbs, useStartServices } from '../../../../hooks'; import { YamlCodeEditorWithPlaceholder } from './yaml_code_editor_with_placeholder'; import { useOutputForm } from './use_output_form'; @@ -39,12 +43,22 @@ export interface EditOutputFlyoutProps { onClose: () => void; } +const OUTPUT_TYPE_OPTIONS = [ + { value: 'elasticsearch', text: 'Elasticsearch' }, + { value: 'logstash', text: 'Logstash' }, +]; + export const EditOutputFlyout: React.FunctionComponent = ({ onClose, output, }) => { + useBreadcrumbs('settings'); const form = useOutputForm(onClose, output); const inputs = form.inputs; + const { docLinks } = useStartServices(); + + const isLogstashOutput = inputs.typeInput.value === 'logstash'; + const isESOutput = inputs.typeInput.value === 'elasticsearch'; return ( @@ -120,7 +134,7 @@ export const EditOutputFlyout: React.FunctionComponent = = )} /> - - - } - {...inputs.caTrustedFingerprintInput.formRowProps} - > - + + + + + )} + {isESOutput && ( + + )} + {isLogstashOutput && ( + + + + ), + }} + /> + } + label={i18n.translate( + 'xpack.fleet.settings.editOutputFlyout.logstashHostsInputLabel', + { + defaultMessage: 'Logstash hosts', + } + )} + {...inputs.logstashHostsInput.props} + /> + )} + {isESOutput && ( + + } + {...inputs.caTrustedFingerprintInput.formRowProps} + > + + + )} + {isLogstashOutput && ( + - + )} + {isLogstashOutput && ( + + } + {...inputs.sslCertificateInput.formRowProps} + > + + + )} + {isLogstashOutput && ( + + } + {...inputs.sslKeyInput.formRowProps} + > + + + )} = 'xpack.fleet.settings.editOutputFlyout.yamlConfigInputPlaceholder', { defaultMessage: - '# YAML settings here will be added to the Elasticsearch output section of each agent policy.', + '# YAML settings here will be added to the output section of each agent policy.', } )} /> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx index 4f8b147e8044..7f414a8f12de 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx @@ -6,39 +6,40 @@ */ import { - validateHosts, + validateESHosts, + validateLogstashHosts, validateYamlConfig, validateCATrustedFingerPrint, } from './output_form_validators'; describe('Output form validation', () => { - describe('validateHosts', () => { + describe('validateESHosts', () => { it('should work without any urls', () => { - const res = validateHosts([]); + const res = validateESHosts([]); expect(res).toBeUndefined(); }); it('should work with valid url', () => { - const res = validateHosts(['https://test.fr:9200']); + const res = validateESHosts(['https://test.fr:9200']); expect(res).toBeUndefined(); }); it('should return an error with invalid url', () => { - const res = validateHosts(['toto']); + const res = validateESHosts(['toto']); expect(res).toEqual([{ index: 0, message: 'Invalid URL' }]); }); it('should return an error with url with invalid port', () => { - const res = validateHosts(['https://test.fr:qwerty9200']); + const res = validateESHosts(['https://test.fr:qwerty9200']); expect(res).toEqual([{ index: 0, message: 'Invalid URL' }]); }); it('should return an error with multiple invalid urls', () => { - const res = validateHosts(['toto', 'tata']); + const res = validateESHosts(['toto', 'tata']); expect(res).toEqual([ { index: 0, message: 'Invalid URL' }, @@ -46,7 +47,7 @@ describe('Output form validation', () => { ]); }); it('should return an error with duplicate urls', () => { - const res = validateHosts(['http://test.fr', 'http://test.fr']); + const res = validateESHosts(['http://test.fr', 'http://test.fr']); expect(res).toEqual([ { index: 0, message: 'Duplicate URL' }, @@ -54,6 +55,27 @@ describe('Output form validation', () => { ]); }); }); + + describe('validateLogstashHosts', () => { + it('should work for valid hosts', () => { + const res = validateLogstashHosts(['test.fr:5044']); + + expect(res).toBeUndefined(); + }); + it('should throw for invalid hosts starting with http', () => { + const res = validateLogstashHosts(['https://test.fr:5044']); + + expect(res).toEqual([ + { index: 0, message: 'Invalid logstash host should not start with http(s)' }, + ]); + }); + + it('should throw for invalid host', () => { + const res = validateLogstashHosts(['$#!$!@#!@#@!#!@#@#:!@#!@#']); + + expect(res).toEqual([{ index: 0, message: 'Invalid Host' }]); + }); + }); describe('validateYamlConfig', () => { it('should work with an empty yaml', () => { const res = validateYamlConfig(``); 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 3a9e42c152cc..64b3353c8b2c 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 @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { safeLoad } from 'js-yaml'; -export function validateHosts(value: string[]) { +export function validateESHosts(value: string[]) { const res: Array<{ message: string; index: number }> = []; const urlIndexes: { [key: string]: number[] } = {}; value.forEach((val, idx) => { @@ -48,6 +48,57 @@ export function validateHosts(value: string[]) { } } +export function validateLogstashHosts(value: string[]) { + const res: Array<{ message: string; index: number }> = []; + const urlIndexes: { [key: string]: number[] } = {}; + value.forEach((val, idx) => { + try { + if (val.match(/^http([s]){0,1}:\/\//)) { + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.logstashHostProtocolError', { + defaultMessage: 'Invalid logstash host should not start with http(s)', + }), + index: idx, + }); + return; + } + + const url = new URL(`http://${val}`); + + if (url.host !== val) { + throw new Error('Invalid host'); + } + } catch (error) { + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.logstashHostError', { + defaultMessage: 'Invalid Host', + }), + index: idx, + }); + } + + const curIndexes = urlIndexes[val] || []; + urlIndexes[val] = [...curIndexes, idx]; + }); + + Object.values(urlIndexes) + .filter(({ length }) => length > 1) + .forEach((indexes) => { + indexes.forEach((index) => + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.elasticHostDuplicateError', { + defaultMessage: 'Duplicate URL', + }), + index, + }) + ); + }); + + if (res.length) { + return res; + } +} + export function validateYamlConfig(value: string) { try { safeLoad(value); @@ -81,3 +132,23 @@ export function validateCATrustedFingerPrint(value: string) { ]; } } + +export function validateSSLCertificate(value: string) { + if (!value || value === '') { + return [ + i18n.translate('xpack.fleet.settings.outputForm.sslCertificateRequiredErrorMessage', { + defaultMessage: 'SSL certificate is required', + }), + ]; + } +} + +export function validateSSLKey(value: string) { + if (!value || value === '') { + return [ + i18n.translate('xpack.fleet.settings.outputForm.sslKeyRequiredErrorMessage', { + defaultMessage: 'SSL key is required', + }), + ]; + } +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx index b99caf8eba64..cd927923a4fe 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx @@ -5,10 +5,9 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; +import { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import { sendPostOutput, @@ -20,78 +19,17 @@ import { } from '../../../../hooks'; import type { Output, PostOutputRequest } from '../../../../types'; import { useConfirmModal } from '../../hooks/use_confirm_modal'; -import { getAgentAndPolicyCountForOutput } from '../../services/agent_and_policies_count'; import { validateName, - validateHosts, + validateESHosts, + validateLogstashHosts, validateYamlConfig, validateCATrustedFingerPrint, + validateSSLCertificate, + validateSSLKey, } from './output_form_validators'; - -const ConfirmTitle = () => ( - -); - -interface ConfirmDescriptionProps { - output: Output; - agentCount: number; - agentPolicyCount: number; -} - -const ConfirmDescription: React.FunctionComponent = ({ - output, - agentCount, - agentPolicyCount, -}) => ( - {output.name}, - agents: ( - - - - ), - policies: ( - - - - ), - }} - /> -); - -async function confirmUpdate( - output: Output, - confirm: ReturnType['confirm'] -) { - const { agentCount, agentPolicyCount } = await getAgentAndPolicyCountForOutput(output); - return confirm( - , - - ); -} +import { confirmUpdate } from './confirm_update'; export function useOutputForm(onSucess: () => void, output?: Output) { const [isLoading, setIsloading] = useState(false); @@ -102,26 +40,15 @@ export function useOutputForm(onSucess: () => void, output?: Output) { const isPreconfigured = output?.is_preconfigured ?? false; // Define inputs + // Shared inputs const nameInput = useInput(output?.name ?? '', validateName, isPreconfigured); - const typeInput = useInput(output?.type ?? '', undefined, isPreconfigured); - const elasticsearchUrlInput = useComboInput( - 'esHostsComboxBox', - output?.hosts ?? [], - validateHosts, - isPreconfigured - ); + const typeInput = useInput(output?.type ?? 'elasticsearch', undefined, isPreconfigured); const additionalYamlConfigInput = useInput( output?.config_yaml ?? '', validateYamlConfig, isPreconfigured ); - const caTrustedFingerprintInput = useInput( - output?.ca_trusted_fingerprint ?? '', - validateCATrustedFingerPrint, - isPreconfigured - ); - const defaultOutputInput = useSwitchInput( output?.is_default ?? false, isPreconfigured || output?.is_default @@ -131,14 +58,53 @@ export function useOutputForm(onSucess: () => void, output?: Output) { isPreconfigured || output?.is_default_monitoring ); + // ES inputs + const caTrustedFingerprintInput = useInput( + output?.ca_trusted_fingerprint ?? '', + validateCATrustedFingerPrint, + isPreconfigured + ); + const elasticsearchUrlInput = useComboInput( + 'esHostsComboxBox', + output?.hosts ?? [], + validateESHosts, + isPreconfigured + ); + // Logstash inputs + const logstashHostsInput = useComboInput( + 'logstashHostsComboxBox', + output?.hosts ?? [], + validateLogstashHosts, + isPreconfigured + ); + const sslCertificateAuthoritiesInput = useComboInput( + 'sslCertificateAuthoritiesComboxBox', + output?.ssl?.certificate_authorities ?? [], + undefined, + isPreconfigured + ); + const sslCertificateInput = useInput( + output?.ssl?.certificate ?? '', + validateSSLCertificate, + isPreconfigured + ); + + const sslKeyInput = useInput(output?.ssl?.key ?? '', validateSSLKey, isPreconfigured); + + const isLogstash = typeInput.value === 'logstash'; + const inputs = { nameInput, typeInput, elasticsearchUrlInput, + logstashHostsInput, additionalYamlConfigInput, defaultOutputInput, defaultMonitoringOutputInput, caTrustedFingerprintInput, + sslCertificateInput, + sslKeyInput, + sslCertificateAuthoritiesInput, }; const hasChanged = Object.values(inputs).some((input) => input.hasChanged); @@ -146,20 +112,40 @@ export function useOutputForm(onSucess: () => void, output?: Output) { const validate = useCallback(() => { const nameInputValid = nameInput.validate(); const elasticsearchUrlsValid = elasticsearchUrlInput.validate(); + const logstashHostsValid = logstashHostsInput.validate(); const additionalYamlConfigValid = additionalYamlConfigInput.validate(); const caTrustedFingerprintValid = caTrustedFingerprintInput.validate(); - - if ( - !elasticsearchUrlsValid || - !additionalYamlConfigValid || - !nameInputValid || - !caTrustedFingerprintValid - ) { - return false; + const sslCertificateValid = sslCertificateInput.validate(); + const sslKeyValid = sslKeyInput.validate(); + + if (isLogstash) { + // validate logstash + return ( + logstashHostsValid && + additionalYamlConfigValid && + nameInputValid && + sslCertificateValid && + sslKeyValid + ); + } else { + // validate ES + return ( + elasticsearchUrlsValid && + additionalYamlConfigValid && + nameInputValid && + caTrustedFingerprintValid + ); } - - return true; - }, [nameInput, elasticsearchUrlInput, additionalYamlConfigInput, caTrustedFingerprintInput]); + }, [ + isLogstash, + nameInput, + sslCertificateInput, + sslKeyInput, + elasticsearchUrlInput, + logstashHostsInput, + additionalYamlConfigInput, + caTrustedFingerprintInput, + ]); const submit = useCallback(async () => { try { @@ -168,15 +154,31 @@ export function useOutputForm(onSucess: () => void, output?: Output) { } setIsloading(true); - const data: PostOutputRequest['body'] = { - name: nameInput.value, - type: 'elasticsearch', - hosts: elasticsearchUrlInput.value, - is_default: defaultOutputInput.value, - is_default_monitoring: defaultMonitoringOutputInput.value, - config_yaml: additionalYamlConfigInput.value, - ca_trusted_fingerprint: caTrustedFingerprintInput.value, - }; + const data: PostOutputRequest['body'] = isLogstash + ? { + name: nameInput.value, + type: typeInput.value as 'elasticsearch' | 'logstash', + hosts: logstashHostsInput.value, + is_default: defaultOutputInput.value, + is_default_monitoring: defaultMonitoringOutputInput.value, + config_yaml: additionalYamlConfigInput.value, + ssl: { + certificate: sslCertificateInput.value, + key: sslKeyInput.value, + certificate_authorities: sslCertificateAuthoritiesInput.value.filter( + (val) => val !== '' + ), + }, + } + : { + name: nameInput.value, + type: typeInput.value as 'elasticsearch' | 'logstash', + hosts: elasticsearchUrlInput.value, + is_default: defaultOutputInput.value, + is_default_monitoring: defaultMonitoringOutputInput.value, + config_yaml: additionalYamlConfigInput.value, + ca_trusted_fingerprint: caTrustedFingerprintInput.value, + }; if (output) { // Update @@ -208,14 +210,21 @@ export function useOutputForm(onSucess: () => void, output?: Output) { }); } }, [ + isLogstash, validate, confirm, additionalYamlConfigInput.value, defaultMonitoringOutputInput.value, defaultOutputInput.value, elasticsearchUrlInput.value, + logstashHostsInput.value, caTrustedFingerprintInput.value, + sslCertificateInput.value, + sslCertificateAuthoritiesInput.value, + sslKeyInput.value, nameInput.value, + typeInput.value, + notifications.toasts, onSucess, output, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/index.tsx index 175f95b029ba..57c9ded6609b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/index.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; import { EuiFlyout, EuiFlyoutBody, @@ -22,7 +23,7 @@ import { EuiSpacer, } from '@elastic/eui'; -import { HostsInput } from '../hosts_input'; +import { MultiRowInput } from '../multi_row_input'; import { useStartServices } from '../../../../hooks'; import { FLYOUT_MAX_WIDTH } from '../../constants'; @@ -75,7 +76,16 @@ export const FleetServerHostsFlyout: React.FunctionComponent - + diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/helpers.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/helpers.tsx new file mode 100644 index 000000000000..aecfe39c7e32 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/helpers.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const LOGSTASH_CONFIG_PIPELINES = `- pipeline.id: elastic-agent-pipeline + path.config: "/etc/path/to/elastic-agent-pipeline.config" +`; + +export function getLogstashPipeline(apiKey?: string) { + return `input { + elastic_agent { + port => 5044 + ssl => true + ssl_certificate_authorities => [""] + ssl_certificate => "" + ssl_key => "" + ssl_verification_mode => "force-peer" + } +} + +output { + elasticsearch { + hosts => "" + api_key => "" + data_stream => true + # ca_cert: + } +}`.replace('', apiKey || ''); +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/hooks.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/hooks.ts new file mode 100644 index 000000000000..228140ac290b --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/hooks.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useMemo, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { sendPostLogstashApiKeys, useStartServices } from '../../../../hooks'; + +export function useLogstashApiKey() { + const [isLoading, setIsLoading] = useState(false); + const [apiKey, setApiKey] = useState(); + const { notifications } = useStartServices(); + + const generateApiKey = useCallback(async () => { + try { + setIsLoading(true); + + const res = await sendPostLogstashApiKeys(); + if (res.error) { + throw res.error; + } + + setApiKey(res.data?.api_key); + } catch (err) { + notifications.toasts.addError(err, { + title: i18n.translate('xpack.fleet.settings.logstashInstructions.generateApiKeyError', { + defaultMessage: 'Impossible to generate an api key', + }), + }); + } finally { + setIsLoading(false); + } + }, [notifications.toasts]); + + return useMemo( + () => ({ + isLoading, + generateApiKey, + apiKey, + }), + [isLoading, generateApiKey, apiKey] + ); +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/index.tsx new file mode 100644 index 000000000000..2e7924711e55 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/index.tsx @@ -0,0 +1,226 @@ +/* + * Copyright 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, useMemo } from 'react'; + +import { + EuiCallOut, + EuiButton, + EuiSpacer, + EuiLink, + EuiCodeBlock, + EuiCopy, + EuiButtonIcon, +} from '@elastic/eui'; +import type { EuiCallOutProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; + +import { useStartServices } from '../../../../hooks'; + +import { getLogstashPipeline, LOGSTASH_CONFIG_PIPELINES } from './helpers'; +import { useLogstashApiKey } from './hooks'; + +export const LogstashInstructions = () => { + const { docLinks } = useStartServices(); + + return ( + + } + > + <> + + + + ), + }} + /> + + + + + ); +}; + +const CollapsibleCallout: React.FunctionComponent = ({ children, ...props }) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + {isOpen ? ( + setIsOpen(false)}> + + + ) : ( + setIsOpen(true)} fill={true}> + + + )} + {isOpen && ( + <> + + {children} + + )} + + ); +}; + +const LogstashInstructionSteps = () => { + const { docLinks } = useStartServices(); + const logstashApiKey = useLogstashApiKey(); + + const steps = useMemo( + () => [ + { + children: ( + <> + + + {logstashApiKey.apiKey ? ( + +
API Key
+ {logstashApiKey.apiKey} + + {(copy) => ( +
+
+ +
+
+ )} +
+
+ ) : ( + + + + )} + + + ), + }, + { + children: ( + <> + + + + {LOGSTASH_CONFIG_PIPELINES} + + + ), + }, + { + children: ( + <> + + + + {getLogstashPipeline(logstashApiKey.apiKey)} + + + ), + }, + { + children: ( + <> + + + + ), + }} + /> + + + + + + + ), + }, + { + children: ( + <> + + + + ), + }, + ], + [logstashApiKey, docLinks] + ); + + return ( +
    + {steps.map((step, idx) => ( +
  1. {step.children}
  2. + ))} +
+ ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.stories.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.stories.tsx similarity index 94% rename from x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.stories.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.stories.tsx index 9b4674f3ce77..4401cc3e1e49 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.stories.tsx @@ -9,7 +9,7 @@ import { useState } from '@storybook/addons'; import { addParameters } from '@storybook/react'; import React from 'react'; -import { HostsInput as Component } from '.'; +import { MultiRowInput as Component } from '.'; addParameters({ options: { @@ -19,7 +19,7 @@ addParameters({ export default { component: Component, - title: 'Sections/Fleet/Settings/HostInput', + title: 'Sections/Fleet/Settings/MultiRowInput', }; interface Args { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.test.tsx similarity index 94% rename from x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.test.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.test.tsx index 4d556cd2749c..f3fcdfabc772 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.test.tsx @@ -10,7 +10,7 @@ import { fireEvent, act } from '@testing-library/react'; import { createFleetTestRendererMock } from '../../../../../../mock'; -import { HostsInput } from '.'; +import { MultiRowInput } from '.'; function renderInput( value = ['http://host1.com'], @@ -20,7 +20,7 @@ function renderInput( const renderer = createFleetTestRendererMock(); const utils = renderer.render( - { const { utils, mockOnChange } = renderInput(['http://host1.com', 'http://host2.com']); await act(async () => { - const deleteRowEl = await utils.container.querySelector('[aria-label="Delete host"]'); + const deleteRowEl = await utils.container.querySelector('[aria-label="Delete row"]'); if (!deleteRowEl) { - throw new Error('Delete host button not found'); + throw new Error('Delete row button not found'); } fireEvent.click(deleteRowEl); }); @@ -115,7 +115,7 @@ test('Should remove error when item deleted', async () => { mockOnChange.mockImplementation((newValue) => { utils.rerender( - { }); await act(async () => { - const deleteRowButtons = await utils.container.querySelectorAll('[aria-label="Delete host"]'); + const deleteRowButtons = await utils.container.querySelectorAll('[aria-label="Delete row"]'); if (deleteRowButtons.length !== 3) { - throw new Error('Delete host buttons not found'); + throw new Error('Delete row buttons not found'); } fireEvent.click(deleteRowButtons[1]); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.tsx similarity index 59% rename from x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.tsx index 712bc7256977..1497d2a422ce 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.tsx @@ -23,13 +23,14 @@ import { EuiFormHelpText, euiDragDropReorder, EuiFormErrorText, + EuiTextArea, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { EuiTheme } from '../../../../../../../../../../src/plugins/kibana_react/common'; -export interface HostInputProps { +export interface MultiRowInputProps { id: string; value: string[]; onChange: (newValue: string[]) => void; @@ -38,17 +39,35 @@ export interface HostInputProps { errors?: Array<{ message: string; index?: number }>; isInvalid?: boolean; disabled?: boolean; + placeholder?: string; + multiline?: boolean; + sortable?: boolean; } interface SortableTextFieldProps { id: string; index: number; value: string; - onChange: (e: ChangeEvent) => void; + onChange: (e: ChangeEvent) => void; onDelete: (index: number) => void; errors?: string[]; autoFocus?: boolean; disabled?: boolean; + placeholder?: string; + multiline?: boolean; +} + +interface NonSortableTextFieldProps { + index: number; + value: string; + onChange: (e: ChangeEvent) => void; + onDelete: (index: number) => void; + errors?: string[]; + autoFocus?: boolean; + disabled?: boolean; + placeholder?: string; + multiline?: boolean; + deletable?: boolean; } const DraggableDiv = sytled.div` @@ -64,7 +83,18 @@ function displayErrors(errors?: string[]) { } const SortableTextField: FunctionComponent = React.memo( - ({ id, index, value, onChange, onDelete, autoFocus, errors, disabled }) => { + ({ + id, + index, + multiline, + value, + onChange, + onDelete, + placeholder, + autoFocus, + errors, + disabled, + }) => { const onDeleteHandler = useCallback(() => { onDelete(index); }, [onDelete, index]); @@ -106,6 +136,83 @@ const SortableTextField: FunctionComponent = React.memo(
+ {multiline ? ( + + ) : ( + + )} + {displayErrors(errors)} + + + + +
+ )} + + ); + } +); + +const NonSortableTextField: FunctionComponent = React.memo( + ({ + index, + deletable, + multiline, + value, + onChange, + onDelete, + placeholder, + autoFocus, + errors, + disabled, + }) => { + const onDeleteHandler = useCallback(() => { + onDelete(index); + }, [onDelete, index]); + + const isInvalid = (errors?.length ?? 0) > 0; + + return ( + <> + {index > 0 && } + + + + {multiline ? ( + + ) : ( = React.memo( autoFocus={autoFocus} isInvalid={isInvalid} disabled={disabled} - placeholder={i18n.translate('xpack.fleet.hostsInput.placeholder', { - defaultMessage: 'Specify host URL', - })} + placeholder={placeholder} /> - {displayErrors(errors)} - + )} + {displayErrors(errors)} + + {deletable && ( - - )} - + )} + + ); } ); -export const HostsInput: FunctionComponent = ({ +export const MultiRowInput: FunctionComponent = ({ id, value: valueFromProps, onChange, @@ -146,6 +253,9 @@ export const HostsInput: FunctionComponent = ({ isInvalid, errors, disabled, + placeholder, + multiline = false, + sortable = true, }) => { const [autoFocus, setAutoFocus] = useState(false); const value = useMemo(() => { @@ -156,7 +266,7 @@ export const HostsInput: FunctionComponent = ({ () => value.map((host, idx) => ({ value: host, - onChange: (e: ChangeEvent) => { + onChange: (e: ChangeEvent) => { const newValue = [...value]; newValue[idx] = e.target.value; @@ -215,17 +325,19 @@ export const HostsInput: FunctionComponent = ({ return errors && errors.filter((err) => err.index === undefined).map(({ message }) => message); }, [errors]); - const isSortable = rows.length > 1; + const isSortable = sortable && rows.length > 1; + return ( <> {helpText} {helpText && } - - - {rows.map((row, idx) => ( - - {isSortable ? ( + + {isSortable ? ( + + + {rows.map((row, idx) => ( + = ({ autoFocus={autoFocus} errors={indexedErrors[idx]} disabled={disabled} + placeholder={placeholder} /> - ) : ( - <> - - {displayErrors(indexedErrors[idx])} - - )} - - ))} - - + + ))} + + + ) : ( + rows.map((row, idx) => ( + 1} + /> + )) + )} {displayErrors(globalErrors)} = ({ iconType="plusInCircle" onClick={addRowHandler} > - + diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/output_section.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/output_section.tsx index 835a3576da77..6f053316ac58 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/output_section.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/output_section.tsx @@ -12,7 +12,6 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { useLink } from '../../../../hooks'; import type { Output } from '../../../../types'; import { OutputsTable } from '../outputs_table'; -import { FEATURE_ADD_OUTPUT_ENABLED } from '../../constants'; export interface OutputSectionProps { outputs: Output[]; @@ -42,14 +41,16 @@ export const OutputSection: React.FunctionComponent = ({ - {FEATURE_ADD_OUTPUT_ENABLED && ( - - - - )} + + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/constants/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/constants/index.tsx index b609c4c25308..8d29433e7232 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/constants/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/constants/index.tsx @@ -6,5 +6,3 @@ */ export const FLYOUT_MAX_WIDTH = 670; - -export const FEATURE_ADD_OUTPUT_ENABLED = false; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/index.tsx index 5a393ee74ea7..c586e8826194 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/index.tsx @@ -19,7 +19,6 @@ import { withConfirmModalProvider } from './hooks/use_confirm_modal'; import { FleetServerHostsFlyout } from './components/fleet_server_hosts_flyout'; import { EditOutputFlyout } from './components/edit_output_flyout'; import { useDeleteOutput } from './hooks/use_delete_output'; -import { FEATURE_ADD_OUTPUT_ENABLED } from './constants'; export const SettingsApp = withConfirmModalProvider(() => { useBreadcrumbs('settings'); @@ -64,13 +63,11 @@ export const SettingsApp = withConfirmModalProvider(() => { /> - {FEATURE_ADD_OUTPUT_ENABLED && ( - - - - - - )} + + + + + {(route: { match: { params: { outputId: string } } }) => { const output = outputs.data?.items.find((o) => route.match.params.outputId === o.id); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index 2ba625ea420e..d002a743e77b 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -138,9 +138,20 @@ export function Detail() { data: packageInfoData, error: packageInfoError, isLoading: packageInfoLoading, + isInitialRequest: packageIsInitialRequest, + resendRequest: refreshPackageInfo, } = useGetPackageInfoByKey(pkgName, pkgVersion); - const isLoading = packageInfoLoading || permissionCheck.isLoading; + // Refresh package info when status change + const [oldPackageInstallStatus, setOldPackageStatus] = useState(packageInstallStatus); + useEffect(() => { + if (oldPackageInstallStatus === 'not_installed' && packageInstallStatus === 'installed') { + setOldPackageStatus(oldPackageInstallStatus); + refreshPackageInfo(); + } + }, [packageInstallStatus, oldPackageInstallStatus, refreshPackageInfo]); + + const isLoading = (packageInfoLoading && !packageIsInitialRequest) || permissionCheck.isLoading; const showCustomTab = useUIExtension(packageInfoData?.item.name ?? '', 'package-detail-custom') !== undefined; diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx index 826069261610..3227308d93b9 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx @@ -162,7 +162,7 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => { defaultMessage: 'Agent policy', } )} - hasNoInitialSelection={agentPolicies.length > 1} + hasNoInitialSelection={!selectedAgentPolicyId} data-test-subj="agentPolicyDropdown" isInvalid={!selectedAgentPolicyId} /> diff --git a/x-pack/plugins/fleet/public/components/alpha_flyout.tsx b/x-pack/plugins/fleet/public/components/alpha_flyout.tsx deleted file mode 100644 index d8ee58093485..000000000000 --- a/x-pack/plugins/fleet/public/components/alpha_flyout.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { - EuiButtonEmpty, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiFlyoutFooter, - EuiLink, - EuiText, - EuiTitle, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { useStartServices } from '../hooks'; - -interface Props { - onClose: () => void; -} - -export const AlphaFlyout: React.FunctionComponent = ({ onClose }) => { - const { docLinks } = useStartServices(); - - return ( - - - -

- -

-
-
- - -

- -

-

- - - - ), - forumLink: ( - - - - ), - }} - /> -

-
-
- - - - - -
- ); -}; diff --git a/x-pack/plugins/fleet/public/components/alpha_messaging.tsx b/x-pack/plugins/fleet/public/components/alpha_messaging.tsx deleted file mode 100644 index 4db54e25c537..000000000000 --- a/x-pack/plugins/fleet/public/components/alpha_messaging.tsx +++ /dev/null @@ -1,51 +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, { useState } from 'react'; -import styled from 'styled-components'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiText, EuiLink } from '@elastic/eui'; - -import { AlphaFlyout } from './alpha_flyout'; - -const Message = styled(EuiText).attrs((props) => ({ - color: 'subdued', - textAlign: 'center', - size: 's', -}))` - padding: ${(props) => props.theme.eui.paddingSizes.m}; - margin-top: auto; -`; - -export const AlphaMessaging: React.FC<{}> = () => { - const [isAlphaFlyoutOpen, setIsAlphaFlyoutOpen] = useState(false); - - return ( - <> - -

- - - - {' – '} - {' '} - setIsAlphaFlyoutOpen(true)}> - - -

-
- {isAlphaFlyoutOpen && setIsAlphaFlyoutOpen(false)} />} - - ); -}; diff --git a/x-pack/plugins/fleet/public/components/index.ts b/x-pack/plugins/fleet/public/components/index.ts index ce6e09f33eb6..f5372d69d887 100644 --- a/x-pack/plugins/fleet/public/components/index.ts +++ b/x-pack/plugins/fleet/public/components/index.ts @@ -11,8 +11,6 @@ export { PackageIcon } from './package_icon'; export { ContextMenuActions } from './context_menu_actions'; export { LinkedAgentCount } from './linked_agent_count'; export { ExtensionWrapper } from './extension_wrapper'; -export { AlphaMessaging } from './alpha_messaging'; -export { AlphaFlyout } from './alpha_flyout'; export type { HeaderProps } from './header'; export { Header } from './header'; export { NewEnrollmentTokenModal } from './new_enrollment_key_modal'; diff --git a/x-pack/plugins/fleet/public/hooks/use_input.ts b/x-pack/plugins/fleet/public/hooks/use_input.ts index 435cfec95b02..c5f625a3d403 100644 --- a/x-pack/plugins/fleet/public/hooks/use_input.ts +++ b/x-pack/plugins/fleet/public/hooks/use_input.ts @@ -19,7 +19,7 @@ export function useInput( const [hasChanged, setHasChanged] = useState(false); const onChange = useCallback( - (e: React.ChangeEvent) => { + (e: React.ChangeEvent) => { const newValue = e.target.value; setValue(newValue); if (errors && validate && validate(newValue) === undefined) { @@ -155,6 +155,10 @@ export function useComboInput( isInvalid, disabled, }, + formRowProps: { + error: errors, + isInvalid, + }, value, clear: () => { setValue([]); diff --git a/x-pack/plugins/fleet/public/hooks/use_request/outputs.ts b/x-pack/plugins/fleet/public/hooks/use_request/outputs.ts index 8c56ee811e46..24b36df68a5f 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/outputs.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/outputs.ts @@ -6,7 +6,12 @@ */ import { outputRoutesService } from '../../services'; -import type { PutOutputRequest, GetOutputsResponse, PostOutputRequest } from '../../types'; +import type { + PutOutputRequest, + GetOutputsResponse, + PostOutputRequest, + PostLogstashApiKeyResponse, +} from '../../types'; import { sendRequest, useRequest } from './use_request'; @@ -32,6 +37,13 @@ export function sendPutOutput(outputId: string, body: PutOutputRequest['body']) }); } +export function sendPostLogstashApiKeys() { + return sendRequest({ + method: 'post', + path: outputRoutesService.getCreateLogstashApiKeyPath(), + }); +} + export function sendPostOutput(body: PostOutputRequest['body']) { return sendRequest({ method: 'post', diff --git a/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts b/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts index 2545e6370976..842b690eb978 100644 --- a/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts +++ b/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts @@ -12,7 +12,6 @@ import { homePluginMock } from '../../../../../src/plugins/home/public/mocks'; import { navigationPluginMock } from '../../../../../src/plugins/navigation/public/mocks'; import { customIntegrationsMock } from '../../../../../src/plugins/custom_integrations/public/mocks'; import { sharePluginMock } from '../../../../../src/plugins/share/public/mocks'; -import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; export const createSetupDepsMock = () => { const cloud = cloudMock.createSetup(); @@ -28,7 +27,6 @@ export const createStartDepsMock = () => { return { licensing: licensingMock.createStart(), data: dataPluginMock.createStartContract(), - fieldFormats: fieldFormatsServiceMock.createStartContract() as any, navigation: navigationPluginMock.createStartContract(), customIntegrations: customIntegrationsMock.createStart(), share: sharePluginMock.createStartContract(), diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts index b8bda08177a7..4848b0508467 100644 --- a/x-pack/plugins/fleet/public/plugin.ts +++ b/x-pack/plugins/fleet/public/plugin.ts @@ -38,7 +38,6 @@ import type { DataPublicPluginSetup, DataPublicPluginStart, } from '../../../../src/plugins/data/public'; -import type { FieldFormatsStart } from '../../../../src/plugins/field_formats/public/index'; import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; @@ -96,7 +95,6 @@ export interface FleetSetupDeps { export interface FleetStartDeps { licensing: LicensingPluginStart; data: DataPublicPluginStart; - fieldFormats: FieldFormatsStart; navigation: NavigationPublicPluginStart; customIntegrations: CustomIntegrationsStart; share: SharePluginStart; diff --git a/x-pack/plugins/fleet/public/types/index.ts b/x-pack/plugins/fleet/public/types/index.ts index ead44a798cfc..0a5d39c4a1ce 100644 --- a/x-pack/plugins/fleet/public/types/index.ts +++ b/x-pack/plugins/fleet/public/types/index.ts @@ -72,6 +72,7 @@ export type { GetOneEnrollmentAPIKeyResponse, PostEnrollmentAPIKeyRequest, PostEnrollmentAPIKeyResponse, + PostLogstashApiKeyResponse, GetOutputsResponse, PutOutputRequest, PutOutputResponse, diff --git a/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts b/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts index 8cdb93be2814..859a25a0ec7c 100644 --- a/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts +++ b/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts @@ -8,12 +8,44 @@ import { getESAssetMetadata } from '../services/epm/elasticsearch/meta'; const meta = getESAssetMetadata(); +export const MAPPINGS_TEMPLATE_SUFFIX = '@mappings'; + +export const SETTINGS_TEMPLATE_SUFFIX = '@settings'; + +export const USER_SETTINGS_TEMPLATE_SUFFIX = '@custom'; export const FLEET_FINAL_PIPELINE_ID = '.fleet_final_pipeline-1'; -export const FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME = '.fleet_component_template-1'; +export const FLEET_GLOBALS_COMPONENT_TEMPLATE_NAME = '.fleet_globals-1'; + +export const FLEET_GLOBALS_COMPONENT_TEMPLATE_CONTENT = { + _meta: meta, + template: { + settings: {}, + mappings: { + _meta: meta, + // All the dynamic field mappings + dynamic_templates: [ + // This makes sure all mappings are keywords by default + { + strings_as_keyword: { + mapping: { + ignore_above: 1024, + type: 'keyword', + }, + match_mapping_type: 'string', + }, + }, + ], + // As we define fields ahead, we don't need any automatic field detection + // This makes sure all the fields are mapped to keyword by default to prevent mapping conflicts + date_detection: false, + }, + }, +}; +export const FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_NAME = '.fleet_agent_id_verification-1'; -export const FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT = { +export const FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_CONTENT = { _meta: meta, template: { settings: { @@ -40,6 +72,14 @@ export const FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT = { }, }; +export const FLEET_COMPONENT_TEMPLATES = [ + { name: FLEET_GLOBALS_COMPONENT_TEMPLATE_NAME, body: FLEET_GLOBALS_COMPONENT_TEMPLATE_CONTENT }, + { + name: FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_NAME, + body: FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_CONTENT, + }, +]; + export const FLEET_FINAL_PIPELINE_VERSION = 2; // If the content is updated you probably need to update the FLEET_FINAL_PIPELINE_VERSION too to allow upgrade of the pipeline diff --git a/x-pack/plugins/fleet/server/constants/index.ts b/x-pack/plugins/fleet/server/constants/index.ts index 0ccbeb9f025e..ec7a4a266488 100644 --- a/x-pack/plugins/fleet/server/constants/index.ts +++ b/x-pack/plugins/fleet/server/constants/index.ts @@ -58,9 +58,15 @@ export { } from '../../common'; export { - FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME, - FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT, + FLEET_GLOBALS_COMPONENT_TEMPLATE_NAME, + FLEET_GLOBALS_COMPONENT_TEMPLATE_CONTENT, + FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_NAME, + FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_CONTENT, + FLEET_COMPONENT_TEMPLATES, FLEET_FINAL_PIPELINE_ID, FLEET_FINAL_PIPELINE_CONTENT, FLEET_FINAL_PIPELINE_VERSION, + MAPPINGS_TEMPLATE_SUFFIX, + SETTINGS_TEMPLATE_SUFFIX, + USER_SETTINGS_TEMPLATE_SUFFIX, } from './fleet_es_assets'; diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index 550d6acaffc7..47b94103c65f 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import path from 'path'; + import { schema } from '@kbn/config-schema'; import type { TypeOf } from '@kbn/config-schema'; import type { PluginConfigDescriptor, PluginInitializerContext } from 'src/core/server'; @@ -40,6 +42,8 @@ export type { } from './types'; export { AgentNotFoundError, FleetUnauthorizedError } from './errors'; +const DEFAULT_BUNDLED_PACKAGE_LOCATION = path.join(__dirname, '../target/bundled_packages'); + export const config: PluginConfigDescriptor = { exposeToBrowser: { epm: true, @@ -130,6 +134,7 @@ export const config: PluginConfigDescriptor = { developer: schema.object({ disableRegistryVersionCheck: schema.boolean({ defaultValue: false }), allowAgentUpgradeSourceUri: schema.boolean({ defaultValue: false }), + bundledPackageLocation: schema.string({ defaultValue: DEFAULT_BUNDLED_PACKAGE_LOCATION }), }), }), }; diff --git a/x-pack/plugins/fleet/server/integration_tests/__snapshots__/cloud_preconfiguration.test.ts.snap b/x-pack/plugins/fleet/server/integration_tests/__snapshots__/cloud_preconfiguration.test.ts.snap index 80f2c39abe98..88b4a2161a0b 100644 --- a/x-pack/plugins/fleet/server/integration_tests/__snapshots__/cloud_preconfiguration.test.ts.snap +++ b/x-pack/plugins/fleet/server/integration_tests/__snapshots__/cloud_preconfiguration.test.ts.snap @@ -15,7 +15,7 @@ Object { "data_stream": Object { "namespace": "default", }, - "id": "elastic-cloud-fleet-server", + "id": "fleet-server-fleet_server-elastic-cloud-fleet-server", "meta": Object { "package": Object { "name": "fleet_server", @@ -111,11 +111,11 @@ Object { "data_stream": Object { "namespace": "default", }, - "id": "elastic-cloud-apm", + "id": "apm-apmserver-elastic-cloud-apm", "meta": Object { "package": Object { "name": "apm", - "version": "8.2.0-dev3", + "version": "8.2.0-dev4", }, }, "name": "Elastic APM", diff --git a/x-pack/plugins/fleet/server/integration_tests/cloud_preconfiguration.test.ts b/x-pack/plugins/fleet/server/integration_tests/cloud_preconfiguration.test.ts index 2dbdb5849750..f3a4e045d042 100644 --- a/x-pack/plugins/fleet/server/integration_tests/cloud_preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/integration_tests/cloud_preconfiguration.test.ts @@ -39,8 +39,13 @@ describe('Fleet preconfiguration reset', () => { const startKibana = async () => { const root = kbnTestServer.createRootWithCorePlugins( { - ...CLOUD_KIBANA_CONFIG, - 'xpack.fleet.registryUrl': registryUrl, + xpack: { + ...CLOUD_KIBANA_CONFIG.xpack, + fleet: { + ...CLOUD_KIBANA_CONFIG.xpack.fleet, + registryUrl, + }, + }, logging: { appenders: { file: { diff --git a/x-pack/plugins/fleet/server/integration_tests/helpers/docker_registry_helper.ts b/x-pack/plugins/fleet/server/integration_tests/helpers/docker_registry_helper.ts index 7dd58a546aef..04d75f15c7f8 100644 --- a/x-pack/plugins/fleet/server/integration_tests/helpers/docker_registry_helper.ts +++ b/x-pack/plugins/fleet/server/integration_tests/helpers/docker_registry_helper.ts @@ -24,7 +24,7 @@ export function useDockerRegistry() { let dockerProcess: ChildProcess | undefined; async function startDockerRegistryServer() { - const dockerImage = `docker.elastic.co/package-registry/distribution@sha256:8b4ce36ecdf86e6cfdf781d9df8d564a014add9afc9aec21cf2c5a68ff82d3ab`; + const dockerImage = `docker.elastic.co/package-registry/distribution@sha256:b3dfc6a11ff7dce82ba8689ea9eeb54e353c6b4bfd2d28127b20ef72fd8883e9`; const args = ['run', '--rm', '-p', `${packageRegistryPort}:8080`, dockerImage]; diff --git a/x-pack/plugins/fleet/server/integration_tests/validate_bundled_packages.test.ts b/x-pack/plugins/fleet/server/integration_tests/validate_bundled_packages.test.ts new file mode 100644 index 000000000000..fa7f8b2275da --- /dev/null +++ b/x-pack/plugins/fleet/server/integration_tests/validate_bundled_packages.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import path from 'path'; +import fs from 'fs/promises'; + +import JSON5 from 'json5'; +import { REPO_ROOT } from '@kbn/utils'; + +import * as Registry from '../services/epm/registry'; +import { generatePackageInfoFromArchiveBuffer } from '../services/epm/archive'; + +import { createAppContextStartContractMock } from '../mocks'; +import { appContextService } from '../services'; + +import { useDockerRegistry } from './helpers'; + +describe('validate bundled packages', () => { + const registryUrl = useDockerRegistry(); + let mockContract: ReturnType; + + beforeEach(() => { + mockContract = createAppContextStartContractMock({ registryUrl }); + appContextService.start(mockContract); + }); + + async function getBundledPackageEntries() { + const configFilePath = path.resolve(REPO_ROOT, 'fleet_packages.json'); + const configFile = await fs.readFile(configFilePath, 'utf8'); + const bundledPackages = JSON5.parse(configFile); + + return bundledPackages as Array<{ name: string; version: string }>; + } + + async function setupPackageObjects() { + const bundledPackages = await getBundledPackageEntries(); + + const packageObjects = await Promise.all( + bundledPackages.map(async (bundledPackage) => { + const registryPackage = await Registry.getRegistryPackage( + bundledPackage.name, + bundledPackage.version + ); + + const packageArchive = await Registry.fetchArchiveBuffer( + bundledPackage.name, + bundledPackage.version + ); + + return { registryPackage, packageArchive }; + }) + ); + + return packageObjects; + } + + it('generates matching package info objects for uploaded and registry packages', async () => { + const packageObjects = await setupPackageObjects(); + + for (const packageObject of packageObjects) { + const { registryPackage, packageArchive } = packageObject; + + const archivePackageInfo = await generatePackageInfoFromArchiveBuffer( + packageArchive.archiveBuffer, + 'application/zip' + ); + + expect(archivePackageInfo.packageInfo.data_streams).toEqual( + registryPackage.packageInfo.data_streams + ); + } + }); +}); diff --git a/x-pack/plugins/fleet/server/mocks/index.ts b/x-pack/plugins/fleet/server/mocks/index.ts index 8e6155e6bf7c..b229677a4ded 100644 --- a/x-pack/plugins/fleet/server/mocks/index.ts +++ b/x-pack/plugins/fleet/server/mocks/index.ts @@ -21,6 +21,7 @@ import type { PackagePolicyServiceInterface } from '../services/package_policy'; import type { AgentPolicyServiceInterface } from '../services'; import type { FleetAppContext } from '../plugin'; import { createMockTelemetryEventsSender } from '../telemetry/__mocks__'; +import type { FleetConfigType } from '../../common'; import { createFleetAuthzMock } from '../../common'; import { agentServiceMock } from '../services/agents/agent_service.mock'; import type { FleetRequestHandlerContext } from '../types'; @@ -39,11 +40,14 @@ export interface MockedFleetAppContext extends FleetAppContext { logger: ReturnType['get']>; } -export const createAppContextStartContractMock = (): MockedFleetAppContext => { +export const createAppContextStartContractMock = ( + configOverrides: Partial = {} +): MockedFleetAppContext => { const config = { agents: { enabled: true, elasticsearch: {} }, enabled: true, agentIdVerificationEnabled: true, + ...configOverrides, }; const config$ = of(config); diff --git a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts index 070def907bcf..437806abf4e9 100644 --- a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts @@ -37,13 +37,6 @@ interface ESDataStreamInfo { hidden: boolean; } -interface ESDataStreamStats { - data_stream: string; - backing_indices: number; - store_size_bytes: number; - maximum_timestamp: number; -} - export const getListHandler: RequestHandler = async (context, request, response) => { // Query datastreams as the current user as the Kibana internal user may not have all the required permission const esClient = context.core.elasticsearch.client.asCurrentUser; @@ -60,12 +53,12 @@ export const getListHandler: RequestHandler = async (context, request, response) packageSavedObjects, ] = await Promise.all([ esClient.indices.getDataStream({ name: DATA_STREAM_INDEX_PATTERN }), - esClient.indices.dataStreamsStats({ name: DATA_STREAM_INDEX_PATTERN }), + esClient.indices.dataStreamsStats({ name: DATA_STREAM_INDEX_PATTERN, human: true }), getPackageSavedObjects(context.core.savedObjects.client), ]); const dataStreamsInfoByName = keyBy(dataStreamsInfo, 'name'); - const dataStreamsStatsByName = keyBy(dataStreamStats, 'data_stream'); + const dataStreamsStatsByName = keyBy(dataStreamStats, 'data_stream'); // Combine data stream info const dataStreams = merge(dataStreamsInfoByName, dataStreamsStatsByName); @@ -127,6 +120,9 @@ export const getListHandler: RequestHandler = async (context, request, response) package_version: '', last_activity_ms: dataStream.maximum_timestamp, // overridden below if maxIngestedTimestamp agg returns a result size_in_bytes: dataStream.store_size_bytes, + // `store_size` should be available from ES due to ?human=true flag + // but fallback to bytes just in case + size_in_bytes_formatted: dataStream.store_size || `${dataStream.store_size_bytes}b`, dashboards: [], }; diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index 9bfcffa04bf3..7ba2d3f194ee 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -267,9 +267,13 @@ export const installPackageFromRegistryHandler: FleetRequestHandler< force: request.body?.force, ignoreConstraints: request.body?.ignore_constraints, }); + if (!res.error) { const body: InstallPackageResponse = { items: res.assets || [], + _meta: { + install_source: res.installSource, + }, }; return response.ok({ body }); } else { @@ -342,6 +346,9 @@ export const installPackageByUploadHandler: FleetRequestHandler< const body: InstallPackageResponse = { items: res.assets || [], response: res.assets || [], + _meta: { + install_source: res.installSource, + }, }; return response.ok({ body }); } else { diff --git a/x-pack/plugins/fleet/server/routes/epm/index.ts b/x-pack/plugins/fleet/server/routes/epm/index.ts index 95aadf1b8555..544ab8b288cb 100644 --- a/x-pack/plugins/fleet/server/routes/epm/index.ts +++ b/x-pack/plugins/fleet/server/routes/epm/index.ts @@ -242,7 +242,7 @@ export const registerRoutes = (router: FleetAuthzRouter) => { response ); if (resp.payload?.items) { - return response.ok({ body: { response: resp.payload.items } }); + return response.ok({ body: { ...resp.payload, response: resp.payload.items } }); } return resp; } diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts index 1908d38ab408..de2045ff3e47 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts @@ -139,7 +139,7 @@ export const updatePackagePolicyHandler: RequestHandler< throw Boom.notFound('Package policy not found'); } - const body = { ...request.body }; + const { force, ...body } = request.body; // removed fields not recognized by schema const packagePolicyInputs = packagePolicy.inputs.map((input) => { const newInput = { @@ -180,7 +180,7 @@ export const updatePackagePolicyHandler: RequestHandler< esClient, request.params.packagePolicyId, newData, - { user }, + { user, force }, packagePolicy.package?.version ); return response.ok({ diff --git a/x-pack/plugins/fleet/server/services/agent_policies/index.ts b/x-pack/plugins/fleet/server/services/agent_policies/index.ts index b793ed26a08b..2e1fffdec114 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/index.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/index.ts @@ -6,3 +6,4 @@ */ export { getFullAgentPolicy } from './full_agent_policy'; +export { validateOutputForPolicy } from './validate_outputs_for_policy'; diff --git a/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.test.ts new file mode 100644 index 000000000000..ba5bc4a3aeeb --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { appContextService } from '..'; + +import { validateOutputForPolicy } from '.'; + +jest.mock('../app_context'); + +const mockedAppContextService = appContextService as jest.Mocked; + +function mockHasLicence(res: boolean) { + mockedAppContextService.getSecurityLicense.mockReturnValue({ + hasAtLeast: () => res, + } as any); +} + +describe('validateOutputForPolicy', () => { + describe('Without oldData (create)', () => { + it('should allow default outputs without platinum licence', async () => { + mockHasLicence(false); + await validateOutputForPolicy({ + data_output_id: null, + monitoring_output_id: null, + }); + }); + + it('should allow default outputs with platinum licence', async () => { + mockHasLicence(false); + await validateOutputForPolicy({ + data_output_id: null, + monitoring_output_id: null, + }); + }); + + it('should not allow custom data outputs without platinum licence', async () => { + mockHasLicence(false); + const res = validateOutputForPolicy({ + data_output_id: 'test1', + monitoring_output_id: null, + }); + await expect(res).rejects.toThrow( + 'Invalid licence to set per policy output, you need platinum licence' + ); + }); + + it('should not allow custom monitoring outputs without platinum licence', async () => { + mockHasLicence(false); + const res = validateOutputForPolicy({ + data_output_id: null, + monitoring_output_id: 'test1', + }); + await expect(res).rejects.toThrow( + 'Invalid licence to set per policy output, you need platinum licence' + ); + }); + + it('should allow custom data output with platinum licence', async () => { + mockHasLicence(true); + await validateOutputForPolicy({ + data_output_id: 'test1', + monitoring_output_id: null, + }); + }); + + it('should allow custom monitoring output with platinum licence', async () => { + mockHasLicence(true); + await validateOutputForPolicy({ + data_output_id: null, + monitoring_output_id: 'test1', + }); + }); + + it('should allow custom outputs for managed preconfigured policy without licence', async () => { + mockHasLicence(false); + await validateOutputForPolicy({ + is_managed: true, + is_preconfigured: true, + data_output_id: 'test1', + monitoring_output_id: 'test1', + }); + }); + }); + + describe('With oldData (update)', () => { + it('should allow default outputs without platinum licence', async () => { + mockHasLicence(false); + await validateOutputForPolicy( + { + data_output_id: null, + monitoring_output_id: null, + }, + { + data_output_id: 'test1', + monitoring_output_id: 'test1', + } + ); + }); + + it('should not allow custom data outputs without platinum licence', async () => { + mockHasLicence(false); + const res = validateOutputForPolicy( + { + data_output_id: 'test1', + monitoring_output_id: null, + }, + { + data_output_id: null, + monitoring_output_id: null, + } + ); + await expect(res).rejects.toThrow( + 'Invalid licence to set per policy output, you need platinum licence' + ); + }); + + it('should not allow custom monitoring outputs without platinum licence', async () => { + mockHasLicence(false); + const res = validateOutputForPolicy( + { + data_output_id: null, + monitoring_output_id: 'test1', + }, + { + data_output_id: null, + monitoring_output_id: null, + } + ); + await expect(res).rejects.toThrow( + 'Invalid licence to set per policy output, you need platinum licence' + ); + }); + + it('should allow custom data output with platinum licence', async () => { + mockHasLicence(true); + await validateOutputForPolicy( + { + data_output_id: 'test1', + monitoring_output_id: null, + }, + { + data_output_id: 'test1', + monitoring_output_id: null, + } + ); + }); + + it('should allow custom monitoring output with platinum licence', async () => { + mockHasLicence(true); + await validateOutputForPolicy({ + data_output_id: null, + monitoring_output_id: 'test1', + }); + }); + + it('should allow custom outputs for managed preconfigured policy without licence', async () => { + mockHasLicence(false); + await validateOutputForPolicy( + { + data_output_id: 'test1', + monitoring_output_id: 'test1', + }, + { is_managed: true, is_preconfigured: true } + ); + }); + + it('should allow custom outputs if they did not change without licence', async () => { + mockHasLicence(false); + await validateOutputForPolicy( + { + data_output_id: 'test1', + monitoring_output_id: 'test1', + }, + { data_output_id: 'test1', monitoring_output_id: 'test1' } + ); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.ts b/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.ts new file mode 100644 index 000000000000..272e1cd6c5b5 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.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 type { AgentPolicySOAttributes } from '../../types'; +import { LICENCE_FOR_PER_POLICY_OUTPUT } from '../../../common'; +import { appContextService } from '..'; + +/** + * Validate outputs are valid for a policy using the current kibana licence or throw. + * @param data + * @returns + */ +export async function validateOutputForPolicy( + newData: Partial, + oldData: Partial = {} +) { + if ( + newData.data_output_id === oldData.data_output_id && + newData.monitoring_output_id === oldData.monitoring_output_id + ) { + return; + } + + const data = { ...oldData, ...newData }; + + if (!data.data_output_id && !data.monitoring_output_id) { + return; + } + + // Do not validate licence output for managed and preconfigured policy + if (data.is_managed && data.is_preconfigured) { + return; + } + + const hasLicence = appContextService + .getSecurityLicense() + .hasAtLeast(LICENCE_FOR_PER_POLICY_OUTPUT); + + if (!hasLicence) { + throw new Error( + `Invalid licence to set per policy output, you need ${LICENCE_FOR_PER_POLICY_OUTPUT} licence` + ); + } +} diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts index 11d710e9e2b3..214237868dd8 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts @@ -204,6 +204,59 @@ describe('agent policy', () => { }); }); + describe('removeOutputFromAll', () => { + let mockedAgentPolicyServiceUpdate: jest.SpyInstance< + ReturnType + >; + beforeEach(() => { + mockedAgentPolicyServiceUpdate = jest + .spyOn(agentPolicyService, 'update') + .mockResolvedValue({} as any); + }); + + afterEach(() => { + mockedAgentPolicyServiceUpdate.mockRestore(); + }); + it('should update policies using deleted output', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + soClient.find.mockResolvedValue({ + saved_objects: [ + { + id: 'test1', + attributes: { + data_output_id: 'output-id-123', + monitoring_output_id: 'output-id-another-output', + }, + }, + { + id: 'test2', + attributes: { + data_output_id: 'output-id-another-output', + monitoring_output_id: 'output-id-123', + }, + }, + ], + } as any); + + await agentPolicyService.removeOutputFromAll(soClient, esClient, 'output-id-123'); + + expect(mockedAgentPolicyServiceUpdate).toHaveBeenCalledTimes(2); + expect(mockedAgentPolicyServiceUpdate).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'test1', + { data_output_id: null, monitoring_output_id: 'output-id-another-output' } + ); + expect(mockedAgentPolicyServiceUpdate).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'test2', + { data_output_id: 'output-id-another-output', monitoring_output_id: null } + ); + }); + }); + describe('update', () => { it('should update is_managed property, if given', async () => { // ignore unrelated unique name constraint diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 50586badbe0c..dd944f366a32 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -63,6 +63,7 @@ import { agentPolicyUpdateEventHandler } from './agent_policy_update'; import { normalizeKuery, escapeSearchQueryPhrase } from './saved_object'; import { appContextService } from './app_context'; import { getFullAgentPolicy } from './agent_policies'; +import { validateOutputForPolicy } from './agent_policies'; const SAVED_OBJECT_TYPE = AGENT_POLICY_SAVED_OBJECT_TYPE; @@ -99,6 +100,8 @@ class AgentPolicyService { ); } + await validateOutputForPolicy(agentPolicy); + await soClient.update(SAVED_OBJECT_TYPE, id, { ...agentPolicy, ...(options.bumpRevision ? { revision: oldAgentPolicy.revision + 1 } : {}), @@ -169,6 +172,8 @@ class AgentPolicyService { ): Promise { await this.requireUniqueName(soClient, agentPolicy); + await validateOutputForPolicy(agentPolicy); + const newSo = await soClient.create( SAVED_OBJECT_TYPE, { @@ -409,6 +414,49 @@ class AgentPolicyService { return res; } + /** + * Remove an output from all agent policies that are using it, and replace the output by the default ones. + * @param soClient + * @param esClient + * @param outputId + */ + public async removeOutputFromAll( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + outputId: string + ) { + const agentPolicies = ( + await soClient.find({ + type: SAVED_OBJECT_TYPE, + fields: ['revision', 'data_output_id', 'monitoring_output_id'], + searchFields: ['data_output_id', 'monitoring_output_id'], + search: escapeSearchQueryPhrase(outputId), + perPage: SO_SEARCH_LIMIT, + }) + ).saved_objects.map((so) => ({ + id: so.id, + ...so.attributes, + })); + + if (agentPolicies.length > 0) { + await pMap( + agentPolicies, + (agentPolicy) => + this.update(soClient, esClient, agentPolicy.id, { + data_output_id: + agentPolicy.data_output_id === outputId ? null : agentPolicy.data_output_id, + monitoring_output_id: + agentPolicy.monitoring_output_id === outputId + ? null + : agentPolicy.monitoring_output_id, + }), + { + concurrency: 50, + } + ); + } + } + public async bumpAllAgentPoliciesForOutput( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, @@ -662,6 +710,7 @@ class AgentPolicyService { const res = await esClient.search({ index: AGENT_POLICY_INDEX, ignore_unavailable: true, + rest_total_hits_as_int: true, body: { query: { term: { @@ -673,8 +722,7 @@ class AgentPolicyService { }, }); - // @ts-expect-error value is number | TotalHits - if (res.body.hits.total.value === 0) { + if ((res.hits.total as number) === 0) { return null; } diff --git a/x-pack/plugins/fleet/server/services/agents/crud.test.ts b/x-pack/plugins/fleet/server/services/agents/crud.test.ts index 6ae8fbd47123..f6988c1c7d28 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.test.ts @@ -30,7 +30,7 @@ describe('Agents CRUD test', () => { function getEsResponse(ids: string[], total: number) { return { hits: { - total: { value: total }, + total, hits: ids.map((id: string) => ({ _id: id, _source: {}, diff --git a/x-pack/plugins/fleet/server/services/agents/crud.ts b/x-pack/plugins/fleet/server/services/agents/crud.ts index 426bddd1bd9b..03b71ceb496f 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.ts @@ -128,6 +128,7 @@ export async function getAgentsByKuery( from, size, track_total_hits: true, + rest_total_hits_as_int: true, ignore_unavailable: true, body: { ...body, @@ -137,7 +138,7 @@ export async function getAgentsByKuery( const res = await queryAgents((page - 1) * perPage, perPage); let agents = res.hits.hits.map(searchHitToAgent); - let total = (res.hits.total as estypes.SearchTotalHits).value; + let total = res.hits.total as number; // filtering for a range on the version string will not work, // nor does filtering on a flattened field (local_metadata), so filter here if (showUpgradeable) { @@ -202,11 +203,13 @@ export async function countInactiveAgents( index: AGENTS_INDEX, size: 0, track_total_hits: true, + rest_total_hits_as_int: true, + filter_path: 'hits.total', ignore_unavailable: true, body, }); - // @ts-expect-error value is number | TotalHits - return res.body.hits.total.value; + + return (res.hits.total as number) || 0; } export async function getAgentById(esClient: ElasticsearchClient, agentId: string) { diff --git a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts index 03c9e4f97995..ee5d982811e3 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts @@ -43,6 +43,7 @@ export async function listEnrollmentApiKeys( from: (page - 1) * perPage, size: perPage, track_total_hits: true, + rest_total_hits_as_int: true, ignore_unavailable: true, body: { sort: [{ created_at: { order: 'desc' } }], @@ -55,8 +56,7 @@ export async function listEnrollmentApiKeys( return { items, - // @ts-expect-error value is number | TotalHits - total: res.hits.total.value, + total: res.hits.total as number, page, perPage, }; diff --git a/x-pack/plugins/fleet/server/services/artifacts/artifacts.test.ts b/x-pack/plugins/fleet/server/services/artifacts/artifacts.test.ts index fedb89c1e779..3f0c5af247eb 100644 --- a/x-pack/plugins/fleet/server/services/artifacts/artifacts.test.ts +++ b/x-pack/plugins/fleet/server/services/artifacts/artifacts.test.ts @@ -148,6 +148,8 @@ describe('When using the artifacts services', () => { q: '', from: 0, size: 20, + track_total_hits: true, + rest_total_hits_as_int: true, body: { sort: [{ created: { order: 'asc' } }], }, @@ -182,6 +184,8 @@ describe('When using the artifacts services', () => { ignore_unavailable: true, from: 450, size: 50, + track_total_hits: true, + rest_total_hits_as_int: true, body: { sort: [{ identifier: { order: 'desc' } }], }, diff --git a/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts b/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts index 336a03e84126..c6e77301bed2 100644 --- a/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts +++ b/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts @@ -107,6 +107,8 @@ export const listArtifacts = async ( from: (page - 1) * perPage, ignore_unavailable: true, size: perPage, + track_total_hits: true, + rest_total_hits_as_int: true, body: { sort: [{ [sortField]: { order: sortOrder } }], }, @@ -117,8 +119,7 @@ export const listArtifacts = async ( items: searchResult.hits.hits.map((hit) => esSearchHitToArtifact(hit)), page, perPage, - // @ts-expect-error doesn't handle total as number - total: searchResult.hits.total.value, + total: searchResult.hits.total as number, }; } catch (e) { throw new ArtifactsElasticsearchError(e); diff --git a/x-pack/plugins/fleet/server/services/artifacts/mocks.ts b/x-pack/plugins/fleet/server/services/artifacts/mocks.ts index b122d3343dac..9eee24afe08b 100644 --- a/x-pack/plugins/fleet/server/services/artifacts/mocks.ts +++ b/x-pack/plugins/fleet/server/services/artifacts/mocks.ts @@ -102,7 +102,8 @@ export const generateArtifactEsGetSingleHitMock = ( export const generateArtifactEsSearchResultHitsMock = (): ESSearchResponse< ArtifactElasticsearchProperties, - {} + {}, + { restTotalHitsAsInt: true } > => { return { took: 0, @@ -114,10 +115,7 @@ export const generateArtifactEsSearchResultHitsMock = (): ESSearchResponse< failed: 0, }, hits: { - total: { - value: 1, - relation: 'eq', - }, + total: 1, max_score: 2, hits: [generateArtifactEsGetSingleHitMock()], }, diff --git a/x-pack/plugins/fleet/server/services/epm/archive/index.ts b/x-pack/plugins/fleet/server/services/epm/archive/index.ts index 7c590eb1bcd3..e7e43d3d932b 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/index.ts @@ -23,7 +23,7 @@ import { getBufferExtractor } from './extract'; export * from './cache'; export { getBufferExtractor, untarBuffer, unzipBuffer } from './extract'; -export { parseAndVerifyArchiveBuffer as parseAndVerifyArchiveEntries } from './validation'; +export { generatePackageInfoFromArchiveBuffer } from './parse'; export interface ArchiveEntry { path: string; diff --git a/x-pack/plugins/fleet/server/services/epm/archive/validation.ts b/x-pack/plugins/fleet/server/services/epm/archive/parse.ts similarity index 69% rename from x-pack/plugins/fleet/server/services/epm/archive/validation.ts rename to x-pack/plugins/fleet/server/services/epm/archive/parse.ts index 5fdbdc5b1733..828a31b3ab03 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/validation.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/parse.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { merge } from '@kbn/std'; import yaml from 'js-yaml'; import { pick, uniq } from 'lodash'; @@ -32,6 +33,45 @@ import { unpackBufferEntries } from './index'; const MANIFESTS: Record = {}; const MANIFEST_NAME = 'manifest.yml'; +const DEFAULT_RELEASE_VALUE = 'ga'; + +// Ingest pipelines are specified in a `data_stream//elasticsearch/ingest_pipeline/` directory where a `default` +// ingest pipeline should be specified by one of these filenames. +const DEFAULT_INGEST_PIPELINE_VALUE = 'default'; +const DEFAULT_INGEST_PIPELINE_FILE_NAME_YML = 'default.yml'; +const DEFAULT_INGEST_PIPELINE_FILE_NAME_JSON = 'default.json'; + +// Borrowed from https://github.com/elastic/kibana/blob/main/x-pack/plugins/security_solution/common/utils/expand_dotted.ts +// with some alterations around non-object values. The package registry service expands some dotted fields from manifest files, +// so we need to do the same here. +const expandDottedField = (dottedFieldName: string, val: unknown): object => { + const parts = dottedFieldName.split('.'); + + if (parts.length === 1) { + return { [parts[0]]: val }; + } else { + return { [parts[0]]: expandDottedField(parts.slice(1).join('.'), val) }; + } +}; + +export const expandDottedObject = (dottedObj: object) => { + if (typeof dottedObj !== 'object' || Array.isArray(dottedObj)) { + return dottedObj; + } + return Object.entries(dottedObj).reduce( + (acc, [key, val]) => merge(acc, expandDottedField(key, val)), + {} + ); +}; + +export const expandDottedEntries = (obj: object) => { + return Object.entries(obj).reduce((acc, [key, value]) => { + acc[key] = expandDottedObject(value); + + return acc; + }, {} as Record); +}; + // not sure these are 100% correct but they do the job here // keeping them local until others need them type OptionalPropertyOf = Exclude< @@ -76,10 +116,19 @@ const registryPolicyTemplateProps = Object.values(RegistryPolicyTemplateKeys); const registryStreamProps = Object.values(RegistryStreamKeys); const registryDataStreamProps = Object.values(RegistryDataStreamKeys); -// TODO: everything below performs verification of manifest.yml files, and hence duplicates functionality already implemented in the -// package registry. At some point this should probably be replaced (or enhanced) with verification based on -// https://github.com/elastic/package-spec/ -export async function parseAndVerifyArchiveBuffer( +/* + This function generates a package info object (see type `ArchivePackage`) by parsing and verifying the `manifest.yml` file as well + as the directory structure for the given package archive and other files adhering to the package spec: https://github.com/elastic/package-spec. + + Currently, this process is duplicative of logic that's already implemented in the Package Registry codebase, + e.g. https://github.com/elastic/package-registry/blob/main/packages/package.go. Because of this duplication, it's likely for our parsing/verification + logic to fall out of sync with the registry codebase's implementation. + + This should be addressed in https://github.com/elastic/kibana/issues/115032 + where we'll no longer use the package registry endpoint as a source of truth for package info objects, and instead Fleet will _always_ generate + them in the manner implemented below. +*/ +export async function generatePackageInfoFromArchiveBuffer( archiveBuffer: Buffer, contentType: string ): Promise<{ paths: string[]; packageInfo: ArchivePackage }> { @@ -144,8 +193,13 @@ function parseAndVerifyArchive(paths: string[]): ArchivePackage { ); } - parsed.data_streams = parseAndVerifyDataStreams(paths, parsed.name, parsed.version); + const parsedDataStreams = parseAndVerifyDataStreams(paths, parsed.name, parsed.version); + if (parsedDataStreams.length) { + parsed.data_streams = parsedDataStreams; + } + parsed.policy_templates = parseAndVerifyPolicyTemplates(manifest); + // add readme if exists const readme = parseAndVerifyReadme(paths, parsed.name, parsed.version); if (readme) { @@ -202,11 +256,11 @@ export function parseAndVerifyDataStreams( const { title: dataStreamTitle, - release, + release = DEFAULT_RELEASE_VALUE, type, dataset, - ingest_pipeline: ingestPipeline, streams: manifestStreams, + elasticsearch, ...restOfProps } = manifest; if (!(dataStreamTitle && type)) { @@ -214,28 +268,75 @@ export function parseAndVerifyDataStreams( `Invalid manifest for data stream '${dataStreamPath}': one or more fields missing of 'title', 'type'` ); } + + let ingestPipeline; + const ingestPipelinePaths = paths.filter((path) => + path.startsWith(`${pkgKey}/data_stream/${dataStreamPath}/elasticsearch/ingest_pipeline`) + ); + + if ( + ingestPipelinePaths.length && + (ingestPipelinePaths.some((ingestPipelinePath) => + ingestPipelinePath.endsWith(DEFAULT_INGEST_PIPELINE_FILE_NAME_YML) + ) || + ingestPipelinePaths.some((ingestPipelinePath) => + ingestPipelinePath.endsWith(DEFAULT_INGEST_PIPELINE_FILE_NAME_JSON) + )) + ) { + ingestPipeline = DEFAULT_INGEST_PIPELINE_VALUE; + } + const streams = parseAndVerifyStreams(manifestStreams, dataStreamPath); + const parsedElasticsearchEntry: Record = {}; + + if (ingestPipeline) { + parsedElasticsearchEntry['ingest_pipeline.name'] = DEFAULT_INGEST_PIPELINE_VALUE; + } + + if (elasticsearch?.privileges) { + parsedElasticsearchEntry.privileges = elasticsearch.privileges; + } + + if (elasticsearch?.index_template?.mappings) { + parsedElasticsearchEntry['index_template.mappings'] = expandDottedEntries( + elasticsearch.index_template.mappings + ); + } + + if (elasticsearch?.index_template?.settings) { + parsedElasticsearchEntry['index_template.settings'] = expandDottedEntries( + elasticsearch.index_template.settings + ); + } + + // Build up the stream object here so we can conditionally insert nullable fields. The package registry omits undefined + // fields, so we're mimicking that behavior here. + const dataStreamObject: RegistryDataStream = { + title: dataStreamTitle, + release, + type, + package: pkgName, + dataset: dataset || `${pkgName}.${dataStreamPath}`, + path: dataStreamPath, + elasticsearch: parsedElasticsearchEntry, + }; + + if (ingestPipeline) { + dataStreamObject.ingest_pipeline = ingestPipeline; + } + + if (streams.length) { + dataStreamObject.streams = streams; + } + dataStreams.push( - Object.entries(restOfProps).reduce( - (validatedDataStream, [key, value]) => { - if (registryDataStreamProps.includes(key as RegistryDataStreamKeys)) { - // @ts-expect-error - validatedDataStream[key] = value; - } - return validatedDataStream; - }, - { - title: dataStreamTitle, - release, - type, - package: pkgName, - dataset: dataset || `${pkgName}.${dataStreamPath}`, - ingest_pipeline: ingestPipeline, - path: dataStreamPath, - streams, + Object.entries(restOfProps).reduce((validatedDataStream, [key, value]) => { + if (registryDataStreamProps.includes(key as RegistryDataStreamKeys)) { + validatedDataStream[key] = value; } - ) + return validatedDataStream; + }, dataStreamObject) ); }); @@ -261,25 +362,27 @@ export function parseAndVerifyStreams( `Invalid manifest for data stream ${dataStreamPath}: stream is missing one or more fields of: input, title` ); } + const vars = parseAndVerifyVars(manifestVars, `data stream ${dataStreamPath}`); - // default template path name see https://github.com/elastic/package-registry/blob/master/util/dataset.go#L143 + const streamObject: RegistryStream = { + input, + title: streamTitle, + template_path: templatePath || 'stream.yml.hbs', + }; + + if (vars.length) { + streamObject.vars = vars; + } + streams.push( - Object.entries(restOfProps).reduce( - (validatedStream, [key, value]) => { - if (registryStreamProps.includes(key as RegistryStreamKeys)) { - // @ts-expect-error - validatedStream[key] = value; - } - return validatedStream; - }, - { - input, - title: streamTitle, - vars, - template_path: templatePath || 'stream.yml.hbs', - } as RegistryStream - ) + Object.entries(restOfProps).reduce((validatedStream, [key, value]) => { + if (registryStreamProps.includes(key as RegistryStreamKeys)) { + // @ts-expect-error + validatedStream[key] = value; + } + return validatedStream; + }, streamObject) ); }); } diff --git a/x-pack/plugins/fleet/server/services/epm/archive/storage.ts b/x-pack/plugins/fleet/server/services/epm/archive/storage.ts index bc26388c990f..d2a96921bf03 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/storage.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/storage.ts @@ -27,7 +27,7 @@ import { appContextService } from '../../app_context'; import { getArchiveEntry, setArchiveEntry, setArchiveFilelist, setPackageInfo } from './index'; import type { ArchiveEntry } from './index'; -import { parseAndVerifyPolicyTemplates, parseAndVerifyStreams } from './validation'; +import { parseAndVerifyPolicyTemplates, parseAndVerifyStreams } from './parse'; const ONE_BYTE = 1024 * 1024; // could be anything, picked this from https://github.com/elastic/elastic-agent-client/issues/17 diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/meta.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/meta.ts index a3ceaf44100d..d691cd8c700e 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/meta.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/meta.ts @@ -7,15 +7,9 @@ import { safeLoad, safeDump } from 'js-yaml'; -const MANAGED_BY_DEFAULT = 'fleet'; +import type { ESAssetMetadata } from '../../../../common/types'; -export interface ESAssetMetadata { - package?: { - name: string; - }; - managed_by: string; - managed: boolean; -} +const MANAGED_BY_DEFAULT = 'fleet'; /** * Build common metadata object for Elasticsearch assets installed by Fleet. Result should be diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap index e977c41cd69d..efd51d5e0d99 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap @@ -2,613 +2,550 @@ exports[`EPM template tests loading base.yml: base.yml 1`] = ` { - "priority": 200, - "index_patterns": [ - "foo-*" - ], - "template": { - "settings": { - "index": {} + "properties": { + "user": { + "properties": { + "auid": { + "ignore_above": 1024, + "type": "keyword" + }, + "euid": { + "ignore_above": 1024, + "type": "keyword" + } + } }, - "mappings": { - "dynamic_templates": [ - { - "strings_as_keyword": { - "mapping": { - "ignore_above": 1024, - "type": "keyword" + "long": { + "properties": { + "nested": { + "properties": { + "foo": { + "type": "text" }, - "match_mapping_type": "string" + "bar": { + "type": "long" + } } } - ], - "date_detection": false, + } + }, + "nested": { + "properties": { + "bar": { + "ignore_above": 1024, + "type": "keyword" + }, + "baz": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "myalias": { + "type": "alias", + "path": "user.euid" + }, + "validarray": { + "type": "integer" + }, + "cycle_type": { + "type": "constant_keyword", + "value": "bicycle" + } + } +} +`; + +exports[`EPM template tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` +{ + "properties": { + "coredns": { "properties": { - "user": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "query": { "properties": { - "auid": { + "size": { + "type": "long" + }, + "class": { "ignore_above": 1024, "type": "keyword" }, - "euid": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { "ignore_above": 1024, "type": "keyword" } } }, - "long": { - "properties": { - "nested": { - "properties": { - "foo": { - "type": "text" - }, - "bar": { - "type": "long" - } - } - } - } - }, - "nested": { + "response": { "properties": { - "bar": { + "code": { "ignore_above": 1024, "type": "keyword" }, - "baz": { + "flags": { "ignore_above": 1024, "type": "keyword" + }, + "size": { + "type": "long" } } }, - "myalias": { - "type": "alias", - "path": "user.euid" - }, - "validarray": { - "type": "integer" - }, - "cycle_type": { - "type": "constant_keyword", - "value": "bicycle" - } - }, - "_meta": { - "managed_by": "fleet", - "managed": true, - "package": { - "name": "nginx" + "dnssec_ok": { + "type": "boolean" } } } - }, - "data_stream": {}, - "composed_of": [ - ".fleet_component_template-1" - ], - "_meta": { - "managed_by": "fleet", - "managed": true, - "package": { - "name": "nginx" - } } } `; -exports[`EPM template tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` +exports[`EPM template tests loading system.yml: system.yml 1`] = ` { - "priority": 200, - "index_patterns": [ - "foo-*" - ], - "template": { - "settings": { - "index": {} - }, - "mappings": { - "dynamic_templates": [ - { - "strings_as_keyword": { - "mapping": { - "ignore_above": 1024, - "type": "keyword" - }, - "match_mapping_type": "string" - } - } - ], - "date_detection": false, + "properties": { + "system": { "properties": { - "coredns": { + "core": { "properties": { "id": { - "ignore_above": 1024, - "type": "keyword" + "type": "long" }, - "query": { + "user": { "properties": { - "size": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { "type": "long" + } + } + }, + "system": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "class": { - "ignore_above": 1024, - "type": "keyword" + "ticks": { + "type": "long" + } + } + }, + "nice": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "name": { - "ignore_above": 1024, - "type": "keyword" + "ticks": { + "type": "long" + } + } + }, + "idle": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "type": { - "ignore_above": 1024, - "type": "keyword" + "ticks": { + "type": "long" } } }, - "response": { + "iowait": { "properties": { - "code": { - "ignore_above": 1024, - "type": "keyword" + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "flags": { - "ignore_above": 1024, - "type": "keyword" + "ticks": { + "type": "long" + } + } + }, + "irq": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "size": { + "ticks": { "type": "long" } } }, - "dnssec_ok": { - "type": "boolean" - } - } - } - }, - "_meta": { - "managed_by": "fleet", - "managed": true, - "package": { - "name": "coredns" - } - } - } - }, - "data_stream": {}, - "composed_of": [ - ".fleet_component_template-1" - ], - "_meta": { - "managed_by": "fleet", - "managed": true, - "package": { - "name": "coredns" - } - } -} -`; - -exports[`EPM template tests loading system.yml: system.yml 1`] = ` -{ - "priority": 200, - "index_patterns": [ - "whatsthis-*" - ], - "template": { - "settings": { - "index": {} - }, - "mappings": { - "dynamic_templates": [ - { - "strings_as_keyword": { - "mapping": { - "ignore_above": 1024, - "type": "keyword" + "softirq": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { + "type": "long" + } + } }, - "match_mapping_type": "string" + "steal": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { + "type": "long" + } + } + } } - } - ], - "date_detection": false, - "properties": { - "system": { + }, + "cpu": { "properties": { - "core": { + "cores": { + "type": "long" + }, + "user": { "properties": { - "id": { - "type": "long" + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "user": { + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "system": { + "ticks": { + "type": "long" + } + } + }, + "system": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "nice": { + "ticks": { + "type": "long" + } + } + }, + "nice": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "idle": { + "ticks": { + "type": "long" + } + } + }, + "idle": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "iowait": { + "ticks": { + "type": "long" + } + } + }, + "iowait": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "irq": { + "ticks": { + "type": "long" + } + } + }, + "irq": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "softirq": { + "ticks": { + "type": "long" + } + } + }, + "softirq": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "steal": { + "ticks": { + "type": "long" + } + } + }, + "steal": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } + }, + "ticks": { + "type": "long" } } }, - "cpu": { + "total": { "properties": { - "cores": { - "type": "long" - }, - "user": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } - }, - "system": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } - }, - "nice": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } - }, - "idle": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } - }, - "iowait": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "irq": { + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } - }, - "softirq": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" } } + } + } + } + } + }, + "diskio": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "read": { + "properties": { + "count": { + "type": "long" }, - "steal": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } + "bytes": { + "type": "long" }, - "total": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - } - } + "time": { + "type": "long" } } }, - "diskio": { + "write": { "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" + "count": { + "type": "long" }, - "serial_number": { - "ignore_above": 1024, - "type": "keyword" + "bytes": { + "type": "long" }, + "time": { + "type": "long" + } + } + }, + "io": { + "properties": { + "time": { + "type": "long" + } + } + }, + "iostat": { + "properties": { "read": { "properties": { - "count": { - "type": "long" - }, - "bytes": { - "type": "long" - }, - "time": { - "type": "long" - } - } - }, - "write": { - "properties": { - "count": { - "type": "long" - }, - "bytes": { - "type": "long" - }, - "time": { - "type": "long" - } - } - }, - "io": { - "properties": { - "time": { - "type": "long" - } - } - }, - "iostat": { - "properties": { - "read": { + "request": { "properties": { - "request": { - "properties": { - "merges_per_sec": { - "type": "float" - }, - "per_sec": { - "type": "float" - } - } + "merges_per_sec": { + "type": "float" }, "per_sec": { - "properties": { - "bytes": { - "type": "float" - } - } - }, - "await": { "type": "float" } } }, - "write": { + "per_sec": { "properties": { - "request": { - "properties": { - "merges_per_sec": { - "type": "float" - }, - "per_sec": { - "type": "float" - } - } - }, - "per_sec": { - "properties": { - "bytes": { - "type": "float" - } - } - }, - "await": { + "bytes": { "type": "float" } } }, + "await": { + "type": "float" + } + } + }, + "write": { + "properties": { "request": { "properties": { - "avg_size": { + "merges_per_sec": { + "type": "float" + }, + "per_sec": { "type": "float" } } }, - "queue": { + "per_sec": { "properties": { - "avg_size": { + "bytes": { "type": "float" } } }, "await": { "type": "float" - }, - "service_time": { + } + } + }, + "request": { + "properties": { + "avg_size": { "type": "float" - }, - "busy": { + } + } + }, + "queue": { + "properties": { + "avg_size": { "type": "float" } } + }, + "await": { + "type": "float" + }, + "service_time": { + "type": "float" + }, + "busy": { + "type": "float" } } + } + } + }, + "entropy": { + "properties": { + "available_bits": { + "type": "long" }, - "entropy": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "filesystem": { + "properties": { + "available": { + "type": "long" + }, + "device_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "mount_point": { + "ignore_above": 1024, + "type": "keyword" + }, + "files": { + "type": "long" + }, + "free": { + "type": "long" + }, + "free_files": { + "type": "long" + }, + "total": { + "type": "long" + }, + "used": { "properties": { - "available_bits": { + "bytes": { "type": "long" }, "pct": { @@ -616,36 +553,88 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` "scaling_factor": 1000 } } + } + } + }, + "fsstat": { + "properties": { + "count": { + "type": "long" + }, + "total_files": { + "type": "long" }, - "filesystem": { + "total_size": { "properties": { - "available": { + "free": { "type": "long" }, - "device_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "mount_point": { - "ignore_above": 1024, - "type": "keyword" - }, - "files": { + "used": { "type": "long" }, - "free": { + "total": { "type": "long" + } + } + } + } + }, + "load": { + "properties": { + "1": { + "type": "scaled_float", + "scaling_factor": 100 + }, + "5": { + "type": "scaled_float", + "scaling_factor": 100 + }, + "15": { + "type": "scaled_float", + "scaling_factor": 100 + }, + "norm": { + "properties": { + "1": { + "type": "scaled_float", + "scaling_factor": 100 }, - "free_files": { - "type": "long" + "5": { + "type": "scaled_float", + "scaling_factor": 100 }, - "total": { + "15": { + "type": "scaled_float", + "scaling_factor": 100 + } + } + }, + "cores": { + "type": "long" + } + } + }, + "memory": { + "properties": { + "total": { + "type": "long" + }, + "used": { + "properties": { + "bytes": { "type": "long" }, + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "free": { + "type": "long" + }, + "actual": { + "properties": { "used": { "properties": { "bytes": { @@ -656,68 +645,58 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` "scaling_factor": 1000 } } + }, + "free": { + "type": "long" } } }, - "fsstat": { + "swap": { "properties": { - "count": { - "type": "long" - }, - "total_files": { + "total": { "type": "long" }, - "total_size": { + "used": { "properties": { - "free": { - "type": "long" - }, - "used": { + "bytes": { "type": "long" }, - "total": { - "type": "long" + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 } } - } - } - }, - "load": { - "properties": { - "1": { - "type": "scaled_float", - "scaling_factor": 100 }, - "5": { - "type": "scaled_float", - "scaling_factor": 100 + "free": { + "type": "long" }, - "15": { - "type": "scaled_float", - "scaling_factor": 100 + "out": { + "properties": { + "pages": { + "type": "long" + } + } }, - "norm": { + "in": { "properties": { - "1": { - "type": "scaled_float", - "scaling_factor": 100 - }, - "5": { - "type": "scaled_float", - "scaling_factor": 100 - }, - "15": { - "type": "scaled_float", - "scaling_factor": 100 + "pages": { + "type": "long" } } }, - "cores": { - "type": "long" + "readahead": { + "properties": { + "pages": { + "type": "long" + }, + "cached": { + "type": "long" + } + } } } }, - "memory": { + "hugepages": { "properties": { "total": { "type": "long" @@ -728,296 +707,332 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` "type": "long" }, "pct": { - "type": "scaled_float", - "scaling_factor": 1000 + "type": "long" } } }, "free": { "type": "long" }, - "actual": { + "reserved": { + "type": "long" + }, + "surplus": { + "type": "long" + }, + "default_size": { + "type": "long" + }, + "swap": { + "properties": { + "out": { + "properties": { + "pages": { + "type": "long" + }, + "fallback": { + "type": "long" + } + } + } + } + } + } + } + } + }, + "network": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "out": { + "properties": { + "bytes": { + "type": "long" + }, + "packets": { + "type": "long" + }, + "errors": { + "type": "long" + }, + "dropped": { + "type": "long" + } + } + }, + "in": { + "properties": { + "bytes": { + "type": "long" + }, + "packets": { + "type": "long" + }, + "errors": { + "type": "long" + }, + "dropped": { + "type": "long" + } + } + } + } + }, + "network_summary": { + "properties": { + "ip": { + "properties": { + "*": { + "type": "object" + } + } + }, + "tcp": { + "properties": { + "*": { + "type": "object" + } + } + }, + "udp": { + "properties": { + "*": { + "type": "object" + } + } + }, + "udp_lite": { + "properties": { + "*": { + "type": "object" + } + } + }, + "icmp": { + "properties": { + "*": { + "type": "object" + } + } + } + } + }, + "process": { + "properties": { + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "cmdline": { + "ignore_above": 2048, + "type": "keyword" + }, + "env": { + "type": "object" + }, + "cpu": { + "properties": { + "user": { "properties": { - "used": { - "properties": { - "bytes": { - "type": "long" - }, - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "free": { + "ticks": { "type": "long" } } }, - "swap": { + "total": { "properties": { - "total": { + "value": { "type": "long" }, - "used": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { - "bytes": { - "type": "long" - }, "pct": { "type": "scaled_float", "scaling_factor": 1000 } } }, - "free": { + "ticks": { "type": "long" - }, - "out": { - "properties": { - "pages": { - "type": "long" - } - } - }, - "in": { - "properties": { - "pages": { - "type": "long" - } - } - }, - "readahead": { - "properties": { - "pages": { - "type": "long" - }, - "cached": { - "type": "long" - } - } } } }, - "hugepages": { + "system": { "properties": { - "total": { - "type": "long" - }, - "used": { - "properties": { - "bytes": { - "type": "long" - }, - "pct": { - "type": "long" - } - } - }, - "free": { - "type": "long" - }, - "reserved": { - "type": "long" - }, - "surplus": { - "type": "long" - }, - "default_size": { + "ticks": { "type": "long" - }, - "swap": { - "properties": { - "out": { - "properties": { - "pages": { - "type": "long" - }, - "fallback": { - "type": "long" - } - } - } - } } } + }, + "start_time": { + "type": "date" } } }, - "network": { + "memory": { "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" + "size": { + "type": "long" }, - "out": { + "rss": { "properties": { "bytes": { "type": "long" }, - "packets": { - "type": "long" - }, - "errors": { - "type": "long" - }, - "dropped": { - "type": "long" + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 } } }, - "in": { - "properties": { - "bytes": { - "type": "long" - }, - "packets": { - "type": "long" - }, - "errors": { - "type": "long" - }, - "dropped": { - "type": "long" - } - } + "share": { + "type": "long" } } }, - "network_summary": { + "fd": { "properties": { - "ip": { - "properties": { - "*": { - "type": "object" - } - } - }, - "tcp": { - "properties": { - "*": { - "type": "object" - } - } - }, - "udp": { - "properties": { - "*": { - "type": "object" - } - } - }, - "udp_lite": { - "properties": { - "*": { - "type": "object" - } - } + "open": { + "type": "long" }, - "icmp": { + "limit": { "properties": { - "*": { - "type": "object" + "soft": { + "type": "long" + }, + "hard": { + "type": "long" } } } } }, - "process": { + "cgroup": { "properties": { - "state": { + "id": { "ignore_above": 1024, "type": "keyword" }, - "cmdline": { - "ignore_above": 2048, + "path": { + "ignore_above": 1024, "type": "keyword" }, - "env": { - "type": "object" - }, "cpu": { "properties": { - "user": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "cfs": { "properties": { - "ticks": { + "period": { + "properties": { + "us": { + "type": "long" + } + } + }, + "quota": { + "properties": { + "us": { + "type": "long" + } + } + }, + "shares": { "type": "long" } } }, - "total": { + "rt": { "properties": { - "value": { - "type": "long" - }, - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { + "period": { "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 + "us": { + "type": "long" } } }, - "ticks": { - "type": "long" + "runtime": { + "properties": { + "us": { + "type": "long" + } + } } } }, - "system": { + "stats": { "properties": { - "ticks": { + "periods": { "type": "long" + }, + "throttled": { + "properties": { + "periods": { + "type": "long" + }, + "ns": { + "type": "long" + } + } } } - }, - "start_time": { - "type": "date" } } }, - "memory": { + "cpuacct": { "properties": { - "size": { - "type": "long" + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" }, - "rss": { + "total": { "properties": { - "bytes": { + "ns": { "type": "long" - }, - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 } } }, - "share": { - "type": "long" - } - } - }, - "fd": { - "properties": { - "open": { - "type": "long" - }, - "limit": { + "stats": { "properties": { - "soft": { - "type": "long" + "user": { + "properties": { + "ns": { + "type": "long" + } + } }, - "hard": { - "type": "long" + "system": { + "properties": { + "ns": { + "type": "long" + } + } } } + }, + "percpu": { + "type": "object" } } }, - "cgroup": { + "memory": { "properties": { "id": { "ignore_above": 1024, @@ -1027,354 +1042,212 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` "ignore_above": 1024, "type": "keyword" }, - "cpu": { + "mem": { "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" - }, - "cfs": { + "usage": { "properties": { - "period": { - "properties": { - "us": { - "type": "long" - } - } + "bytes": { + "type": "long" }, - "quota": { + "max": { "properties": { - "us": { + "bytes": { "type": "long" } } - }, - "shares": { + } + } + }, + "limit": { + "properties": { + "bytes": { "type": "long" } } }, - "rt": { + "failures": { + "type": "long" + } + } + }, + "memsw": { + "properties": { + "usage": { "properties": { - "period": { - "properties": { - "us": { - "type": "long" - } - } + "bytes": { + "type": "long" }, - "runtime": { + "max": { "properties": { - "us": { + "bytes": { "type": "long" } } } } }, - "stats": { + "limit": { "properties": { - "periods": { + "bytes": { + "type": "long" + } + } + }, + "failures": { + "type": "long" + } + } + }, + "kmem": { + "properties": { + "usage": { + "properties": { + "bytes": { "type": "long" }, - "throttled": { + "max": { "properties": { - "periods": { - "type": "long" - }, - "ns": { + "bytes": { "type": "long" } } } } - } - } - }, - "cpuacct": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" }, - "total": { + "limit": { "properties": { - "ns": { + "bytes": { "type": "long" } } }, - "stats": { + "failures": { + "type": "long" + } + } + }, + "kmem_tcp": { + "properties": { + "usage": { "properties": { - "user": { - "properties": { - "ns": { - "type": "long" - } - } + "bytes": { + "type": "long" }, - "system": { + "max": { "properties": { - "ns": { + "bytes": { "type": "long" } } } } }, - "percpu": { - "type": "object" + "limit": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "failures": { + "type": "long" } } }, - "memory": { + "stats": { "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" + "active_anon": { + "properties": { + "bytes": { + "type": "long" + } + } }, - "mem": { + "active_file": { "properties": { - "usage": { - "properties": { - "bytes": { - "type": "long" - }, - "max": { - "properties": { - "bytes": { - "type": "long" - } - } - } - } - }, - "limit": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "failures": { + "bytes": { "type": "long" } } }, - "memsw": { + "cache": { "properties": { - "usage": { - "properties": { - "bytes": { - "type": "long" - }, - "max": { - "properties": { - "bytes": { - "type": "long" - } - } - } - } - }, - "limit": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "failures": { + "bytes": { "type": "long" } } }, - "kmem": { + "hierarchical_memory_limit": { "properties": { - "usage": { - "properties": { - "bytes": { - "type": "long" - }, - "max": { - "properties": { - "bytes": { - "type": "long" - } - } - } - } - }, - "limit": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "failures": { + "bytes": { "type": "long" } } }, - "kmem_tcp": { + "hierarchical_memsw_limit": { "properties": { - "usage": { - "properties": { - "bytes": { - "type": "long" - }, - "max": { - "properties": { - "bytes": { - "type": "long" - } - } - } - } - }, - "limit": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "failures": { + "bytes": { "type": "long" } } }, - "stats": { + "inactive_anon": { "properties": { - "active_anon": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "active_file": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "cache": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "hierarchical_memory_limit": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "hierarchical_memsw_limit": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "inactive_anon": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "inactive_file": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "mapped_file": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "page_faults": { + "bytes": { "type": "long" - }, - "major_page_faults": { + } + } + }, + "inactive_file": { + "properties": { + "bytes": { "type": "long" - }, - "pages_in": { + } + } + }, + "mapped_file": { + "properties": { + "bytes": { "type": "long" - }, - "pages_out": { + } + } + }, + "page_faults": { + "type": "long" + }, + "major_page_faults": { + "type": "long" + }, + "pages_in": { + "type": "long" + }, + "pages_out": { + "type": "long" + }, + "rss": { + "properties": { + "bytes": { "type": "long" - }, - "rss": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "rss_huge": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "swap": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "unevictable": { - "properties": { - "bytes": { - "type": "long" - } - } } } - } - } - }, - "blkio": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" }, - "path": { - "ignore_above": 1024, - "type": "keyword" + "rss_huge": { + "properties": { + "bytes": { + "type": "long" + } + } }, - "total": { + "swap": { "properties": { "bytes": { "type": "long" - }, - "ios": { + } + } + }, + "unevictable": { + "properties": { + "bytes": { "type": "long" } } @@ -1383,286 +1256,290 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` } } }, - "summary": { + "blkio": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "total": { + "properties": { + "bytes": { + "type": "long" + }, + "ios": { + "type": "long" + } + } + } + } + } + } + }, + "summary": { + "properties": { + "total": { + "type": "long" + }, + "running": { + "type": "long" + }, + "idle": { + "type": "long" + }, + "sleeping": { + "type": "long" + }, + "stopped": { + "type": "long" + }, + "zombie": { + "type": "long" + }, + "dead": { + "type": "long" + }, + "unknown": { + "type": "long" + } + } + } + } + }, + "raid": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "sync_action": { + "ignore_above": 1024, + "type": "keyword" + }, + "disks": { + "properties": { + "active": { + "type": "long" + }, + "total": { + "type": "long" + }, + "spare": { + "type": "long" + }, + "failed": { + "type": "long" + }, + "states": { "properties": { - "total": { - "type": "long" - }, - "running": { - "type": "long" - }, - "idle": { - "type": "long" - }, - "sleeping": { - "type": "long" - }, - "stopped": { - "type": "long" - }, - "zombie": { - "type": "long" - }, - "dead": { - "type": "long" - }, - "unknown": { - "type": "long" + "*": { + "type": "object" } } } } }, - "raid": { + "blocks": { + "properties": { + "total": { + "type": "long" + }, + "synced": { + "type": "long" + } + } + } + } + }, + "socket": { + "properties": { + "local": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "remote": { "properties": { - "name": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + }, + "host": { "ignore_above": 1024, "type": "keyword" }, - "status": { + "etld_plus_one": { "ignore_above": 1024, "type": "keyword" }, - "level": { + "host_error": { "ignore_above": 1024, "type": "keyword" - }, - "sync_action": { + } + } + }, + "process": { + "properties": { + "cmdline": { "ignore_above": 1024, "type": "keyword" - }, - "disks": { - "properties": { - "active": { - "type": "long" - }, - "total": { - "type": "long" - }, - "spare": { - "type": "long" - }, - "failed": { - "type": "long" - }, - "states": { - "properties": { - "*": { - "type": "object" - } - } - } - } - }, - "blocks": { - "properties": { - "total": { - "type": "long" - }, - "synced": { - "type": "long" - } - } } } }, - "socket": { + "user": { + "properties": {} + }, + "summary": { "properties": { - "local": { + "all": { "properties": { - "ip": { - "type": "ip" + "count": { + "type": "long" }, - "port": { + "listening": { "type": "long" } } }, - "remote": { + "tcp": { "properties": { - "ip": { - "type": "ip" - }, - "port": { + "memory": { "type": "long" }, - "host": { - "ignore_above": 1024, - "type": "keyword" - }, - "etld_plus_one": { - "ignore_above": 1024, - "type": "keyword" - }, - "host_error": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "process": { - "properties": { - "cmdline": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "user": { - "properties": {} - }, - "summary": { - "properties": { "all": { "properties": { + "orphan": { + "type": "long" + }, "count": { "type": "long" }, "listening": { "type": "long" - } - } - }, - "tcp": { - "properties": { - "memory": { + }, + "established": { "type": "long" }, - "all": { - "properties": { - "orphan": { - "type": "long" - }, - "count": { - "type": "long" - }, - "listening": { - "type": "long" - }, - "established": { - "type": "long" - }, - "close_wait": { - "type": "long" - }, - "time_wait": { - "type": "long" - }, - "syn_sent": { - "type": "long" - }, - "syn_recv": { - "type": "long" - }, - "fin_wait1": { - "type": "long" - }, - "fin_wait2": { - "type": "long" - }, - "last_ack": { - "type": "long" - }, - "closing": { - "type": "long" - } - } - } - } - }, - "udp": { - "properties": { - "memory": { + "close_wait": { "type": "long" }, - "all": { - "properties": { - "count": { - "type": "long" - } - } + "time_wait": { + "type": "long" + }, + "syn_sent": { + "type": "long" + }, + "syn_recv": { + "type": "long" + }, + "fin_wait1": { + "type": "long" + }, + "fin_wait2": { + "type": "long" + }, + "last_ack": { + "type": "long" + }, + "closing": { + "type": "long" } } } } - } - } - }, - "uptime": { - "properties": { - "duration": { + }, + "udp": { "properties": { - "ms": { + "memory": { "type": "long" + }, + "all": { + "properties": { + "count": { + "type": "long" + } + } } } } } - }, - "users": { + } + } + }, + "uptime": { + "properties": { + "duration": { "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "seat": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "service": { - "ignore_above": 1024, - "type": "keyword" - }, - "remote": { - "type": "boolean" - }, - "state": { - "ignore_above": 1024, - "type": "keyword" - }, - "scope": { - "ignore_above": 1024, - "type": "keyword" - }, - "leader": { + "ms": { "type": "long" - }, - "remote_host": { - "ignore_above": 1024, - "type": "keyword" } } } } - } - }, - "_meta": { - "managed_by": "fleet", - "managed": true, - "package": { - "name": "system" + }, + "users": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "seat": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "service": { + "ignore_above": 1024, + "type": "keyword" + }, + "remote": { + "type": "boolean" + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "leader": { + "type": "long" + }, + "remote_host": { + "ignore_above": 1024, + "type": "keyword" + } + } } } } - }, - "data_stream": {}, - "composed_of": [ - ".fleet_component_template-1" - ], - "_meta": { - "managed_by": "fleet", - "managed": true, - "package": { - "name": "system" - } } } `; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts index ee6d7086cdd3..ea2d0868c6d1 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts @@ -46,11 +46,6 @@ describe('buildDefaultSettings', () => { "lifecycle": Object { "name": "logs", }, - "mapping": Object { - "total_fields": Object { - "limit": "10000", - }, - }, "query": Object { "default_field": Array [ "field1Keyword", diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.ts index 84ec75b9da06..7f8e8e854410 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.ts @@ -67,12 +67,6 @@ export function buildDefaultSettings({ }, // What should be our default for the compression? codec: 'best_compression', - mapping: { - total_fields: { - limit: '10000', - }, - }, - // All the default fields which should be queried have to be added here. // So far we add all keyword and text fields here if there are any, otherwise // this setting is skipped. 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 1303db1a36c0..894c2820fa2e 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { merge } from 'lodash'; +import { merge, cloneDeep } from 'lodash'; import Boom from '@hapi/boom'; import type { ElasticsearchClient, Logger, SavedObjectsClientContract } from 'src/core/server'; @@ -17,18 +17,23 @@ import type { InstallablePackage, IndexTemplate, PackageInfo, + IndexTemplateMappings, + TemplateMapEntry, + TemplateMap, } 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 { removeAssetTypesFromInstalledEs, saveInstalledEsRefs } from '../../packages/install'; import { - FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME, - FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT, + FLEET_COMPONENT_TEMPLATES, + MAPPINGS_TEMPLATE_SUFFIX, + SETTINGS_TEMPLATE_SUFFIX, + USER_SETTINGS_TEMPLATE_SUFFIX, } from '../../../../constants'; -import type { ESAssetMetadata } from '../meta'; import { getESAssetMetadata } from '../meta'; import { retryTransientEsErrors } from '../retry'; @@ -43,6 +48,8 @@ import { } from './template'; import { buildDefaultSettings } from './default_settings'; +const FLEET_COMPONENT_TEMPLATE_NAMES = FLEET_COMPONENT_TEMPLATES.map((tmpl) => tmpl.name); + export const installTemplates = async ( installablePackage: InstallablePackage, esClient: ElasticsearchClient, @@ -202,19 +209,6 @@ export async function installTemplateForDataStream({ }); } -interface TemplateMapEntry { - _meta: ESAssetMetadata; - template: - | { - mappings: NonNullable; - } - | { - settings: NonNullable | object; - }; -} - -type TemplateMap = Record; - function putComponentTemplate( esClient: ElasticsearchClient, logger: Logger, @@ -223,7 +217,10 @@ function putComponentTemplate( name: string; create?: boolean; } -): { clusterPromise: Promise; name: string } { +): { + clusterPromise: ReturnType; + name: string; +} { const { name, body, create = false } = params; return { clusterPromise: retryTransientEsErrors( @@ -234,41 +231,59 @@ function putComponentTemplate( }; } -const mappingsSuffix = '@mappings'; -const settingsSuffix = '@settings'; -const userSettingsSuffix = '@custom'; type TemplateBaseName = string; -type UserSettingsTemplateName = `${TemplateBaseName}${typeof userSettingsSuffix}`; +type UserSettingsTemplateName = `${TemplateBaseName}${typeof USER_SETTINGS_TEMPLATE_SUFFIX}`; const isUserSettingsTemplate = (name: string): name is UserSettingsTemplateName => - name.endsWith(userSettingsSuffix); + name.endsWith(USER_SETTINGS_TEMPLATE_SUFFIX); function buildComponentTemplates(params: { + mappings: IndexTemplateMappings; templateName: string; registryElasticsearch: RegistryElasticsearch | undefined; packageName: string; defaultSettings: IndexTemplate['template']['settings']; }) { - const { templateName, registryElasticsearch, packageName, defaultSettings } = params; - const mappingsTemplateName = `${templateName}${mappingsSuffix}`; - const settingsTemplateName = `${templateName}${settingsSuffix}`; - const userSettingsTemplateName = `${templateName}${userSettingsSuffix}`; + const { templateName, registryElasticsearch, packageName, defaultSettings, mappings } = params; + const mappingsTemplateName = `${templateName}${MAPPINGS_TEMPLATE_SUFFIX}`; + const settingsTemplateName = `${templateName}${SETTINGS_TEMPLATE_SUFFIX}`; + const userSettingsTemplateName = `${templateName}${USER_SETTINGS_TEMPLATE_SUFFIX}`; const templatesMap: TemplateMap = {}; const _meta = getESAssetMetadata({ packageName }); - if (registryElasticsearch && registryElasticsearch['index_template.mappings']) { - templatesMap[mappingsTemplateName] = { - template: { - mappings: registryElasticsearch['index_template.mappings'], - }, - _meta, - }; + const indexTemplateSettings = registryElasticsearch?.['index_template.settings'] ?? {}; + // @ts-expect-error no property .mapping (yes there is) + const indexTemplateMappingSettings = indexTemplateSettings?.index?.mapping; + const indexTemplateSettingsForTemplate = cloneDeep(indexTemplateSettings); + + // index.mapping settings must go on the mapping component template otherwise + // the template may be rejected e.g if nested_fields.limit has been increased + if (indexTemplateMappingSettings) { + // @ts-expect-error no property .mapping + delete indexTemplateSettingsForTemplate.index.mapping; } + templatesMap[mappingsTemplateName] = { + template: { + settings: { + index: { + mapping: { + total_fields: { + limit: '10000', + }, + ...indexTemplateMappingSettings, + }, + }, + }, + mappings: merge(mappings, registryElasticsearch?.['index_template.mappings'] ?? {}), + }, + _meta, + }; + templatesMap[settingsTemplateName] = { template: { - settings: merge(defaultSettings, registryElasticsearch?.['index_template.settings'] ?? {}), + settings: merge(defaultSettings, indexTemplateSettingsForTemplate), }, _meta, }; @@ -285,6 +300,7 @@ function buildComponentTemplates(params: { } async function installDataStreamComponentTemplates(params: { + mappings: IndexTemplateMappings; templateName: string; registryElasticsearch: RegistryElasticsearch | undefined; esClient: ElasticsearchClient; @@ -292,16 +308,23 @@ async function installDataStreamComponentTemplates(params: { packageName: string; defaultSettings: IndexTemplate['template']['settings']; }) { - const { templateName, registryElasticsearch, esClient, packageName, defaultSettings, logger } = - params; - const templates = buildComponentTemplates({ + const { + templateName, + registryElasticsearch, + esClient, + packageName, + defaultSettings, + logger, + mappings, + } = params; + const componentTemplates = buildComponentTemplates({ + mappings, templateName, registryElasticsearch, packageName, defaultSettings, }); - const templateNames = Object.keys(templates); - const templateEntries = Object.entries(templates); + const templateEntries = Object.entries(componentTemplates); // TODO: Check return values for errors await Promise.all( templateEntries.map(async ([name, body]) => { @@ -327,18 +350,31 @@ async function installDataStreamComponentTemplates(params: { }) ); - return templateNames; + return { componentTemplateNames: Object.keys(componentTemplates) }; } -export async function ensureDefaultComponentTemplate( +export async function ensureDefaultComponentTemplates( esClient: ElasticsearchClient, logger: Logger +) { + return Promise.all( + FLEET_COMPONENT_TEMPLATES.map(({ name, body }) => + ensureComponentTemplate(esClient, logger, name, body) + ) + ); +} + +export async function ensureComponentTemplate( + esClient: ElasticsearchClient, + logger: Logger, + name: string, + body: TemplateMapEntry ) { const getTemplateRes = await retryTransientEsErrors( () => esClient.cluster.getComponentTemplate( { - name: FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME, + name, }, { ignore: [404], @@ -350,8 +386,8 @@ export async function ensureDefaultComponentTemplate( const existingTemplate = getTemplateRes?.component_templates?.[0]; if (!existingTemplate) { await putComponentTemplate(esClient, logger, { - name: FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME, - body: FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT, + name, + body, }).clusterPromise; } @@ -434,7 +470,8 @@ export async function installTemplate({ ilmPolicy: dataStream.ilm_policy, }); - const composedOfTemplates = await installDataStreamComponentTemplates({ + const { componentTemplateNames } = await installDataStreamComponentTemplates({ + mappings, templateName, registryElasticsearch: dataStream.elasticsearch, esClient, @@ -444,13 +481,10 @@ export async function installTemplate({ }); const template = getTemplate({ - type: dataStream.type, templateIndexPattern, - fields: validFields, - mappings, pipelineName, packageName, - composedOfTemplates, + composedOfTemplates: componentTemplateNames, templatePriority, hidden: dataStream.hidden, }); @@ -482,7 +516,9 @@ export function getAllTemplateRefs(installedTemplates: IndexTemplateEntry[]) { ]; const componentTemplates = installedTemplate.indexTemplate.composed_of // Filter global component template shared between integrations - .filter((componentTemplateId) => componentTemplateId !== FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME) + .filter( + (componentTemplateId) => !FLEET_COMPONENT_TEMPLATE_NAMES.includes(componentTemplateId) + ) .map((componentTemplateId) => ({ id: componentTemplateId, type: ElasticsearchAssetType.componentTemplate, diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index 7650caf73d71..86edf1c5e406 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -26,7 +26,7 @@ import { updateCurrentWriteIndices, } from './template'; -const FLEET_COMPONENT_TEMPLATE = '.fleet_component_template-1'; +const FLEET_COMPONENT_TEMPLATES = ['.fleet_globals-1', '.fleet_agent_id_verification-1']; // Add our own serialiser to just do JSON.stringify expect.addSnapshotSerializer({ @@ -48,11 +48,8 @@ describe('EPM template', () => { const templateIndexPattern = 'logs-nginx.access-abcd-*'; const template = getTemplate({ - type: 'logs', templateIndexPattern, packageName: 'nginx', - fields: [], - mappings: { properties: {} }, composedOfTemplates: [], templatePriority: 200, }); @@ -63,41 +60,35 @@ describe('EPM template', () => { const composedOfTemplates = ['component1', 'component2']; const template = getTemplate({ - type: 'logs', templateIndexPattern: 'name-*', packageName: 'nginx', - fields: [], - mappings: { properties: {} }, composedOfTemplates, templatePriority: 200, }); - expect(template.composed_of).toStrictEqual([...composedOfTemplates, FLEET_COMPONENT_TEMPLATE]); + expect(template.composed_of).toStrictEqual([ + ...composedOfTemplates, + ...FLEET_COMPONENT_TEMPLATES, + ]); }); it('adds empty composed_of correctly', () => { const composedOfTemplates: string[] = []; const template = getTemplate({ - type: 'logs', templateIndexPattern: 'name-*', packageName: 'nginx', - fields: [], - mappings: { properties: {} }, composedOfTemplates, templatePriority: 200, }); - expect(template.composed_of).toStrictEqual([FLEET_COMPONENT_TEMPLATE]); + expect(template.composed_of).toStrictEqual(FLEET_COMPONENT_TEMPLATES); }); it('adds hidden field correctly', () => { const templateIndexPattern = 'logs-nginx.access-abcd-*'; const templateWithHidden = getTemplate({ - type: 'logs', templateIndexPattern, packageName: 'nginx', - fields: [], - mappings: { properties: {} }, composedOfTemplates: [], templatePriority: 200, hidden: true, @@ -105,11 +96,8 @@ describe('EPM template', () => { expect(templateWithHidden.data_stream.hidden).toEqual(true); const templateWithoutHidden = getTemplate({ - type: 'logs', templateIndexPattern, packageName: 'nginx', - fields: [], - mappings: { properties: {} }, composedOfTemplates: [], templatePriority: 200, }); @@ -123,17 +111,8 @@ describe('EPM template', () => { const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - const template = getTemplate({ - type: 'logs', - templateIndexPattern: 'foo-*', - packageName: 'nginx', - fields: processedFields, - mappings, - composedOfTemplates: [], - templatePriority: 200, - }); - expect(template).toMatchSnapshot(path.basename(ymlPath)); + expect(mappings).toMatchSnapshot(path.basename(ymlPath)); }); it('tests loading coredns.logs.yml', () => { @@ -143,37 +122,19 @@ describe('EPM template', () => { const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - const template = getTemplate({ - type: 'logs', - templateIndexPattern: 'foo-*', - packageName: 'coredns', - fields: processedFields, - mappings, - composedOfTemplates: [], - templatePriority: 200, - }); - expect(template).toMatchSnapshot(path.basename(ymlPath)); + expect(mappings).toMatchSnapshot(path.basename(ymlPath)); }); it('tests loading system.yml', () => { const ymlPath = path.join(__dirname, '../../fields/tests/system.yml'); const fieldsYML = readFileSync(ymlPath, 'utf-8'); const fields: Field[] = safeLoad(fieldsYML); - const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); - const template = getTemplate({ - type: 'metrics', - templateIndexPattern: 'whatsthis-*', - packageName: 'system', - fields: processedFields, - mappings, - composedOfTemplates: [], - templatePriority: 200, - }); - expect(template).toMatchSnapshot(path.basename(ymlPath)); + expect(mappings).toMatchSnapshot(path.basename(ymlPath)); }); it('tests processing long field with index false', () => { @@ -700,7 +661,6 @@ describe('EPM template', () => { example: { properties: { id: { - ignore_above: 1024, time_series_dimension: true, type: 'keyword', }, @@ -875,6 +835,12 @@ describe('EPM template', () => { esClient.indices.getDataStream.mockResponse({ data_streams: [{ name: 'test.prefix1-default' }], } as any); + esClient.indices.simulateTemplate.mockResponse({ + template: { + settings: { index: {} }, + mappings: { properties: {} }, + }, + } as any); const logger = loggerMock.create(); await updateCurrentWriteIndices(esClient, logger, [ { @@ -903,6 +869,14 @@ describe('EPM template', () => { { name: 'test-replicated', replicated: true }, ], } as any); + + esClient.indices.simulateTemplate.mockResponse({ + template: { + settings: { index: {} }, + mappings: { properties: {} }, + }, + } as any); + const logger = loggerMock.create(); await updateCurrentWriteIndices(esClient, logger, [ { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 73ad218d1a9f..21c7351b3138 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -6,6 +6,7 @@ */ import type { ElasticsearchClient, Logger } from 'kibana/server'; +import type { IndicesIndexSettings } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { Field, Fields } from '../../fields/field'; import type { @@ -16,7 +17,10 @@ import type { } from '../../../../types'; import { appContextService } from '../../../'; import { getRegistryDataStreamAssetBaseName } from '../index'; -import { FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME } from '../../../../constants'; +import { + FLEET_GLOBALS_COMPONENT_TEMPLATE_NAME, + FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_NAME, +} from '../../../../constants'; import { getESAssetMetadata } from '../meta'; import { retryTransientEsErrors } from '../retry'; @@ -51,20 +55,14 @@ const META_PROP_KEYS = ['metric_type', 'unit']; * @param indexPattern String with the index pattern */ export function getTemplate({ - type, templateIndexPattern, - fields, - mappings, pipelineName, packageName, composedOfTemplates, templatePriority, hidden, }: { - type: string; templateIndexPattern: string; - fields: Fields; - mappings: IndexTemplateMappings; pipelineName?: string | undefined; packageName: string; composedOfTemplates: string[]; @@ -72,10 +70,7 @@ export function getTemplate({ hidden?: boolean; }): IndexTemplate { const template = getBaseTemplate( - type, templateIndexPattern, - fields, - mappings, packageName, composedOfTemplates, templatePriority, @@ -88,10 +83,13 @@ export function getTemplate({ throw new Error(`Error template for ${templateIndexPattern} contains a final_pipeline`); } - if (appContextService.getConfig()?.agentIdVerificationEnabled) { - // Add fleet global assets - template.composed_of = [...(template.composed_of || []), FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME]; - } + template.composed_of = [ + ...(template.composed_of || []), + FLEET_GLOBALS_COMPONENT_TEMPLATE_NAME, + ...(appContextService.getConfig()?.agentIdVerificationEnabled + ? [FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_NAME] + : []), + ]; return template; } @@ -269,6 +267,7 @@ function generateKeywordMapping(field: Field): IndexTemplateMapping { } if (field.dimension) { mapping.time_series_dimension = field.dimension; + delete mapping.ignore_above; } return mapping; } @@ -320,6 +319,14 @@ export function generateTemplateName(dataStream: RegistryDataStream): string { return getRegistryDataStreamAssetBaseName(dataStream); } +/** + * Given a data stream name, return the indexTemplate name + */ +function dataStreamNameToIndexTemplateName(dataStreamName: string): string { + const [type, dataset] = dataStreamName.split('-'); // ignore namespace at the end + return [type, dataset].join('-'); +} + export function generateTemplateIndexPattern(dataStream: RegistryDataStream): string { // undefined or explicitly set to false // See also https://github.com/elastic/package-spec/pull/102 @@ -386,45 +393,22 @@ const flattenFieldsToNameAndType = ( }; function getBaseTemplate( - type: string, templateIndexPattern: string, - fields: Fields, - mappings: IndexTemplateMappings, packageName: string, composedOfTemplates: string[], templatePriority: number, hidden?: boolean ): IndexTemplate { - // Meta information to identify Ingest Manager's managed templates and indices const _meta = getESAssetMetadata({ packageName }); return { priority: templatePriority, - // To be completed with the correct index patterns index_patterns: [templateIndexPattern], template: { settings: { index: {}, }, mappings: { - // All the dynamic field mappings - dynamic_templates: [ - // This makes sure all mappings are keywords by default - { - strings_as_keyword: { - mapping: { - ignore_above: 1024, - type: 'keyword', - }, - match_mapping_type: 'string', - }, - }, - ], - // As we define fields ahead, we don't need any automatic field detection - // This makes sure all the fields are mapped to keyword by default to prevent mapping conflicts - date_detection: false, - // All the properties we know from the fields.yml file - properties: mappings.properties, _meta, }, }, @@ -489,70 +473,81 @@ const getDataStreams = async ( })); }; +const rolloverDataStream = (dataStreamName: string, esClient: ElasticsearchClient) => { + try { + // Do no wrap rollovers in retryTransientEsErrors since it is not idempotent + return esClient.indices.rollover({ + alias: dataStreamName, + }); + } catch (error) { + throw new Error(`cannot rollover data stream [${dataStreamName}] due to error: ${error}`); + } +}; + const updateAllDataStreams = async ( indexNameWithTemplates: CurrentDataStream[], esClient: ElasticsearchClient, logger: Logger ): Promise => { - const updatedataStreamPromises = indexNameWithTemplates.map( - ({ dataStreamName, indexTemplate }) => { - return updateExistingDataStream({ dataStreamName, esClient, logger, indexTemplate }); - } - ); + const updatedataStreamPromises = indexNameWithTemplates.map((templateEntry) => { + return updateExistingDataStream({ + esClient, + logger, + dataStreamName: templateEntry.dataStreamName, + }); + }); await Promise.all(updatedataStreamPromises); }; const updateExistingDataStream = async ({ dataStreamName, esClient, logger, - indexTemplate, }: { dataStreamName: string; esClient: ElasticsearchClient; logger: Logger; - indexTemplate: IndexTemplate; }) => { - const { settings, mappings } = indexTemplate.template; - - // for now, remove from object so as not to update stream or data stream properties of the index until type and name - // are added in https://github.com/elastic/kibana/issues/66551. namespace value we will continue - // to skip updating and assume the value in the index mapping is correct - delete mappings.properties.stream; - delete mappings.properties.data_stream; - // try to update the mappings first + let settings: IndicesIndexSettings; try { + const simulateResult = await retryTransientEsErrors(() => + esClient.indices.simulateTemplate({ + name: dataStreamNameToIndexTemplateName(dataStreamName), + }) + ); + + settings = simulateResult.template.settings; + const mappings = simulateResult.template.mappings; + // for now, remove from object so as not to update stream or data stream properties of the index until type and name + // are added in https://github.com/elastic/kibana/issues/66551. namespace value we will continue + // to skip updating and assume the value in the index mapping is correct + if (mappings && mappings.properties) { + delete mappings.properties.stream; + delete mappings.properties.data_stream; + } await retryTransientEsErrors( () => esClient.indices.putMapping({ index: dataStreamName, - body: mappings, + body: mappings || {}, write_index_only: true, }), { logger } ); // if update fails, rollover data stream } catch (err) { - try { - // Do no wrap rollovers in retryTransientEsErrors since it is not idempotent - const path = `/${dataStreamName}/_rollover`; - await esClient.transport.request({ - method: 'POST', - path, - }); - } catch (error) { - throw new Error(`cannot rollover data stream ${error}`); - } + await rolloverDataStream(dataStreamName, esClient); + return; } // update settings after mappings was successful to ensure // pointing to the new pipeline is safe // for now, only update the pipeline - if (!settings.index.default_pipeline) return; + if (!settings?.index?.default_pipeline) return; try { await retryTransientEsErrors( () => esClient.indices.putSettings({ index: dataStreamName, - body: { default_pipeline: settings.index.default_pipeline }, + body: { default_pipeline: settings!.index!.default_pipeline }, }), { logger } ); diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.test.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.test.ts new file mode 100644 index 000000000000..51aee45c83cf --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright 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 { + ISavedObjectsImporter, + SavedObjectsImportFailure, + SavedObjectsImportSuccess, + SavedObjectsImportResponse, +} from 'src/core/server'; + +import { loggingSystemMock } from '../../../../../../../../src/core/server/mocks'; + +import type { ArchiveAsset } from './install'; + +jest.mock('timers/promises', () => ({ + async setTimeout() {}, +})); + +import { installKibanaSavedObjects } from './install'; + +const mockLogger = loggingSystemMock.createLogger(); + +const mockImporter: jest.Mocked = { + import: jest.fn(), + resolveImportErrors: jest.fn(), +}; + +const createImportError = (so: ArchiveAsset, type: string) => + ({ id: so.id, error: { type } } as SavedObjectsImportFailure); +const createImportSuccess = (so: ArchiveAsset) => + ({ id: so.id, type: so.type, meta: {} } as SavedObjectsImportSuccess); +const createAsset = (asset: Partial) => + ({ id: 1234, type: 'dashboard', attributes: {}, ...asset } as ArchiveAsset); + +const createImportResponse = ( + errors: SavedObjectsImportFailure[] = [], + successResults: SavedObjectsImportSuccess[] = [] +) => + ({ + success: !!successResults.length, + errors, + successResults, + warnings: [], + successCount: successResults.length, + } as SavedObjectsImportResponse); + +describe('installKibanaSavedObjects', () => { + beforeEach(() => { + mockImporter.import.mockReset(); + mockImporter.resolveImportErrors.mockReset(); + }); + + it('should retry on conflict error', async () => { + const asset = createAsset({ attributes: { hello: 'world' } }); + const conflictResponse = createImportResponse([createImportError(asset, 'conflict')]); + const successResponse = createImportResponse([], [createImportSuccess(asset)]); + + mockImporter.import + .mockResolvedValueOnce(conflictResponse) + .mockResolvedValueOnce(successResponse); + + await installKibanaSavedObjects({ + savedObjectsImporter: mockImporter, + logger: mockLogger, + kibanaAssets: [asset], + }); + + expect(mockImporter.import).toHaveBeenCalledTimes(2); + }); + + it('should give up after 50 retries on conflict errors', async () => { + const asset = createAsset({ attributes: { hello: 'world' } }); + const conflictResponse = createImportResponse([createImportError(asset, 'conflict')]); + + mockImporter.import.mockImplementation(() => Promise.resolve(conflictResponse)); + + await expect( + installKibanaSavedObjects({ + savedObjectsImporter: mockImporter, + logger: mockLogger, + kibanaAssets: [asset], + }) + ).rejects.toEqual(expect.any(Error)); + expect(mockImporter.import).toHaveBeenCalledTimes(51); + }); + it('should not retry errors that arent conflict errors', async () => { + const asset = createAsset({ attributes: { hello: 'world' } }); + const errorResponse = createImportResponse([createImportError(asset, 'something_bad')]); + const successResponse = createImportResponse([], [createImportSuccess(asset)]); + + mockImporter.import.mockResolvedValueOnce(errorResponse).mockResolvedValueOnce(successResponse); + + expect( + installKibanaSavedObjects({ + savedObjectsImporter: mockImporter, + logger: mockLogger, + kibanaAssets: [asset], + }) + ).rejects.toEqual(expect.any(Error)); + }); + + it('should resolve reference errors', async () => { + const asset = createAsset({ attributes: { hello: 'world' } }); + const referenceErrorResponse = createImportResponse([ + createImportError(asset, 'missing_references'), + ]); + const successResponse = createImportResponse([], [createImportSuccess(asset)]); + + mockImporter.import.mockResolvedValueOnce(referenceErrorResponse); + mockImporter.resolveImportErrors.mockResolvedValueOnce(successResponse); + + await installKibanaSavedObjects({ + savedObjectsImporter: mockImporter, + logger: mockLogger, + kibanaAssets: [asset], + }); + + expect(mockImporter.import).toHaveBeenCalledTimes(1); + expect(mockImporter.resolveImportErrors).toHaveBeenCalledTimes(1); + }); +}); 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 5ab15a1f52e7..d654fab427f1 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 @@ -5,6 +5,8 @@ * 2.0. */ +import { setTimeout } from 'timers/promises'; + import type { SavedObject, SavedObjectsBulkCreateObject, @@ -13,7 +15,6 @@ import type { Logger, } from 'src/core/server'; import type { SavedObjectsImportSuccess, SavedObjectsImportFailure } from 'src/core/server/types'; - import { createListStream } from '@kbn/utils'; import { partition } from 'lodash'; @@ -166,7 +167,40 @@ export async function getKibanaAssets( return result; } -async function installKibanaSavedObjects({ +const isImportConflictError = (e: SavedObjectsImportFailure) => e?.error?.type === 'conflict'; +/** + * retry saved object import if only conflict errors are encountered + */ +async function retryImportOnConflictError( + importCall: () => ReturnType, + { + logger, + maxAttempts = 50, + _attempt = 0, + }: { logger?: Logger; _attempt?: number; maxAttempts?: number } = {} +): ReturnType { + const result = await importCall(); + + const errors = result.errors ?? []; + if (_attempt < maxAttempts && errors.length && errors.every(isImportConflictError)) { + const retryCount = _attempt + 1; + const retryDelayMs = 1000 + Math.floor(Math.random() * 3000); // 1s + 0-3s of jitter + + logger?.debug( + `Retrying import operation after [${ + retryDelayMs * 1000 + }s] due to conflict errors: ${JSON.stringify(errors)}` + ); + + await setTimeout(retryDelayMs); + return retryImportOnConflictError(importCall, { logger, _attempt: retryCount }); + } + + return result; +} + +// only exported for testing +export async function installKibanaSavedObjects({ savedObjectsImporter, kibanaAssets, logger, @@ -185,18 +219,19 @@ async function installKibanaSavedObjects({ return []; } else { const { successResults: importSuccessResults = [], errors: importErrors = [] } = - await savedObjectsImporter.import({ - overwrite: true, - readStream: createListStream(toBeSavedObjects), - createNewCopies: false, - }); + await retryImportOnConflictError(() => + savedObjectsImporter.import({ + overwrite: true, + readStream: createListStream(toBeSavedObjects), + createNewCopies: false, + }) + ); allSuccessResults = importSuccessResults; const [referenceErrors, otherErrors] = partition( importErrors, (e) => e?.error?.type === 'missing_references' ); - if (otherErrors?.length) { throw new Error( `Encountered ${ diff --git a/x-pack/plugins/fleet/server/services/epm/packages/bundled_packages.ts b/x-pack/plugins/fleet/server/services/epm/packages/bundled_packages.ts index 8ccd2006ad84..6934134c34ac 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/bundled_packages.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/bundled_packages.ts @@ -9,19 +9,26 @@ import fs from 'fs/promises'; import path from 'path'; import type { BundledPackage } from '../../../types'; +import { IngestManagerError } from '../../../errors'; import { appContextService } from '../../app_context'; import { splitPkgKey } from '../registry'; -const BUNDLED_PACKAGE_DIRECTORY = path.join(__dirname, '../../../bundled_packages'); - export async function getBundledPackages(): Promise { + const config = appContextService.getConfig(); + + const bundledPackageLocation = config?.developer?.bundledPackageLocation; + + if (!bundledPackageLocation) { + throw new IngestManagerError('xpack.fleet.developer.bundledPackageLocation is not configured'); + } + try { - const dirContents = await fs.readdir(BUNDLED_PACKAGE_DIRECTORY); + const dirContents = await fs.readdir(bundledPackageLocation); const zipFiles = dirContents.filter((file) => file.endsWith('.zip')); const result = await Promise.all( zipFiles.map(async (zipFile) => { - const file = await fs.readFile(path.join(BUNDLED_PACKAGE_DIRECTORY, zipFile)); + const file = await fs.readFile(path.join(bundledPackageLocation, zipFile)); const { pkgName, pkgVersion } = splitPkgKey(zipFile.replace(/\.zip$/, '')); @@ -36,7 +43,7 @@ export async function getBundledPackages(): Promise { return result; } catch (err) { const logger = appContextService.getLogger(); - logger.debug(`Unable to read bundled packages from ${BUNDLED_PACKAGE_DIRECTORY}`); + logger.debug(`Unable to read bundled packages from ${bundledPackageLocation}`); return []; } diff --git a/x-pack/plugins/fleet/server/services/epm/packages/index.ts b/x-pack/plugins/fleet/server/services/epm/packages/index.ts index fa2e5781a209..d742103dccf1 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/index.ts @@ -25,6 +25,8 @@ export { getLimitedPackages, } from './get'; +export { getBundledPackages } from './bundled_packages'; + export type { BulkInstallResponse, IBulkInstallPackageError } from './install'; export { handleInstallPackageFailure, installPackage, ensureInstalledPackage } from './install'; export { removeInstallation } from './remove'; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts index 1a1f1aa617f5..973e2624af5d 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts @@ -54,7 +54,7 @@ jest.mock('../kibana/index_pattern/install', () => { }); jest.mock('../archive', () => { return { - parseAndVerifyArchiveEntries: jest.fn(() => + generatePackageInfoFromArchiveBuffer: jest.fn(() => Promise.resolve({ packageInfo: { name: 'apache', version: '1.3.0' } }) ), unpackBufferToCache: jest.fn(), @@ -83,6 +83,7 @@ describe('install', () => { .mockImplementation(() => Promise.resolve({ packageInfo: { license: 'basic' } } as any)); mockGetBundledPackages.mockReset(); + (install._installPackage as jest.Mock).mockClear(); }); describe('registry', () => { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index 107b906a969c..8a4271c20be6 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -32,7 +32,11 @@ import type { } from '../../../types'; import { appContextService } from '../../app_context'; import * as Registry from '../registry'; -import { setPackageInfo, parseAndVerifyArchiveEntries, unpackBufferToCache } from '../archive'; +import { + setPackageInfo, + generatePackageInfoFromArchiveBuffer, + unpackBufferToCache, +} from '../archive'; import { toAssetReference } from '../kibana/assets/install'; import type { ArchiveAsset } from '../kibana/assets/install'; @@ -276,6 +280,7 @@ async function installPackageFromRegistry({ ], status: 'already_installed', installType, + installSource: 'registry', }; } } @@ -307,7 +312,7 @@ async function installPackageFromRegistry({ ...telemetryEvent, errorMessage: err.message, }); - return { error: err, installType }; + return { error: err, installType, installSource: 'registry' }; } const savedObjectsImporter = appContextService @@ -338,7 +343,7 @@ async function installPackageFromRegistry({ ...telemetryEvent, status: 'success', }); - return { assets, status: 'installed', installType }; + return { assets, status: 'installed', installType, installSource: 'registry' }; }) .catch(async (err: Error) => { logger.warn(`Failure to install package [${pkgName}]: [${err.toString()}]`); @@ -355,7 +360,7 @@ async function installPackageFromRegistry({ ...telemetryEvent, errorMessage: err.message, }); - return { error: err, installType }; + return { error: err, installType, installSource: 'registry' }; }); } catch (e) { sendEvent({ @@ -365,6 +370,7 @@ async function installPackageFromRegistry({ return { error: e, installType, + installSource: 'registry', }; } } @@ -389,7 +395,7 @@ async function installPackageByUpload({ let installType: InstallType = 'unknown'; const telemetryEvent: PackageUpdateEvent = getTelemetryEvent('', ''); try { - const { packageInfo } = await parseAndVerifyArchiveEntries(archiveBuffer, contentType); + const { packageInfo } = await generatePackageInfoFromArchiveBuffer(archiveBuffer, contentType); const installedPkg = await getInstallationObject({ savedObjectsClient, @@ -454,7 +460,7 @@ async function installPackageByUpload({ ...telemetryEvent, errorMessage: e.message, }); - return { error: e, installType }; + return { error: e, installType, installSource: 'upload' }; } } @@ -463,9 +469,10 @@ export type InstallPackageParams = { } & ( | ({ installSource: Extract } & InstallRegistryPackageParams) | ({ installSource: Extract } & InstallUploadedArchiveParams) + | ({ installSource: Extract } & InstallUploadedArchiveParams) ); -export async function installPackage(args: InstallPackageParams) { +export async function installPackage(args: InstallPackageParams): Promise { if (!('installSource' in args)) { throw new Error('installSource is required'); } @@ -487,7 +494,7 @@ export async function installPackage(args: InstallPackageParams) { `found bundled package for requested install of ${pkgkey} - installing from bundled package archive` ); - const response = installPackageByUpload({ + const response = await installPackageByUpload({ savedObjectsClient, esClient, archiveBuffer: matchingBundledPackage.buffer, @@ -495,11 +502,11 @@ export async function installPackage(args: InstallPackageParams) { spaceId, }); - return response; + return { ...response, installSource: 'bundled' }; } logger.debug(`kicking off install of ${pkgkey} from registry`); - const response = installPackageFromRegistry({ + const response = await installPackageFromRegistry({ savedObjectsClient, pkgkey, esClient, @@ -510,7 +517,7 @@ export async function installPackage(args: InstallPackageParams) { return response; } else if (args.installSource === 'upload') { const { archiveBuffer, contentType, spaceId } = args; - const response = installPackageByUpload({ + const response = await installPackageByUpload({ savedObjectsClient, esClient, archiveBuffer, @@ -519,7 +526,6 @@ export async function installPackage(args: InstallPackageParams) { }); return response; } - // @ts-expect-error s/b impossibe b/c `never` by this point, but just in case throw new Error(`Unknown installSource: ${args.installSource}`); } diff --git a/x-pack/plugins/fleet/server/services/epm/registry/index.ts b/x-pack/plugins/fleet/server/services/epm/registry/index.ts index db6a324352ca..182f20297afd 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/index.ts @@ -261,7 +261,7 @@ export async function ensureCachedArchiveInfo( } } -async function fetchArchiveBuffer( +export async function fetchArchiveBuffer( pkgName: string, pkgVersion: string ): Promise<{ archiveBuffer: Buffer; archivePath: string }> { diff --git a/x-pack/plugins/fleet/server/services/fleet_server/index.ts b/x-pack/plugins/fleet/server/services/fleet_server/index.ts index 0ac47df1bfda..43403bf3c946 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server/index.ts +++ b/x-pack/plugins/fleet/server/services/fleet_server/index.ts @@ -16,8 +16,10 @@ export async function hasFleetServers(esClient: ElasticsearchClient) { const res = await esClient.search<{}, {}>({ index: FLEET_SERVER_SERVERS_INDEX, ignore_unavailable: true, + filter_path: 'hits.total', + track_total_hits: true, + rest_total_hits_as_int: true, }); - // @ts-expect-error value is number | TotalHits - return res.hits.total.value > 0; + return (res.hits.total as number) > 0; } diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts index 23ee77e0f28c..589fb10f9958 100644 --- a/x-pack/plugins/fleet/server/services/output.test.ts +++ b/x-pack/plugins/fleet/server/services/output.test.ts @@ -10,10 +10,13 @@ import type { OutputSOAttributes } from '../types'; import { outputService, outputIdToUuid } from './output'; import { appContextService } from './app_context'; +import { agentPolicyService } from './agent_policy'; jest.mock('./app_context'); +jest.mock('./agent_policy'); const mockedAppContextService = appContextService as jest.Mocked; +const mockedAgentPolicyService = agentPolicyService as jest.Mocked; const CLOUD_ID = 'dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw=='; @@ -145,6 +148,9 @@ function getMockedSoClient( } describe('Output Service', () => { + beforeEach(() => { + mockedAgentPolicyService.removeOutputFromAll.mockReset(); + }); describe('create', () => { it('work with a predefined id', async () => { const soClient = getMockedSoClient(); @@ -447,6 +453,15 @@ describe('Output Service', () => { expect(soClient.delete).toBeCalled(); }); + + it('Call removeOutputFromAll before deleting the output', async () => { + const soClient = getMockedSoClient(); + await outputService.delete(soClient, 'existing-preconfigured-default-output', { + fromPreconfiguration: true, + }); + expect(mockedAgentPolicyService.removeOutputFromAll).toBeCalled(); + expect(soClient.delete).toBeCalled(); + }); }); describe('get', () => { diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index 832cb810f750..088220a1b471 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -13,6 +13,7 @@ import { DEFAULT_OUTPUT, DEFAULT_OUTPUT_ID, OUTPUT_SAVED_OBJECT_TYPE } from '../ import { decodeCloudId, normalizeHostsForAgents, SO_SEARCH_LIMIT, outputType } from '../../common'; import { OutputUnauthorizedError } from '../errors'; +import { agentPolicyService } from './agent_policy'; import { appContextService } from './app_context'; const SAVED_OBJECT_TYPE = OUTPUT_SAVED_OBJECT_TYPE; @@ -241,6 +242,12 @@ class OutputService { throw new OutputUnauthorizedError(`Default monitoring output ${id} cannot be deleted.`); } + await agentPolicyService.removeOutputFromAll( + soClient, + appContextService.getInternalUserESClient(), + id + ); + return soClient.delete(SAVED_OBJECT_TYPE, outputIdToUuid(id)); } diff --git a/x-pack/plugins/fleet/server/services/package_policy.test.ts b/x-pack/plugins/fleet/server/services/package_policy.test.ts index 704071154ac9..2fde004048a3 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -632,7 +632,7 @@ describe('Package policy service', () => { ).rejects.toThrow('Saved object [abc/123] conflict'); }); - it('should only update input vars that are not frozen', async () => { + it('should throw if the user try to update input vars that are frozen', async () => { const savedObjectsClient = savedObjectsClientMock.create(); const mockPackagePolicy = createPackagePolicyMock(); const mockInputs = [ @@ -743,22 +743,143 @@ describe('Package policy service', () => { ); const elasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - const result = await packagePolicyService.update( + const res = packagePolicyService.update( savedObjectsClient, elasticsearchClient, 'the-package-policy-id', { ...mockPackagePolicy, inputs: inputsUpdate } ); + await expect(res).rejects.toThrow('cat is a frozen variable and cannot be modified'); + }); + + it('should allow to update input vars that are frozen with the force flag', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const mockPackagePolicy = createPackagePolicyMock(); + const mockInputs = [ + { + config: {}, + enabled: true, + keep_enabled: true, + type: 'endpoint', + vars: { + dog: { + type: 'text', + value: 'dalmatian', + }, + cat: { + type: 'text', + value: 'siamese', + frozen: true, + }, + }, + streams: [ + { + data_stream: { + type: 'birds', + dataset: 'migratory.patterns', + }, + enabled: false, + id: `endpoint-migratory.patterns-${mockPackagePolicy.id}`, + vars: { + paths: { + value: ['north', 'south'], + type: 'text', + frozen: true, + }, + period: { + value: '6mo', + type: 'text', + }, + }, + }, + ], + }, + ]; + const inputsUpdate = [ + { + config: {}, + enabled: false, + type: 'endpoint', + vars: { + dog: { + type: 'text', + value: 'labrador', + }, + cat: { + type: 'text', + value: 'tabby', + }, + }, + streams: [ + { + data_stream: { + type: 'birds', + dataset: 'migratory.patterns', + }, + enabled: false, + id: `endpoint-migratory.patterns-${mockPackagePolicy.id}`, + vars: { + paths: { + value: ['east', 'west'], + type: 'text', + }, + period: { + value: '12mo', + type: 'text', + }, + }, + }, + ], + }, + ]; + const attributes = { + ...mockPackagePolicy, + inputs: mockInputs, + }; + + savedObjectsClient.get.mockResolvedValue({ + id: 'test', + type: 'abcd', + references: [], + version: 'test', + attributes, + }); + + savedObjectsClient.update.mockImplementation( + async ( + type: string, + id: string, + attrs: any + ): Promise> => { + savedObjectsClient.get.mockResolvedValue({ + id: 'test', + type: 'abcd', + references: [], + version: 'test', + attributes: attrs, + }); + return attrs; + } + ); + const elasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + const result = await packagePolicyService.update( + savedObjectsClient, + elasticsearchClient, + 'the-package-policy-id', + { ...mockPackagePolicy, inputs: inputsUpdate }, + { force: true } + ); + const [modifiedInput] = result.inputs; expect(modifiedInput.enabled).toEqual(true); expect(modifiedInput.vars!.dog.value).toEqual('labrador'); - expect(modifiedInput.vars!.cat.value).toEqual('siamese'); + expect(modifiedInput.vars!.cat.value).toEqual('tabby'); const [modifiedStream] = modifiedInput.streams; - expect(modifiedStream.vars!.paths.value).toEqual(expect.arrayContaining(['north', 'south'])); + expect(modifiedStream.vars!.paths.value).toEqual(expect.arrayContaining(['east', 'west'])); expect(modifiedStream.vars!.period.value).toEqual('12mo'); }); - it('should add new input vars when updating', async () => { const savedObjectsClient = savedObjectsClientMock.create(); const mockPackagePolicy = createPackagePolicyMock(); @@ -810,7 +931,7 @@ describe('Package policy service', () => { }, cat: { type: 'text', - value: 'tabby', + value: 'siamese', }, }, streams: [ @@ -823,7 +944,7 @@ describe('Package policy service', () => { id: `endpoint-migratory.patterns-${mockPackagePolicy.id}`, vars: { paths: { - value: ['east', 'west'], + value: ['north', 'south'], type: 'text', }, period: { diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 546ae9c6fb9a..bd68b6c1d4b2 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { omit, partition } from 'lodash'; +import { omit, partition, isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; import semverLt from 'semver/functions/lt'; import { getFlattenedObject } from '@kbn/std'; @@ -358,7 +358,7 @@ class PackagePolicyService implements PackagePolicyServiceInterface { esClient: ElasticsearchClient, id: string, packagePolicyUpdate: UpdatePackagePolicy, - options?: { user?: AuthenticatedUser }, + options?: { user?: AuthenticatedUser; force?: boolean }, currentVersion?: string ): Promise { const packagePolicy = { ...packagePolicyUpdate, name: packagePolicyUpdate.name.trim() }; @@ -386,7 +386,7 @@ class PackagePolicyService implements PackagePolicyServiceInterface { assignStreamIdToInput(oldPackagePolicy.id, input) ); - inputs = enforceFrozenInputs(oldPackagePolicy.inputs, inputs); + inputs = enforceFrozenInputs(oldPackagePolicy.inputs, inputs, options?.force); let elasticsearch: PackagePolicy['elasticsearch']; if (packagePolicy.package?.name) { const pkgInfo = await getPackageInfo({ @@ -1119,21 +1119,25 @@ async function _compilePackageStream( return { ...stream }; } -function enforceFrozenInputs(oldInputs: PackagePolicyInput[], newInputs: PackagePolicyInput[]) { +function enforceFrozenInputs( + oldInputs: PackagePolicyInput[], + newInputs: PackagePolicyInput[], + force = false +) { const resultInputs = [...newInputs]; for (const input of resultInputs) { const oldInput = oldInputs.find((i) => i.type === input.type); if (oldInput?.keep_enabled) input.enabled = oldInput.enabled; if (input.vars && oldInput?.vars) { - input.vars = _enforceFrozenVars(oldInput.vars, input.vars); + input.vars = _enforceFrozenVars(oldInput.vars, input.vars, force); } if (input.streams && oldInput?.streams) { for (const stream of input.streams) { const oldStream = oldInput.streams.find((s) => s.id === stream.id); if (oldStream?.keep_enabled) stream.enabled = oldStream.enabled; if (stream.vars && oldStream?.vars) { - stream.vars = _enforceFrozenVars(oldStream.vars, stream.vars); + stream.vars = _enforceFrozenVars(oldStream.vars, stream.vars, force); } } } @@ -1144,12 +1148,21 @@ function enforceFrozenInputs(oldInputs: PackagePolicyInput[], newInputs: Package function _enforceFrozenVars( oldVars: Record, - newVars: Record + newVars: Record, + force = false ) { const resultVars: Record = {}; for (const [key, val] of Object.entries(newVars)) { if (oldVars[key]?.frozen) { - resultVars[key] = oldVars[key]; + if (force) { + resultVars[key] = val; + } else if (!isEqual(oldVars[key].value, val.value) || oldVars[key].type !== val.type) { + throw new PackagePolicyValidationError( + `${key} is a frozen variable and cannot be modified` + ); + } else { + resultVars[key] = oldVars[key]; + } } else { resultVars[key] = val; } @@ -1206,7 +1219,7 @@ export interface PackagePolicyServiceInterface { esClient: ElasticsearchClient, id: string, packagePolicyUpdate: UpdatePackagePolicy, - options?: { user?: AuthenticatedUser }, + options?: { user?: AuthenticatedUser; force?: boolean }, currentVersion?: string ): Promise; diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 518b79b9e854..27919d7bf101 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -16,7 +16,6 @@ import type { InstallResult, PackagePolicy, PreconfiguredAgentPolicy, - PreconfiguredOutput, RegistrySearchResult, } from '../../common/types'; import type { AgentPolicy, NewPackagePolicy, Output } from '../types'; @@ -28,10 +27,7 @@ import * as agentPolicy from './agent_policy'; import { ensurePreconfiguredPackagesAndPolicies, comparePreconfiguredPolicyToCurrent, - ensurePreconfiguredOutputs, - cleanPreconfiguredOutputs, } from './preconfiguration'; -import { outputService } from './output'; import { packagePolicyService } from './package_policy'; import { getBundledPackages } from './epm/packages/bundled_packages'; import type { InstallPackageParams } from './epm/packages/install'; @@ -41,7 +37,6 @@ jest.mock('./output'); jest.mock('./epm/packages/bundled_packages'); jest.mock('./epm/archive'); -const mockedOutputService = outputService as jest.Mocked; const mockedPackagePolicyService = packagePolicyService as jest.Mocked; const mockedGetBundledPackages = getBundledPackages as jest.MockedFunction< typeof getBundledPackages @@ -143,6 +138,7 @@ jest.mock('./epm/packages/install', () => ({ return { error: new Error(installError), installType: 'install', + installSource: 'registry', }; } @@ -157,6 +153,7 @@ jest.mock('./epm/packages/install', () => ({ return { status: 'installed', installType: 'install', + installSource: 'registry', }; } else if (args.installSource === 'upload') { const { archiveBuffer } = args; @@ -168,7 +165,7 @@ jest.mock('./epm/packages/install', () => ({ const packageInstallation = { name: pkgName, version: '1.0.0', title: pkgName }; mockInstalledPackages.set(pkgName, packageInstallation); - return { status: 'installed', installType: 'install' }; + return { status: 'installed', installType: 'install', installSource: 'upload' }; } }, ensurePackagesCompletedInstall() { @@ -934,201 +931,3 @@ describe('comparePreconfiguredPolicyToCurrent', () => { expect(hasChanged).toBe(false); }); }); - -describe('output preconfiguration', () => { - beforeEach(() => { - mockedOutputService.create.mockReset(); - mockedOutputService.update.mockReset(); - mockedOutputService.delete.mockReset(); - mockedOutputService.getDefaultDataOutputId.mockReset(); - mockedOutputService.getDefaultESHosts.mockReturnValue(['http://default-es:9200']); - mockedOutputService.bulkGet.mockImplementation(async (soClient, id): Promise => { - return [ - { - id: 'existing-output-1', - is_default: false, - is_default_monitoring: false, - name: 'Output 1', - // @ts-ignore - type: 'elasticsearch', - hosts: ['http://es.co:80'], - is_preconfigured: true, - }, - ]; - }); - }); - - it('should create preconfigured output that does not exists', async () => { - const soClient = savedObjectsClientMock.create(); - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - await ensurePreconfiguredOutputs(soClient, esClient, [ - { - id: 'non-existing-output-1', - name: 'Output 1', - type: 'elasticsearch', - is_default: false, - is_default_monitoring: false, - hosts: ['http://test.fr'], - }, - ]); - - expect(mockedOutputService.create).toBeCalled(); - expect(mockedOutputService.update).not.toBeCalled(); - expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); - }); - - it('should set default hosts if hosts is not set output that does not exists', async () => { - const soClient = savedObjectsClientMock.create(); - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - await ensurePreconfiguredOutputs(soClient, esClient, [ - { - id: 'non-existing-output-1', - name: 'Output 1', - type: 'elasticsearch', - is_default: false, - is_default_monitoring: false, - }, - ]); - - expect(mockedOutputService.create).toBeCalled(); - expect(mockedOutputService.create.mock.calls[0][1].hosts).toEqual(['http://default-es:9200']); - }); - - it('should update output if preconfigured output exists and changed', async () => { - const soClient = savedObjectsClientMock.create(); - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); - await ensurePreconfiguredOutputs(soClient, esClient, [ - { - id: 'existing-output-1', - is_default: false, - is_default_monitoring: false, - name: 'Output 1', - type: 'elasticsearch', - hosts: ['http://newhostichanged.co:9201'], // field that changed - }, - ]); - - expect(mockedOutputService.create).not.toBeCalled(); - expect(mockedOutputService.update).toBeCalled(); - expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); - }); - - it('should not delete default output if preconfigured default output exists and changed', async () => { - const soClient = savedObjectsClientMock.create(); - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); - mockedOutputService.getDefaultDataOutputId.mockResolvedValue('existing-output-1'); - await ensurePreconfiguredOutputs(soClient, esClient, [ - { - id: 'existing-output-1', - is_default: true, - is_default_monitoring: false, - name: 'Output 1', - type: 'elasticsearch', - hosts: ['http://newhostichanged.co:9201'], // field that changed - }, - ]); - - expect(mockedOutputService.delete).not.toBeCalled(); - expect(mockedOutputService.create).not.toBeCalled(); - expect(mockedOutputService.update).toBeCalled(); - expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); - }); - - const SCENARIOS: Array<{ name: string; data: PreconfiguredOutput }> = [ - { - name: 'no changes', - data: { - id: 'existing-output-1', - is_default: false, - is_default_monitoring: false, - name: 'Output 1', - type: 'elasticsearch', - hosts: ['http://es.co:80'], - }, - }, - { - name: 'hosts without port', - data: { - id: 'existing-output-1', - is_default: false, - is_default_monitoring: false, - name: 'Output 1', - type: 'elasticsearch', - hosts: ['http://es.co'], - }, - }, - ]; - SCENARIOS.forEach((scenario) => { - const { data, name } = scenario; - it(`should do nothing if preconfigured output exists and did not changed (${name})`, async () => { - const soClient = savedObjectsClientMock.create(); - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - await ensurePreconfiguredOutputs(soClient, esClient, [data]); - - expect(mockedOutputService.create).not.toBeCalled(); - expect(mockedOutputService.update).not.toBeCalled(); - }); - }); - - it('should not delete non deleted preconfigured output', async () => { - const soClient = savedObjectsClientMock.create(); - mockedOutputService.list.mockResolvedValue({ - items: [ - { id: 'output1', is_preconfigured: true } as Output, - { id: 'output2', is_preconfigured: true } as Output, - ], - page: 1, - perPage: 10000, - total: 1, - }); - await cleanPreconfiguredOutputs(soClient, [ - { - id: 'output1', - is_default: false, - is_default_monitoring: false, - name: 'Output 1', - type: 'elasticsearch', - hosts: ['http://es.co:9201'], - }, - { - id: 'output2', - is_default: false, - is_default_monitoring: false, - name: 'Output 2', - type: 'elasticsearch', - hosts: ['http://es.co:9201'], - }, - ]); - - expect(mockedOutputService.delete).not.toBeCalled(); - }); - - it('should delete deleted preconfigured output', async () => { - const soClient = savedObjectsClientMock.create(); - mockedOutputService.list.mockResolvedValue({ - items: [ - { id: 'output1', is_preconfigured: true } as Output, - { id: 'output2', is_preconfigured: true } as Output, - ], - page: 1, - perPage: 10000, - total: 1, - }); - await cleanPreconfiguredOutputs(soClient, [ - { - id: 'output1', - is_default: false, - is_default_monitoring: false, - name: 'Output 1', - type: 'elasticsearch', - hosts: ['http://es.co:9201'], - }, - ]); - - expect(mockedOutputService.delete).toBeCalled(); - expect(mockedOutputService.delete).toBeCalledTimes(1); - expect(mockedOutputService.delete.mock.calls[0][1]).toEqual('output2'); - }); -}); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index 78769e6836b5..6f8c8bbc6a20 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -8,7 +8,6 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; import { i18n } from '@kbn/i18n'; import { groupBy, omit, pick, isEqual } from 'lodash'; -import { safeDump } from 'js-yaml'; import type { NewPackagePolicy, @@ -18,11 +17,9 @@ import type { PreconfiguredAgentPolicy, PreconfiguredPackage, PreconfigurationError, - PreconfiguredOutput, PackagePolicy, } from '../../common'; import { PRECONFIGURATION_LATEST_KEYWORD } from '../../common'; -import { normalizeHostsForAgents } from '../../common'; import { PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE } from '../constants'; import { escapeSearchQueryPhrase } from './saved_object'; @@ -35,7 +32,6 @@ import type { InputsOverride } from './package_policy'; import { preconfigurePackageInputs } from './package_policy'; import { appContextService } from './app_context'; import type { UpgradeManagedPackagePoliciesResult } from './managed_package_policies'; -import { outputService } from './output'; interface PreconfigurationResult { policies: Array<{ id: string; updated_at: string }>; @@ -43,100 +39,6 @@ interface PreconfigurationResult { nonFatalErrors: Array; } -function isPreconfiguredOutputDifferentFromCurrent( - existingOutput: Output, - preconfiguredOutput: Partial -): boolean { - return ( - existingOutput.is_default !== preconfiguredOutput.is_default || - existingOutput.is_default_monitoring !== preconfiguredOutput.is_default_monitoring || - existingOutput.name !== preconfiguredOutput.name || - existingOutput.type !== preconfiguredOutput.type || - (preconfiguredOutput.hosts && - !isEqual( - existingOutput.hosts?.map(normalizeHostsForAgents), - preconfiguredOutput.hosts.map(normalizeHostsForAgents) - )) || - (preconfiguredOutput.ssl && !isEqual(preconfiguredOutput.ssl, existingOutput.ssl)) || - existingOutput.ca_sha256 !== preconfiguredOutput.ca_sha256 || - existingOutput.ca_trusted_fingerprint !== preconfiguredOutput.ca_trusted_fingerprint || - existingOutput.config_yaml !== preconfiguredOutput.config_yaml - ); -} - -export async function ensurePreconfiguredOutputs( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - outputs: PreconfiguredOutput[] -) { - const logger = appContextService.getLogger(); - - if (outputs.length === 0) { - return; - } - - const existingOutputs = await outputService.bulkGet( - soClient, - outputs.map(({ id }) => id), - { ignoreNotFound: true } - ); - - await Promise.all( - outputs.map(async (output) => { - const existingOutput = existingOutputs.find((o) => o.id === output.id); - - const { id, config, ...outputData } = output; - - const configYaml = config ? safeDump(config) : undefined; - - const data = { - ...outputData, - config_yaml: configYaml, - is_preconfigured: true, - }; - - if (!data.hosts || data.hosts.length === 0) { - data.hosts = outputService.getDefaultESHosts(); - } - - const isCreate = !existingOutput; - const isUpdateWithNewData = - existingOutput && isPreconfiguredOutputDifferentFromCurrent(existingOutput, data); - - if (isCreate) { - logger.debug(`Creating output ${output.id}`); - await outputService.create(soClient, data, { id, fromPreconfiguration: true }); - } else if (isUpdateWithNewData) { - logger.debug(`Updating output ${output.id}`); - await outputService.update(soClient, id, data, { fromPreconfiguration: true }); - // Bump revision of all policies using that output - if (outputData.is_default || outputData.is_default_monitoring) { - await agentPolicyService.bumpAllAgentPolicies(soClient, esClient); - } else { - await agentPolicyService.bumpAllAgentPoliciesForOutput(soClient, esClient, id); - } - } - }) - ); -} - -export async function cleanPreconfiguredOutputs( - soClient: SavedObjectsClientContract, - outputs: PreconfiguredOutput[] -) { - const existingPreconfiguredOutput = (await outputService.list(soClient)).items.filter( - (o) => o.is_preconfigured === true - ); - const logger = appContextService.getLogger(); - - for (const output of existingPreconfiguredOutput) { - if (!outputs.find(({ id }) => output.id === id)) { - logger.info(`Deleting preconfigured output ${output.id}`); - await outputService.delete(soClient, output.id, { fromPreconfiguration: true }); - } - } -} - export async function ensurePreconfiguredPackagesAndPolicies( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, diff --git a/x-pack/plugins/fleet/server/services/preconfiguration/index.ts b/x-pack/plugins/fleet/server/services/preconfiguration/index.ts index ccd550759337..e9ebc473af8f 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration/index.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration/index.ts @@ -6,3 +6,5 @@ */ export { resetPreconfiguredAgentPolicies } from './reset_agent_policies'; + +export { ensurePreconfiguredOutputs } from './outputs'; diff --git a/x-pack/plugins/fleet/server/services/preconfiguration/outputs.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration/outputs.test.ts new file mode 100644 index 000000000000..b461bc5463d6 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/preconfiguration/outputs.test.ts @@ -0,0 +1,315 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; + +import type { PreconfiguredOutput } from '../../../common/types'; +import type { Output } from '../../types'; + +import * as agentPolicy from '../agent_policy'; +import { outputService } from '../output'; + +import { createOrUpdatePreconfiguredOutputs, cleanPreconfiguredOutputs } from './outputs'; + +jest.mock('../agent_policy_update'); +jest.mock('../output'); +jest.mock('../epm/packages/bundled_packages'); +jest.mock('../epm/archive'); + +const mockedOutputService = outputService as jest.Mocked; + +jest.mock('../app_context', () => ({ + appContextService: { + getLogger: () => + new Proxy( + {}, + { + get() { + return jest.fn(); + }, + } + ), + }, +})); + +const spyAgentPolicyServicBumpAllAgentPoliciesForOutput = jest.spyOn( + agentPolicy.agentPolicyService, + 'bumpAllAgentPoliciesForOutput' +); + +describe('output preconfiguration', () => { + beforeEach(() => { + mockedOutputService.create.mockReset(); + mockedOutputService.update.mockReset(); + mockedOutputService.delete.mockReset(); + mockedOutputService.getDefaultDataOutputId.mockReset(); + mockedOutputService.getDefaultESHosts.mockReturnValue(['http://default-es:9200']); + mockedOutputService.bulkGet.mockImplementation(async (soClient, id): Promise => { + return [ + { + id: 'existing-output-1', + is_default: false, + is_default_monitoring: false, + name: 'Output 1', + // @ts-ignore + type: 'elasticsearch', + hosts: ['http://es.co:80'], + is_preconfigured: true, + }, + ]; + }); + }); + + it('should create preconfigured output that does not exists', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ + { + id: 'non-existing-output-1', + name: 'Output 1', + type: 'elasticsearch', + is_default: false, + is_default_monitoring: false, + hosts: ['http://test.fr'], + }, + ]); + + expect(mockedOutputService.create).toBeCalled(); + expect(mockedOutputService.update).not.toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); + }); + + it('should set default hosts if hosts is not set output that does not exists', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ + { + id: 'non-existing-output-1', + name: 'Output 1', + type: 'elasticsearch', + is_default: false, + is_default_monitoring: false, + }, + ]); + + expect(mockedOutputService.create).toBeCalled(); + expect(mockedOutputService.create.mock.calls[0][1].hosts).toEqual(['http://default-es:9200']); + }); + + it('should update output if non preconfigured output with the same id exists', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); + mockedOutputService.bulkGet.mockResolvedValue([ + { + id: 'existing-output-1', + is_default: false, + is_default_monitoring: false, + name: 'Output 1', + // @ts-ignore + type: 'elasticsearch', + hosts: ['http://es.co:80'], + is_preconfigured: false, + }, + ]); + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ + { + id: 'existing-output-1', + is_default: false, + is_default_monitoring: false, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://es.co:80'], + }, + ]); + + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).toBeCalled(); + expect(mockedOutputService.update).toBeCalledWith( + expect.anything(), + 'existing-output-1', + expect.objectContaining({ + is_preconfigured: true, + }), + { fromPreconfiguration: true } + ); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); + }); + + it('should update output if preconfigured output exists and changed', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ + { + id: 'existing-output-1', + is_default: false, + is_default_monitoring: false, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://newhostichanged.co:9201'], // field that changed + }, + ]); + + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); + }); + + it('should not delete default output if preconfigured default output exists and changed', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); + mockedOutputService.getDefaultDataOutputId.mockResolvedValue('existing-output-1'); + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ + { + id: 'existing-output-1', + is_default: true, + is_default_monitoring: false, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://newhostichanged.co:9201'], // field that changed + }, + ]); + + expect(mockedOutputService.delete).not.toBeCalled(); + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); + }); + + const SCENARIOS: Array<{ name: string; data: PreconfiguredOutput }> = [ + { + name: 'no changes', + data: { + id: 'existing-output-1', + is_default: false, + is_default_monitoring: false, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://es.co:80'], + }, + }, + { + name: 'hosts without port', + data: { + id: 'existing-output-1', + is_default: false, + is_default_monitoring: false, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://es.co'], + }, + }, + ]; + SCENARIOS.forEach((scenario) => { + const { data, name } = scenario; + it(`should do nothing if preconfigured output exists and did not changed (${name})`, async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [data]); + + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).not.toBeCalled(); + }); + }); + + describe('cleanPreconfiguredOutputs', () => { + it('should not delete non deleted preconfigured output', async () => { + const soClient = savedObjectsClientMock.create(); + mockedOutputService.list.mockResolvedValue({ + items: [ + { id: 'output1', is_preconfigured: true } as Output, + { id: 'output2', is_preconfigured: true } as Output, + ], + page: 1, + perPage: 10000, + total: 1, + }); + await cleanPreconfiguredOutputs(soClient, [ + { + id: 'output1', + is_default: false, + is_default_monitoring: false, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://es.co:9201'], + }, + { + id: 'output2', + is_default: false, + is_default_monitoring: false, + name: 'Output 2', + type: 'elasticsearch', + hosts: ['http://es.co:9201'], + }, + ]); + + expect(mockedOutputService.delete).not.toBeCalled(); + }); + + it('should delete deleted preconfigured output', async () => { + const soClient = savedObjectsClientMock.create(); + mockedOutputService.list.mockResolvedValue({ + items: [ + { id: 'output1', is_preconfigured: true } as Output, + { id: 'output2', is_preconfigured: true } as Output, + ], + page: 1, + perPage: 10000, + total: 1, + }); + await cleanPreconfiguredOutputs(soClient, [ + { + id: 'output1', + is_default: false, + is_default_monitoring: false, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://es.co:9201'], + }, + ]); + + expect(mockedOutputService.delete).toBeCalled(); + expect(mockedOutputService.delete).toBeCalledTimes(1); + expect(mockedOutputService.delete.mock.calls[0][1]).toEqual('output2'); + }); + + it('should update default deleted preconfigured output', async () => { + const soClient = savedObjectsClientMock.create(); + mockedOutputService.list.mockResolvedValue({ + items: [ + { id: 'output1', is_preconfigured: true, is_default: true } as Output, + { id: 'output2', is_preconfigured: true, is_default_monitoring: true } as Output, + ], + page: 1, + perPage: 10000, + total: 1, + }); + await cleanPreconfiguredOutputs(soClient, []); + + expect(mockedOutputService.delete).not.toBeCalled(); + expect(mockedOutputService.update).toBeCalledTimes(2); + expect(mockedOutputService.update).toBeCalledWith( + expect.anything(), + 'output1', + expect.objectContaining({ + is_preconfigured: false, + }), + { fromPreconfiguration: true } + ); + expect(mockedOutputService.update).toBeCalledWith( + expect.anything(), + 'output2', + expect.objectContaining({ + is_preconfigured: false, + }), + { fromPreconfiguration: true } + ); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration/outputs.ts b/x-pack/plugins/fleet/server/services/preconfiguration/outputs.ts new file mode 100644 index 000000000000..83ceb6237b67 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/preconfiguration/outputs.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import { isEqual } from 'lodash'; +import { safeDump } from 'js-yaml'; + +import type { PreconfiguredOutput, Output } from '../../../common'; +import { normalizeHostsForAgents } from '../../../common'; +import { outputService } from '../output'; +import { agentPolicyService } from '../agent_policy'; + +import { appContextService } from '../app_context'; + +export async function ensurePreconfiguredOutputs( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + outputs: PreconfiguredOutput[] +) { + await createOrUpdatePreconfiguredOutputs(soClient, esClient, outputs); + await cleanPreconfiguredOutputs(soClient, outputs); +} + +export async function createOrUpdatePreconfiguredOutputs( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + outputs: PreconfiguredOutput[] +) { + const logger = appContextService.getLogger(); + + if (outputs.length === 0) { + return; + } + + const existingOutputs = await outputService.bulkGet( + soClient, + outputs.map(({ id }) => id), + { ignoreNotFound: true } + ); + + await Promise.all( + outputs.map(async (output) => { + const existingOutput = existingOutputs.find((o) => o.id === output.id); + + const { id, config, ...outputData } = output; + + const configYaml = config ? safeDump(config) : undefined; + + const data = { + ...outputData, + config_yaml: configYaml, + is_preconfigured: true, + }; + + if (!data.hosts || data.hosts.length === 0) { + data.hosts = outputService.getDefaultESHosts(); + } + + const isCreate = !existingOutput; + const isUpdateWithNewData = + existingOutput && isPreconfiguredOutputDifferentFromCurrent(existingOutput, data); + + if (isCreate) { + logger.debug(`Creating output ${output.id}`); + await outputService.create(soClient, data, { id, fromPreconfiguration: true }); + } else if (isUpdateWithNewData) { + logger.debug(`Updating output ${output.id}`); + await outputService.update(soClient, id, data, { fromPreconfiguration: true }); + // Bump revision of all policies using that output + if (outputData.is_default || outputData.is_default_monitoring) { + await agentPolicyService.bumpAllAgentPolicies(soClient, esClient); + } else { + await agentPolicyService.bumpAllAgentPoliciesForOutput(soClient, esClient, id); + } + } + }) + ); +} + +export async function cleanPreconfiguredOutputs( + soClient: SavedObjectsClientContract, + outputs: PreconfiguredOutput[] +) { + const existingOutputs = await outputService.list(soClient); + const existingPreconfiguredOutput = existingOutputs.items.filter( + (o) => o.is_preconfigured === true + ); + + const logger = appContextService.getLogger(); + + for (const output of existingPreconfiguredOutput) { + const hasBeenDelete = !outputs.find(({ id }) => output.id === id); + if (!hasBeenDelete) { + continue; + } + + if (output.is_default) { + logger.info(`Updating default preconfigured output ${output.id} is no longer preconfigured`); + await outputService.update( + soClient, + output.id, + { is_preconfigured: false }, + { + fromPreconfiguration: true, + } + ); + } else if (output.is_default_monitoring) { + logger.info(`Updating default preconfigured output ${output.id} is no longer preconfigured`); + await outputService.update( + soClient, + output.id, + { is_preconfigured: false }, + { + fromPreconfiguration: true, + } + ); + } else { + logger.info(`Deleting preconfigured output ${output.id}`); + await outputService.delete(soClient, output.id, { fromPreconfiguration: true }); + } + } +} + +function isPreconfiguredOutputDifferentFromCurrent( + existingOutput: Output, + preconfiguredOutput: Partial +): boolean { + return ( + !existingOutput.is_preconfigured || + existingOutput.is_default !== preconfiguredOutput.is_default || + existingOutput.is_default_monitoring !== preconfiguredOutput.is_default_monitoring || + existingOutput.name !== preconfiguredOutput.name || + existingOutput.type !== preconfiguredOutput.type || + (preconfiguredOutput.hosts && + !isEqual( + existingOutput.hosts?.map(normalizeHostsForAgents), + preconfiguredOutput.hosts.map(normalizeHostsForAgents) + )) || + (preconfiguredOutput.ssl && !isEqual(preconfiguredOutput.ssl, existingOutput.ssl)) || + existingOutput.ca_sha256 !== preconfiguredOutput.ca_sha256 || + existingOutput.ca_trusted_fingerprint !== preconfiguredOutput.ca_trusted_fingerprint || + existingOutput.config_yaml !== preconfiguredOutput.config_yaml + ); +} diff --git a/x-pack/plugins/fleet/server/services/setup.test.ts b/x-pack/plugins/fleet/server/services/setup.test.ts index 02972648e80d..cd760adf7c8c 100644 --- a/x-pack/plugins/fleet/server/services/setup.test.ts +++ b/x-pack/plugins/fleet/server/services/setup.test.ts @@ -18,6 +18,7 @@ import { upgradeManagedPackagePolicies } from './managed_package_policies'; import { setupFleet } from './setup'; jest.mock('./preconfiguration'); +jest.mock('./preconfiguration/index'); jest.mock('./settings'); jest.mock('./output'); jest.mock('./epm/packages'); diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index e7ba627f5cbd..6e3aed538fb0 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -10,31 +10,33 @@ import { compact } from 'lodash'; import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; import { AUTO_UPDATE_PACKAGES } from '../../common'; -import type { DefaultPackagesInstallationError, PreconfigurationError } from '../../common'; +import type { + DefaultPackagesInstallationError, + PreconfigurationError, + BundledPackage, + Installation, +} from '../../common'; import { SO_SEARCH_LIMIT } from '../constants'; import { DEFAULT_SPACE_ID } from '../../../spaces/common/constants'; import { appContextService } from './app_context'; import { agentPolicyService } from './agent_policy'; -import { - cleanPreconfiguredOutputs, - ensurePreconfiguredOutputs, - ensurePreconfiguredPackagesAndPolicies, -} from './preconfiguration'; +import { ensurePreconfiguredPackagesAndPolicies } from './preconfiguration'; +import { ensurePreconfiguredOutputs } from './preconfiguration/index'; import { outputService } from './output'; import { generateEnrollmentAPIKey, hasEnrollementAPIKeysForPolicy } from './api_keys'; import { settingsService } from '.'; import { awaitIfPending } from './setup_utils'; import { ensureFleetFinalPipelineIsInstalled } from './epm/elasticsearch/ingest_pipeline/install'; -import { ensureDefaultComponentTemplate } from './epm/elasticsearch/template/install'; +import { ensureDefaultComponentTemplates } from './epm/elasticsearch/template/install'; import { getInstallations, installPackage } from './epm/packages'; import { isPackageInstalled } from './epm/packages/install'; import { pkgToPkgKey } from './epm/registry'; import type { UpgradeManagedPackagePoliciesResult } from './managed_package_policies'; import { upgradeManagedPackagePolicies } from './managed_package_policies'; - +import { getBundledPackages } from './epm/packages'; export interface SetupStatus { isInitialized: boolean; nonFatalErrors: Array< @@ -115,9 +117,6 @@ async function createSetupSideEffects( const nonFatalErrors = [...preconfiguredPackagesNonFatalErrors, ...packagePolicyUpgradeErrors]; - logger.debug('Cleaning up Fleet outputs'); - await cleanPreconfiguredOutputs(soClient, outputsOrUndefined ?? []); - logger.debug('Setting up Fleet enrollment keys'); await ensureDefaultEnrollmentAPIKeysExists(soClient, esClient); @@ -145,21 +144,43 @@ export async function ensureFleetGlobalEsAssets( // Ensure Global Fleet ES assets are installed logger.debug('Creating Fleet component template and ingest pipeline'); const globalAssetsRes = await Promise.all([ - ensureDefaultComponentTemplate(esClient, logger), + ensureDefaultComponentTemplates(esClient, logger), // returns an array ensureFleetFinalPipelineIsInstalled(esClient, logger), ]); - - if (globalAssetsRes.some((asset) => asset.isCreated)) { + const assetResults = globalAssetsRes.flat(); + if (assetResults.some((asset) => asset.isCreated)) { // Update existing index template - const packages = await getInstallations(soClient); - + const installedPackages = await getInstallations(soClient); + const bundledPackages = await getBundledPackages(); + const findMatchingBundledPkg = (pkg: Installation) => + bundledPackages.find( + (bundledPkg: BundledPackage) => + bundledPkg.name === pkg.name && bundledPkg.version === pkg.version + ); await Promise.all( - packages.saved_objects.map(async ({ attributes: installation }) => { + installedPackages.saved_objects.map(async ({ attributes: installation }) => { if (installation.install_source !== 'registry') { - logger.error( - `Package needs to be manually reinstalled ${installation.name} after installing Fleet global assets` - ); - return; + const matchingBundledPackage = findMatchingBundledPkg(installation); + if (!matchingBundledPackage) { + logger.error( + `Package needs to be manually reinstalled ${installation.name} after installing Fleet global assets` + ); + return; + } else { + await installPackage({ + installSource: 'upload', + savedObjectsClient: soClient, + esClient, + spaceId: DEFAULT_SPACE_ID, + contentType: 'application/zip', + archiveBuffer: matchingBundledPackage.buffer, + }).catch((err) => { + logger.error( + `Bundled package needs to be manually reinstalled ${installation.name} after installing Fleet global assets: ${err.message}` + ); + }); + return; + } } await installPackage({ installSource: installation.install_source, diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index 91303046485d..9024cd05e2de 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -63,6 +63,8 @@ export type { RegistrySearchResult, IndexTemplateEntry, IndexTemplateMappings, + TemplateMap, + TemplateMapEntry, Settings, SettingsSOAttributes, InstallType, diff --git a/x-pack/plugins/fleet/server/types/models/agent_policy.ts b/x-pack/plugins/fleet/server/types/models/agent_policy.ts index d15d73fca733..38d4b8872274 100644 --- a/x-pack/plugins/fleet/server/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/server/types/models/agent_policy.ts @@ -32,6 +32,8 @@ export const AgentPolicyBaseSchema = { schema.oneOf([schema.literal(dataTypes.Logs), schema.literal(dataTypes.Metrics)]) ) ), + data_output_id: schema.maybe(schema.nullable(schema.string())), + monitoring_output_id: schema.maybe(schema.nullable(schema.string())), }; export const NewAgentPolicySchema = schema.object({ diff --git a/x-pack/plugins/fleet/server/types/models/package_policy.ts b/x-pack/plugins/fleet/server/types/models/package_policy.ts index 904e4e18a854..1d4dc1c1b3b6 100644 --- a/x-pack/plugins/fleet/server/types/models/package_policy.ts +++ b/x-pack/plugins/fleet/server/types/models/package_policy.ts @@ -138,6 +138,7 @@ export const UpdatePackagePolicyRequestBodySchema = schema.object({ ) ), version: schema.maybe(schema.string()), + force: schema.maybe(schema.boolean()), }); export const UpdatePackagePolicySchema = schema.object({ diff --git a/x-pack/plugins/graph/public/components/field_manager/field_editor.tsx b/x-pack/plugins/graph/public/components/field_manager/field_editor.tsx index aa032060db44..f02127fbd655 100644 --- a/x-pack/plugins/graph/public/components/field_manager/field_editor.tsx +++ b/x-pack/plugins/graph/public/components/field_manager/field_editor.tsx @@ -133,7 +133,6 @@ export function FieldEditor({ color={initialField.color} iconSide="right" className={classNames('gphFieldEditor__badge', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'gphFieldEditor__badge--disabled': isDisabled, })} onClickAriaLabel={badgeDescription} diff --git a/x-pack/plugins/graph/public/components/field_manager/field_picker.tsx b/x-pack/plugins/graph/public/components/field_manager/field_picker.tsx index bb451211efd5..bc7718b14223 100644 --- a/x-pack/plugins/graph/public/components/field_manager/field_picker.tsx +++ b/x-pack/plugins/graph/public/components/field_manager/field_picker.tsx @@ -56,7 +56,6 @@ export function FieldPicker({ } className={classNames('gphUrlTemplateList__accordion', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'gphUrlTemplateList__accordion--isOpen': open, })} buttonClassName="gphUrlTemplateList__accordionbutton" diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx index 3a1c2dd36812..89f7d4795429 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx @@ -25,11 +25,13 @@ import { init as initHttp } from '../public/application/services/http'; import { init as initUiMetric } from '../public/application/services/ui_metric'; import { KibanaContextProvider } from '../public/shared_imports'; import { PolicyListContextProvider } from '../public/application/sections/policy_list/policy_list_context'; +import { executionContextServiceMock } from 'src/core/public/execution_context/execution_context_service.mock'; initHttp( new HttpService().setup({ injectedMetadata: injectedMetadataServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), + executionContext: executionContextServiceMock.createSetupContract(), }) ); initUiMetric(usageCollectionPluginMock.createSetupContract()); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts index 11811c7b7d26..b14dd17d3c11 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts @@ -74,6 +74,7 @@ async function fetchTemplates( const response = isLegacy ? await client.indices.getTemplate({}, options) : await client.indices.getIndexTemplate({}, options); + // @ts-expect-error TemplateSerialized.index_patterns not compatible with IndicesIndexTemplate.index_patterns return response; } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.tsx index d0f6575ace85..41f2f8182faf 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.tsx @@ -175,7 +175,6 @@ export const ComponentTemplates = ({ isLoading, components, listItemProps }: Pro
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx index 0b14fc68d9de..71c2f09072da 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx @@ -156,7 +156,6 @@ export const ComponentTemplatesSelector = ({ {/* Selection */}
0, - // eslint-disable-next-line @typescript-eslint/naming-convention 'mappingsEditor__createFieldWrapper--multiField': isMultiField, })} style={{ diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item.tsx index 76284f925c3b..d0957ca709ad 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item.tsx @@ -197,7 +197,6 @@ function FieldListItemComponent( return (
  • treeDepth, })} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result_item.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result_item.tsx index 822c4421fe56..f89e454b5a04 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result_item.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result_item.tsx @@ -91,11 +91,8 @@ export const SearchResultItem = React.memo(function FieldListItemFlatComponent({
    @@ -104,9 +101,7 @@ export const SearchResultItem = React.memo(function FieldListItemFlatComponent({ gutterSize="s" alignItems="center" className={classNames('mappingsEditor__fieldsListItem__content', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'mappingsEditor__fieldsListItem__content--toggle': hasChildFields || hasMultiFields, - // eslint-disable-next-line @typescript-eslint/naming-convention 'mappingsEditor__fieldsListItem__content--multiField': isMultiField, })} > diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/runtimefields_list_item.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/runtimefields_list_item.tsx index 333fbd8eddba..8c503604f5fc 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/runtimefields_list_item.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/runtimefields_list_item.tsx @@ -84,11 +84,8 @@ function RuntimeFieldsListItemComponent(
  • diff --git a/x-pack/plugins/index_management/server/routes/api/component_templates/register_get_route.ts b/x-pack/plugins/index_management/server/routes/api/component_templates/register_get_route.ts index 039eb24f4d9d..9e54e80c7148 100644 --- a/x-pack/plugins/index_management/server/routes/api/component_templates/register_get_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/component_templates/register_get_route.ts @@ -36,6 +36,7 @@ export function registerGetAllRoute({ router, lib: { handleEsError } }: RouteDep const body = componentTemplates.map((componentTemplate: ComponentTemplateFromEs) => { const deserializedComponentTemplateListItem = deserializeComponentTemplateList( componentTemplate, + // @ts-expect-error TemplateSerialized.index_patterns not compatible with IndicesIndexTemplate.index_patterns indexTemplates ); return deserializedComponentTemplateListItem; @@ -70,6 +71,7 @@ export function registerGetAllRoute({ router, lib: { handleEsError } }: RouteDep await client.asCurrentUser.indices.getIndexTemplate(); return response.ok({ + // @ts-expect-error TemplateSerialized.index_patterns not compatible with IndicesIndexTemplate.index_patterns body: deserializeComponentTemplate(componentTemplates[0], indexTemplates), }); } catch (error) { diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts index 8eedcee590fd..93d65e162da7 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts @@ -34,6 +34,7 @@ export function registerGetAllRoute({ router, lib: { handleEsError } }: RouteDep legacyTemplatesEs, cloudManagedTemplatePrefix ); + // @ts-expect-error TemplateSerialized.index_patterns not compatible with IndicesIndexTemplate.index_patterns const templates = deserializeTemplateList(templatesEs, cloudManagedTemplatePrefix); const body = { @@ -92,6 +93,7 @@ export function registerGetOneRoute({ router, lib: { handleEsError } }: RouteDep if (indexTemplates.length > 0) { return response.ok({ body: deserializeTemplate( + // @ts-expect-error TemplateSerialized.index_patterns not compatible with IndicesIndexTemplate.index_patterns { ...indexTemplates[0].index_template, name }, cloudManagedTemplatePrefix ), diff --git a/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx index 61d9c5260881..07249d15ecce 100644 --- a/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx @@ -17,14 +17,17 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { PrefilledInventoryAlertFlyout } from '../../inventory/components/alert_flyout'; import { PrefilledThresholdAlertFlyout } from '../../metric_threshold/components/alert_flyout'; -import { useRulesLink } from '../../../../../observability/public'; +import { InfraClientStartDeps } from '../../../types'; + type VisibleFlyoutType = 'inventory' | 'threshold' | null; export const MetricsAlertDropdown = () => { const [popoverOpen, setPopoverOpen] = useState(false); const [visibleFlyoutType, setVisibleFlyoutType] = useState(null); const uiCapabilities = useKibana().services.application?.capabilities; - + const { + services: { observability }, + } = useKibana(); const canCreateAlerts = useMemo( () => Boolean(uiCapabilities?.infrastructure?.save), [uiCapabilities] @@ -83,7 +86,7 @@ export const MetricsAlertDropdown = () => { [setVisibleFlyoutType, closePopover] ); - const manageRulesLinkProps = useRulesLink(); + const manageRulesLinkProps = observability.useRulesLink(); const manageAlertsMenuItem = useMemo( () => ({ diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx index 4bd0438af9b7..c35335afe493 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx @@ -8,10 +8,14 @@ import { EuiContextMenuItem } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useRulesLink } from '../../../../../observability/public'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { InfraClientStartDeps } from '../../../types'; export const ManageAlertsContextMenuItem = () => { - const manageRulesLinkProps = useRulesLink(); + const { + services: { observability }, + } = useKibana(); + const manageRulesLinkProps = observability.useRulesLink(); return ( diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx index 9fbc88ed5352..a91e2d6ad92f 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx @@ -10,7 +10,6 @@ import { i18n } from '@kbn/i18n'; import { EuiPopover, EuiContextMenuItem, EuiContextMenuPanel, EuiHeaderLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { AlertFlyout } from './alert_flyout'; -import { useRulesLink } from '../../../../../observability/public'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; const readOnlyUserTooltipContent = i18n.translate( @@ -31,13 +30,14 @@ export const AlertDropdown = () => { const { services: { application: { capabilities }, + observability, }, } = useKibanaContextForPlugin(); const canCreateAlerts = capabilities?.logs?.save ?? false; const [popoverOpen, setPopoverOpen] = useState(false); const [flyoutVisible, setFlyoutVisible] = useState(false); - const manageRulesLinkProps = useRulesLink({ + const manageRulesLinkProps = observability.useRulesLink({ hrefOnly: true, }); diff --git a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx index b44f3ffa20df..7b7c256d5ad5 100644 --- a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx @@ -150,6 +150,7 @@ export function SavedViewsToolbarControls(props: Props) { data-test-subj="savedViews-openPopover" iconType="arrowDown" iconSide="right" + color="text" > {currentView ? currentView.name diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx index 3681d740d93d..ad548a632573 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; import { useUiTracker } from '../../../../../../observability/public'; import { useWaffleOptionsContext } from '../hooks/use_waffle_options'; @@ -57,17 +57,6 @@ export const BottomDrawer: React.FC<{ {isOpen ? hideHistory : showHistory} - - {children} - - @@ -97,7 +86,3 @@ const BottomActionTopBar = euiStyled(EuiFlexGroup).attrs({ const ShowHideButton = euiStyled(EuiButtonEmpty).attrs({ size: 's' })` width: 140px; `; - -const RightSideSpacer = euiStyled(EuiSpacer).attrs({ size: 'xs' })` - width: 140px; -`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx index 5a3dafaabbd1..7f3de57b610a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx @@ -17,8 +17,12 @@ import { calculateBoundsFromNodes } from '../lib/calculate_bounds_from_nodes'; import { PageContent } from '../../../../components/page'; import { useWaffleTimeContext } from '../hooks/use_waffle_time'; import { useWaffleFiltersContext } from '../hooks/use_waffle_filters'; -import { DEFAULT_LEGEND, useWaffleOptionsContext } from '../hooks/use_waffle_options'; -import { InfraFormatterType } from '../../../../lib/lib'; +import { + DEFAULT_LEGEND, + useWaffleOptionsContext, + WaffleLegendOptions, +} from '../hooks/use_waffle_options'; +import { InfraFormatterType, InfraWaffleMapBounds } from '../../../../lib/lib'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; import { Toolbar } from './toolbars/toolbar'; import { ViewSwitcher } from './waffle/view_switcher'; @@ -26,7 +30,7 @@ import { createInventoryMetricFormatter } from '../lib/create_inventory_metric_f import { createLegend } from '../lib/create_legend'; import { useWaffleViewState } from '../hooks/use_waffle_view_state'; import { BottomDrawer } from './bottom_drawer'; -import { Legend } from './waffle/legend'; +import { LegendControls } from './waffle/legend_controls'; interface Props { shouldLoadDefault: boolean; @@ -37,149 +41,184 @@ interface Props { loading: boolean; } -export const Layout = ({ - shouldLoadDefault, - currentView, - reload, - interval, - nodes, - loading, -}: Props) => { - const [showLoading, setShowLoading] = useState(true); - const { metric, groupBy, sort, nodeType, changeView, view, autoBounds, boundsOverride, legend } = - useWaffleOptionsContext(); - const { currentTime, jumpToTime, isAutoReloading } = useWaffleTimeContext(); - const { applyFilterQuery } = useWaffleFiltersContext(); - const legendPalette = legend?.palette ?? DEFAULT_LEGEND.palette; - const legendSteps = legend?.steps ?? DEFAULT_LEGEND.steps; - const legendReverseColors = legend?.reverseColors ?? DEFAULT_LEGEND.reverseColors; - - const options = { - formatter: InfraFormatterType.percent, - formatTemplate: '{{value}}', - legend: createLegend(legendPalette, legendSteps, legendReverseColors), - metric, - sort, - groupBy, - }; - - useInterval( - () => { - if (!loading) { - jumpToTime(Date.now()); - } - }, - isAutoReloading ? 5000 : null - ); - - const dataBounds = calculateBoundsFromNodes(nodes); - const bounds = autoBounds ? dataBounds : boundsOverride; - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const formatter = useCallback(createInventoryMetricFormatter(options.metric), [options.metric]); - const { onViewChange } = useWaffleViewState(); - - useEffect(() => { - if (currentView) { - onViewChange(currentView); - } - }, [currentView, onViewChange]); - - useEffect(() => { - // load snapshot data after default view loaded, unless we're not loading a view - if (currentView != null || !shouldLoadDefault) { - reload(); - } - - /** - * INFO: why disable exhaustive-deps - * We need to wait on the currentView not to be null because it is loaded async and could change the view state. - * We don't actually need to watch the value of currentView though, since the view state will be synched up by the - * changing params in the reload method so we should only "watch" the reload method. - * - * TODO: Should refactor this in the future to make it more clear where all the view state is coming - * from and it's precedence [query params, localStorage, defaultView, out of the box view] - */ +interface LegendControlOptions { + auto: boolean; + bounds: InfraWaffleMapBounds; + legend: WaffleLegendOptions; +} + +export const Layout = React.memo( + ({ shouldLoadDefault, currentView, reload, interval, nodes, loading }: Props) => { + const [showLoading, setShowLoading] = useState(true); + const { + metric, + groupBy, + sort, + nodeType, + changeView, + view, + autoBounds, + boundsOverride, + legend, + changeBoundsOverride, + changeAutoBounds, + changeLegend, + } = useWaffleOptionsContext(); + const { currentTime, jumpToTime, isAutoReloading } = useWaffleTimeContext(); + const { applyFilterQuery } = useWaffleFiltersContext(); + const legendPalette = legend?.palette ?? DEFAULT_LEGEND.palette; + const legendSteps = legend?.steps ?? DEFAULT_LEGEND.steps; + const legendReverseColors = legend?.reverseColors ?? DEFAULT_LEGEND.reverseColors; + + const options = { + formatter: InfraFormatterType.percent, + formatTemplate: '{{value}}', + legend: createLegend(legendPalette, legendSteps, legendReverseColors), + metric, + sort, + groupBy, + }; + + useInterval( + () => { + if (!loading) { + jumpToTime(Date.now()); + } + }, + isAutoReloading ? 5000 : null + ); + + const dataBounds = calculateBoundsFromNodes(nodes); + const bounds = autoBounds ? dataBounds : boundsOverride; /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, [reload, shouldLoadDefault]); - - useEffect(() => { - setShowLoading(true); - }, [options.metric, nodeType]); - - useEffect(() => { - const hasNodes = nodes && nodes.length; - // Don't show loading screen when we're auto-reloading - setShowLoading(!hasNodes); - }, [nodes]); - - return ( - <> - - - {({ measureRef: pageMeasureRef, bounds: { width = 0 } }) => ( - - - {({ measureRef: topActionMeasureRef, bounds: { height: topActionHeight = 0 } }) => ( - <> - - - - - - - - - - {({ measureRef, bounds: { height = 0 } }) => ( - <> - - {view === 'map' && ( - { + if (currentView) { + onViewChange(currentView); + } + }, [currentView, onViewChange]); + + useEffect(() => { + // load snapshot data after default view loaded, unless we're not loading a view + if (currentView != null || !shouldLoadDefault) { + reload(); + } + + /** + * INFO: why disable exhaustive-deps + * We need to wait on the currentView not to be null because it is loaded async and could change the view state. + * We don't actually need to watch the value of currentView though, since the view state will be synched up by the + * changing params in the reload method so we should only "watch" the reload method. + * + * TODO: Should refactor this in the future to make it more clear where all the view state is coming + * from and it's precedence [query params, localStorage, defaultView, out of the box view] + */ + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [reload, shouldLoadDefault]); + + useEffect(() => { + setShowLoading(true); + }, [options.metric, nodeType]); + + useEffect(() => { + const hasNodes = nodes && nodes.length; + // Don't show loading screen when we're auto-reloading + setShowLoading(!hasNodes); + }, [nodes]); + + const handleLegendControlChange = useCallback( + (opts: LegendControlOptions) => { + changeBoundsOverride(opts.bounds); + changeAutoBounds(opts.auto); + changeLegend(opts.legend); + }, + [changeBoundsOverride, changeAutoBounds, changeLegend] + ); + + return ( + <> + + + {({ measureRef: pageMeasureRef, bounds: { width = 0 } }) => ( + + + {({ + measureRef: topActionMeasureRef, + bounds: { height: topActionHeight = 0 }, + }) => ( + <> + + + + + {view === 'map' && ( + + + + )} + + + + + + + + {({ measureRef, bounds: { height = 0 } }) => ( + <> + - + {view === 'map' && ( + - - )} - - )} - - - )} - - - )} - - - - ); -}; + )} + + )} + + + )} + + + )} + + + + ); + } +); const MainContainer = euiStyled.div` position: relative; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx index 297f24e95bc4..cec595e4be3d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx @@ -18,6 +18,7 @@ import { Map } from './waffle/map'; import { TableView } from './table_view'; import { SnapshotNode } from '../../../../../common/http_api/snapshot_api'; import { calculateBoundsFromNodes } from '../lib/calculate_bounds_from_nodes'; +import { Legend } from './waffle/legend'; export interface KueryFilterQuery { kind: 'kuery'; @@ -131,6 +132,12 @@ export const NodesOverview = ({ bottomMargin={bottomMargin} staticHeight={isStatic} /> + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend.tsx index d305203b738c..853aa98bf624 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React from 'react'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import { @@ -17,13 +17,7 @@ import { GradientLegendRT, } from '../../../../../lib/lib'; import { GradientLegend } from './gradient_legend'; -import { LegendControls } from './legend_controls'; import { StepLegend } from './steps_legend'; -import { - DEFAULT_LEGEND, - useWaffleOptionsContext, - WaffleLegendOptions, -} from '../../hooks/use_waffle_options'; import { SteppedGradientLegend } from './stepped_gradient_legend'; interface Props { legend: InfraWaffleMapLegend; @@ -32,39 +26,9 @@ interface Props { formatter: InfraFormatter; } -interface LegendControlOptions { - auto: boolean; - bounds: InfraWaffleMapBounds; - legend: WaffleLegendOptions; -} - -export const Legend: React.FC = ({ dataBounds, legend, bounds, formatter }) => { - const { - changeBoundsOverride, - changeAutoBounds, - autoBounds, - legend: legendOptions, - changeLegend, - boundsOverride, - } = useWaffleOptionsContext(); - const handleChange = useCallback( - (options: LegendControlOptions) => { - changeBoundsOverride(options.bounds); - changeAutoBounds(options.auto); - changeLegend(options.legend); - }, - [changeBoundsOverride, changeAutoBounds, changeLegend] - ); +export const Legend: React.FC = ({ legend, bounds, formatter }) => { return ( - {GradientLegendRT.is(legend) && ( )} @@ -77,8 +41,6 @@ export const Legend: React.FC = ({ dataBounds, legend, bounds, formatter }; const LegendContainer = euiStyled.div` - position: absolute; - bottom: 0px; - left: 10px; - right: 10px; + margin: 0 10px; + display: flex; `; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx index c7479434424a..61b293888b85 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx @@ -26,7 +26,6 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { SyntheticEvent, useState, useCallback, useEffect } from 'react'; import { first, last } from 'lodash'; -import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import { InfraWaffleMapBounds, InventoryColorPalette, PALETTES } from '../../../../../lib/lib'; import { WaffleLegendOptions } from '../../hooks/use_waffle_options'; import { getColorPalette } from '../../lib/get_color_palette'; @@ -78,8 +77,10 @@ export const LegendControls = ({ const buttonComponent = ( - - Legend Options - - - <> - - - - - - - + Legend Options + + + <> + - - + + + + + - + + + + + + + + + } + isInvalid={!boundsValidRange} + display="columnCompressed" + error={errors} + > +
    + - - - + + + } + isInvalid={!boundsValidRange} + error={errors} + > +
    + - - + + + + + + - } - isInvalid={!boundsValidRange} - display="columnCompressed" - error={errors} - > -
    - + + + + -
    - - - } - isInvalid={!boundsValidRange} - error={errors} - > -
    - -
    -
    - - - - - - - - - - - - - - - - + +
    +
    + + ); }; - -const ControlContainer = euiStyled.div` - position: absolute; - top: -20px; - right: 6px; - bottom: 0; -`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx index a9bcfa7995c2..339426b126b9 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { EuiText } from '@elastic/eui'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import { InfraWaffleMapBounds, @@ -22,18 +23,19 @@ type TickValue = 0 | 1; export const SteppedGradientLegend: React.FC = ({ legend, bounds, formatter }) => { return ( - - - - + - {legend.rules.map((rule, index) => ( - - ))} + {legend.rules + .slice() + .reverse() + .map((rule, index) => ( + + ))} + ); }; @@ -46,62 +48,38 @@ interface TickProps { const TickLabel = ({ value, bounds, formatter }: TickProps) => { const normalizedValue = value === 0 ? bounds.min : bounds.max * value; - const style = { left: `${value * 100}%` }; const label = formatter(normalizedValue); - return {label}; + return ( +
    + {label} +
    + ); }; -const GradientStep = euiStyled.div` - height: ${(props) => props.theme.eui.paddingSizes.s}; - flex: 1 1 auto; - &:first-child { - border-radius: ${(props) => props.theme.eui.euiBorderRadius} 0 0 ${(props) => - props.theme.eui.euiBorderRadius}; - } - &:last-child { - border-radius: 0 ${(props) => props.theme.eui.euiBorderRadius} ${(props) => - props.theme.eui.euiBorderRadius} 0; - } +const LegendContainer = euiStyled.div` + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; `; -const Ticks = euiStyled.div` - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - top: -18px; +const GradientContainer = euiStyled.div` + height: 200px; + width: 10px; + display: flex; + flex-direction: column; + align-items: stretch; `; -const Tick = euiStyled.div` - position: absolute; - font-size: 11px; - text-align: center; - top: 0; - left: 0; - white-space: nowrap; - transform: translate(-50%, 0); +const GradientStep = euiStyled.div` + flex: 1 1 auto; &:first-child { - padding-left: 5px; - transform: translate(0, 0); + border-radius: ${(props) => props.theme.eui.euiBorderRadius} ${(props) => + props.theme.eui.euiBorderRadius} 0 0; } &:last-child { - padding-right: 5px; - transform: translate(-100%, 0); + border-radius: 0 0 ${(props) => props.theme.eui.euiBorderRadius} ${(props) => + props.theme.eui.euiBorderRadius}; } `; - -const GradientContainer = euiStyled.div` - display: flex; - flex-direction; row; - align-items: stretch; - flex-grow: 1; -`; - -const LegendContainer = euiStyled.div` - position: absolute; - height: 10px; - bottom: 0; - left: 0; - right: 40px; -`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/view_switcher.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/view_switcher.tsx index 4dc288caa983..8e911f7f8291 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/view_switcher.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/view_switcher.tsx @@ -37,8 +37,8 @@ export const ViewSwitcher = ({ view, onChange }: Props) => { defaultMessage: 'Switch between table and map view', })} options={buttons} - color="primary" - buttonSize="m" + color="text" + buttonSize="s" idSelected={view} onChange={onChange} isIconOnly diff --git a/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts index 2a87c9cbca99..3e1be0196231 100644 --- a/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts @@ -83,7 +83,9 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { series: panel.series.map((series) => { return { id: series.id, - label: series.label, + // In case of grouping by multiple fields, "series.label" is array. + // If infra will perform this type of grouping, the following code needs to be updated + label: [series.label].flat()[0], data: series.data.map((point) => ({ timestamp: point[0] as number, value: point[1] as number | null, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/context_menu.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/context_menu.tsx index 873054f45533..802658bde167 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/context_menu.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/context_menu.tsx @@ -27,7 +27,6 @@ export const ContextMenu: FunctionComponent = (props) => { const [isOpen, setIsOpen] = useState(false); const containerClasses = classNames({ - // eslint-disable-next-line @typescript-eslint/naming-convention 'pipelineProcessorsEditor__item--displayNone': hidden, }); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/inline_text_input.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/inline_text_input.tsx index f23442426d71..36222eebcea4 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/inline_text_input.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/inline_text_input.tsx @@ -36,7 +36,6 @@ function _InlineTextInput({ const [textValue, setTextValue] = useState(() => text ?? ''); const containerClasses = classNames('pipelineProcessorsEditor__item__textContainer', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'pipelineProcessorsEditor__item__textContainer--notEditing': !isShowingTextInput && !disabled, }); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx index 49dc6d3a4e7f..765e1ffcf88e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx @@ -81,9 +81,7 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( const processorStatus = processorOutput?.status ?? 'inactive'; const panelClasses = classNames('pipelineProcessorsEditor__item', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'pipelineProcessorsEditor__item--selected': isMovingThisProcessor || isEditingThisProcessor, - // eslint-disable-next-line @typescript-eslint/naming-convention 'pipelineProcessorsEditor__item--dimmed': isDimmed, }); @@ -94,7 +92,6 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( const inlineTextInputContainerClasses = classNames( 'pipelineProcessorsEditor__item__descriptionContainer', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'pipelineProcessorsEditor__item__descriptionContainer--displayNone': isInMoveMode && hasNoDescription, } @@ -132,7 +129,6 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( : i18nTexts.cancelMoveButtonLabel; const dataTestSubj = !isMovingThisProcessor ? 'moveItemButton' : 'cancelMoveItemButton'; const moveButtonClasses = classNames('pipelineProcessorsEditor__item__moveButton', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'pipelineProcessorsEditor__item__moveButton--cancel': isMovingThisProcessor, }); const icon = isMovingThisProcessor ? 'cross' : 'sortable'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/components/drop_zone_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/components/drop_zone_button.tsx index 94f9a54cf0bd..f5e3881fb40f 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/components/drop_zone_button.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/components/drop_zone_button.tsx @@ -37,15 +37,11 @@ export const DropZoneButton: FunctionComponent = (props) => { const { onClick, isDisabled, isVisible, compressed } = props; const isUnavailable = isVisible && isDisabled; const containerClasses = classNames({ - // eslint-disable-next-line @typescript-eslint/naming-convention 'pipelineProcessorsEditor__tree__dropZoneContainer--visible': isVisible, - // eslint-disable-next-line @typescript-eslint/naming-convention 'pipelineProcessorsEditor__tree__dropZoneContainer--unavailable': isUnavailable, }); const buttonClasses = classNames({ - // eslint-disable-next-line @typescript-eslint/naming-convention 'pipelineProcessorsEditor__tree__dropZoneButton--visible': isVisible, - // eslint-disable-next-line @typescript-eslint/naming-convention 'pipelineProcessorsEditor__tree__dropZoneButton--compressed': compressed, }); diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index bd507be52e2a..1504e33ecaca 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -7,7 +7,6 @@ import rison from 'rison-node'; import type { TimeRange } from '../../../../src/plugins/data/common/query'; -import { LayerType } from './types'; export const PLUGIN_ID = 'lens'; export const APP_ID = 'lens'; @@ -43,10 +42,10 @@ export const LegendDisplay = { HIDE: 'hide', } as const; -export const layerTypes: Record = { +export const layerTypes = { DATA: 'data', REFERENCELINE: 'referenceLine', -}; +} as const; // might collide with user-supplied field names, try to make as unique as possible export const DOCUMENT_FIELD_NAME = '___records___'; diff --git a/x-pack/plugins/lens/common/expressions/datatable/datatable.ts b/x-pack/plugins/lens/common/expressions/datatable/datatable.ts index 1eab39905548..af98485a1bca 100644 --- a/x-pack/plugins/lens/common/expressions/datatable/datatable.ts +++ b/x-pack/plugins/lens/common/expressions/datatable/datatable.ts @@ -28,6 +28,7 @@ export interface DatatableArgs { sortingColumnId: SortingState['columnId']; sortingDirection: SortingState['direction']; fitRowToContent?: boolean; + rowHeightLines?: number; pageSize?: PagingState['size']; } @@ -68,6 +69,10 @@ export const getDatatable = ( types: ['boolean'], help: '', }, + rowHeightLines: { + types: ['number'], + help: '', + }, pageSize: { types: ['number'], help: '', diff --git a/x-pack/plugins/lens/common/expressions/index.ts b/x-pack/plugins/lens/common/expressions/index.ts index d7c27c4436b4..526bee92ec7e 100644 --- a/x-pack/plugins/lens/common/expressions/index.ts +++ b/x-pack/plugins/lens/common/expressions/index.ts @@ -11,7 +11,6 @@ export * from './rename_columns'; export * from './merge_tables'; export * from './time_scale'; export * from './datatable'; -export * from './metric_chart'; export * from './xy_chart'; export * from './expression_types'; diff --git a/x-pack/plugins/lens/common/expressions/metric_chart/metric_chart.ts b/x-pack/plugins/lens/common/expressions/metric_chart/metric_chart.ts deleted file mode 100644 index f5808cee9d00..000000000000 --- a/x-pack/plugins/lens/common/expressions/metric_chart/metric_chart.ts +++ /dev/null @@ -1,113 +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 { i18n } from '@kbn/i18n'; -import { ColorMode } from '../../../../../../src/plugins/charts/common'; -import type { ExpressionFunctionDefinition } from '../../../../../../src/plugins/expressions/common'; -import type { LensMultiTable } from '../../types'; -import type { MetricConfig } from './types'; - -interface MetricRender { - type: 'render'; - as: 'lens_metric_chart_renderer'; - value: MetricChartProps; -} - -export interface MetricChartProps { - data: LensMultiTable; - args: MetricConfig; -} - -export const metricChart: ExpressionFunctionDefinition< - 'lens_metric_chart', - LensMultiTable, - Omit, - MetricRender -> = { - name: 'lens_metric_chart', - type: 'render', - help: 'A metric chart', - args: { - title: { - types: ['string'], - help: i18n.translate('xpack.lens.metric.title.help', { - defaultMessage: 'The visualization title.', - }), - }, - size: { - types: ['string'], - help: i18n.translate('xpack.lens.metric.size.help', { - defaultMessage: 'The visualization text size.', - }), - default: 'xl', - }, - titlePosition: { - types: ['string'], - help: i18n.translate('xpack.lens.metric.titlePosition.help', { - defaultMessage: 'The visualization title position.', - }), - default: 'bottom', - }, - textAlign: { - types: ['string'], - help: i18n.translate('xpack.lens.metric.textAlignPosition.help', { - defaultMessage: 'The visualization text alignment position.', - }), - default: 'center', - }, - description: { - types: ['string'], - help: '', - }, - metricTitle: { - types: ['string'], - help: i18n.translate('xpack.lens.metric.metricTitle.help', { - defaultMessage: 'The title of the metric shown.', - }), - }, - accessor: { - types: ['string'], - help: i18n.translate('xpack.lens.metric.accessor.help', { - defaultMessage: 'The column whose value is being displayed', - }), - }, - mode: { - types: ['string'], - options: ['reduced', 'full'], - default: 'full', - help: i18n.translate('xpack.lens.metric.mode.help', { - defaultMessage: - 'The display mode of the chart - reduced will only show the metric itself without min size', - }), - }, - colorMode: { - types: ['string'], - default: `"${ColorMode.None}"`, - options: [ColorMode.None, ColorMode.Labels, ColorMode.Background], - help: i18n.translate('xpack.lens.metric.colorMode.help', { - defaultMessage: 'Which part of metric to color', - }), - }, - palette: { - types: ['palette'], - help: i18n.translate('xpack.lens.metric.palette.help', { - defaultMessage: 'Provides colors for the values', - }), - }, - }, - inputTypes: ['lens_multitable'], - fn(data, args) { - return { - type: 'render', - as: 'lens_metric_chart_renderer', - value: { - data, - args, - }, - } as MetricRender; - }, -}; diff --git a/x-pack/plugins/lens/common/expressions/metric_chart/types.ts b/x-pack/plugins/lens/common/expressions/metric_chart/types.ts deleted file mode 100644 index 00f7d7454061..000000000000 --- a/x-pack/plugins/lens/common/expressions/metric_chart/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - ColorMode, - CustomPaletteState, - PaletteOutput, -} from '../../../../../../src/plugins/charts/common'; -import { CustomPaletteParams, LayerType } from '../../types'; - -export interface MetricState { - layerId: string; - accessor?: string; - layerType: LayerType; - colorMode?: ColorMode; - palette?: PaletteOutput; - titlePosition?: 'top' | 'bottom'; - size?: string; - textAlign?: 'left' | 'right' | 'center'; -} - -export interface MetricConfig extends Omit { - title: string; - description: string; - metricTitle: string; - mode: 'reduced' | 'full'; - colorMode: ColorMode; - palette: PaletteOutput; -} diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/data_layer_config.ts similarity index 72% rename from x-pack/plugins/lens/common/expressions/xy_chart/layer_config.ts rename to x-pack/plugins/lens/common/expressions/xy_chart/layer_config/data_layer_config.ts index ff3d50a13a06..322edccba19e 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/data_layer_config.ts @@ -5,30 +5,28 @@ * 2.0. */ -import type { PaletteOutput } from '../../../../../../src/plugins/charts/common'; -import type { ExpressionFunctionDefinition } from '../../../../../../src/plugins/expressions/common'; -import type { LayerType } from '../../types'; -import { layerTypes } from '../../constants'; -import { axisConfig, YConfig } from './axis_config'; -import type { SeriesType } from './series_type'; +import { layerTypes } from '../../../constants'; +import type { PaletteOutput } from '../../../../../../../src/plugins/charts/common'; +import type { ExpressionFunctionDefinition } from '../../../../../../../src/plugins/expressions/common'; +import { axisConfig, YConfig } from '../axis_config'; +import type { SeriesType } from '../series_type'; -export interface XYLayerConfig { - hide?: boolean; +export interface XYDataLayerConfig { layerId: string; - xAccessor?: string; + layerType: typeof layerTypes.DATA; accessors: string[]; - yConfig?: YConfig[]; seriesType: SeriesType; + xAccessor?: string; + hide?: boolean; + yConfig?: YConfig[]; splitAccessor?: string; palette?: PaletteOutput; - layerType: LayerType; } - -export interface ValidLayer extends XYLayerConfig { - xAccessor: NonNullable; +export interface ValidLayer extends XYDataLayerConfig { + xAccessor: NonNullable; } -export type LayerArgs = XYLayerConfig & { +export type DataLayerArgs = XYDataLayerConfig & { columnToLabel?: string; // Actually a JSON key-value pair yScaleType: 'time' | 'linear' | 'log' | 'sqrt'; xScaleType: 'time' | 'linear' | 'ordinal'; @@ -37,17 +35,17 @@ export type LayerArgs = XYLayerConfig & { palette: PaletteOutput; }; -export type LayerConfigResult = LayerArgs & { type: 'lens_xy_layer' }; +export type DataLayerConfigResult = DataLayerArgs & { type: 'lens_xy_data_layer' }; -export const layerConfig: ExpressionFunctionDefinition< - 'lens_xy_layer', +export const dataLayerConfig: ExpressionFunctionDefinition< + 'lens_xy_data_layer', null, - LayerArgs, - LayerConfigResult + DataLayerArgs, + DataLayerConfigResult > = { - name: 'lens_xy_layer', + name: 'lens_xy_data_layer', aliases: [], - type: 'lens_xy_layer', + type: 'lens_xy_data_layer', help: `Configure a layer in the xy chart`, inputTypes: ['null'], args: { @@ -60,7 +58,7 @@ export const layerConfig: ExpressionFunctionDefinition< types: ['string'], help: '', }, - layerType: { types: ['string'], options: Object.values(layerTypes), help: '' }, + layerType: { types: ['string'], options: [layerTypes.DATA], help: '' }, seriesType: { types: ['string'], options: [ @@ -115,9 +113,9 @@ export const layerConfig: ExpressionFunctionDefinition< types: ['palette'], }, }, - fn: function fn(input: unknown, args: LayerArgs) { + fn: function fn(input: unknown, args: DataLayerArgs) { return { - type: 'lens_xy_layer', + type: 'lens_xy_data_layer', ...args, }; }, diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts new file mode 100644 index 000000000000..0b27ce7d6ed8 --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/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. + */ +import { XYDataLayerConfig } from './data_layer_config'; +import { XYReferenceLineLayerConfig } from './reference_line_layer_config'; +export * from './data_layer_config'; +export * from './reference_line_layer_config'; + +export type XYLayerConfig = XYDataLayerConfig | XYReferenceLineLayerConfig; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/reference_line_layer_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/reference_line_layer_config.ts new file mode 100644 index 000000000000..6e241f8b8db6 --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/reference_line_layer_config.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ExpressionFunctionDefinition } from '../../../../../../../src/plugins/expressions/common'; +import { layerTypes } from '../../../constants'; +import { YConfig } from '../axis_config'; + +export interface XYReferenceLineLayerConfig { + layerId: string; + layerType: typeof layerTypes.REFERENCELINE; + accessors: string[]; + yConfig?: YConfig[]; +} +export type ReferenceLineLayerArgs = XYReferenceLineLayerConfig & { + columnToLabel?: string; +}; +export type ReferenceLineLayerConfigResult = ReferenceLineLayerArgs & { + type: 'lens_xy_referenceLine_layer'; +}; + +export const referenceLineLayerConfig: ExpressionFunctionDefinition< + 'lens_xy_referenceLine_layer', + null, + ReferenceLineLayerArgs, + ReferenceLineLayerConfigResult +> = { + name: 'lens_xy_referenceLine_layer', + aliases: [], + type: 'lens_xy_referenceLine_layer', + help: `Configure a layer in the xy chart`, + inputTypes: ['null'], + args: { + layerId: { + types: ['string'], + help: '', + }, + layerType: { types: ['string'], options: [layerTypes.REFERENCELINE], help: '' }, + accessors: { + types: ['string'], + help: 'The columns to display on the y axis.', + multi: true, + }, + yConfig: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + types: ['lens_xy_yConfig' as any], + help: 'Additional configuration for y axes', + multi: true, + }, + columnToLabel: { + types: ['string'], + help: 'JSON key-value pairs of column ID to label', + }, + }, + fn: function fn(input: unknown, args: ReferenceLineLayerArgs) { + return { + type: 'lens_xy_referenceLine_layer', + ...args, + }; + }, +}; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/legend_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/legend_config.ts index fdf8d06b5942..bced4e284aa3 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/legend_config.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/legend_config.ts @@ -45,6 +45,11 @@ export interface LegendConfig { * Flag whether the legend items are truncated or not */ shouldTruncate?: boolean; + /** + * 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; } export type LegendConfigResult = LegendConfig & { type: 'lens_xy_legendConfig' }; @@ -121,6 +126,12 @@ export const legendConfig: ExpressionFunctionDefinition< defaultMessage: 'Specifies whether the legend items will be truncated or not', }), }, + legendSize: { + types: ['number'], + help: i18n.translate('xpack.lens.xyChart.legendSize.help', { + defaultMessage: 'Specifies the legend size in pixels.', + }), + }, }, fn: function fn(input: unknown, args: LegendConfig) { return { diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts index 4e712f7ca3bf..1334c1149f47 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts @@ -8,7 +8,7 @@ import type { AxisExtentConfigResult, AxisTitlesVisibilityConfigResult } from './axis_config'; import type { FittingFunction } from './fitting_function'; import type { GridlinesConfigResult } from './grid_lines_config'; -import type { LayerArgs } from './layer_config'; +import type { DataLayerArgs } from './layer_config'; import type { LegendConfigResult } from './legend_config'; import type { TickLabelsConfigResult } from './tick_labels_config'; import type { LabelsOrientationConfigResult } from './labels_orientation_config'; @@ -27,7 +27,7 @@ export interface XYArgs { yRightExtent: AxisExtentConfigResult; legend: LegendConfigResult; valueLabels: ValueLabelConfig; - layers: LayerArgs[]; + layers: DataLayerArgs[]; fittingFunction?: FittingFunction; axisTitlesVisibilitySettings?: AxisTitlesVisibilityConfigResult; tickLabelsVisibilitySettings?: TickLabelsConfigResult; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts index 00baf894de04..d3fb2fe7a691 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts @@ -117,7 +117,7 @@ export const xyChart: ExpressionFunctionDefinition< }, layers: { // eslint-disable-next-line @typescript-eslint/no-explicit-any - types: ['lens_xy_layer'] as any, + types: ['lens_xy_data_layer', 'lens_xy_referenceLine_layer'] as any, help: 'Layers of visual series', multi: true, }, diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index 0b2b5d5d739d..422ada21f0a3 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -13,9 +13,18 @@ import type { SerializedFieldFormat, } from '../../../../src/plugins/field_formats/common'; import type { Datatable } from '../../../../src/plugins/expressions/common'; -import type { PaletteContinuity } from '../../../../src/plugins/charts/common'; -import type { PaletteOutput } from '../../../../src/plugins/charts/common'; -import { CategoryDisplay, LegendDisplay, NumberDisplay, PieChartTypes } from './constants'; +import type { + PaletteContinuity, + PaletteOutput, + ColorMode, +} from '../../../../src/plugins/charts/common'; +import { + CategoryDisplay, + layerTypes, + LegendDisplay, + NumberDisplay, + PieChartTypes, +} from './constants'; export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; @@ -73,7 +82,7 @@ export type RequiredPaletteParamTypes = Required & { maxSteps?: number; }; -export type LayerType = 'data' | 'referenceLine'; +export type LayerType = typeof layerTypes[keyof typeof layerTypes]; // Shared by XY Chart and Heatmap as for now export type ValueLabelConfig = 'hide' | 'inside' | 'outside'; @@ -102,6 +111,7 @@ export interface SharedPieLayerState { percentDecimals?: number; emptySizeRatio?: number; legendMaxLines?: number; + legendSize?: number; truncateLegend?: boolean; } @@ -115,3 +125,13 @@ export interface PieVisualizationState { layers: PieLayerState[]; palette?: PaletteOutput; } +export interface MetricState { + layerId: string; + accessor?: string; + layerType: LayerType; + colorMode?: ColorMode; + palette?: PaletteOutput; + titlePosition?: 'top' | 'bottom'; + size?: string; + textAlign?: 'left' | 'right' | 'center'; +} diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 1debe6e6141b..17a58a0f9677 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -28,7 +28,8 @@ "taskManager", "globalSearch", "savedObjectsTagging", - "spaces" + "spaces", + "discover" ], "configPath": [ "xpack", diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index b16afbfc56a4..c4e82aca9ad4 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -635,6 +635,18 @@ describe('Lens App', () => { ); }); + it('applies all changes on-save', async () => { + const { lensStore } = await save({ + initialSavedObjectId: undefined, + newCopyOnSave: false, + newTitle: 'hello there', + preloadedState: { + applyChangesCounter: 0, + }, + }); + expect(lensStore.getState().lens.applyChangesCounter).toBe(1); + }); + it('adds to the recently accessed list on save', async () => { const { services } = await save({ initialSavedObjectId: undefined, diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 3660c3d3db0c..6312225af579 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -13,7 +13,7 @@ import { createKbnUrlStateStorage, withNotifyOnErrors, } from '../../../../../src/plugins/kibana_utils/public'; -import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { useExecutionContext, useKibana } from '../../../../../src/plugins/kibana_react/public'; import { OnSaveProps } from '../../../../../src/plugins/saved_objects/public'; import { syncQueryStateWithUrl } from '../../../../../src/plugins/data/public'; import { LensAppProps, LensAppServices } from './types'; @@ -24,6 +24,7 @@ import { Document } from '../persistence/saved_object_store'; import { setState, + applyChanges, useLensSelector, useLensDispatch, LensAppState, @@ -71,6 +72,7 @@ export function App({ getOriginatingAppName, spaces, http, + executionContext, // Temporarily required until the 'by value' paradigm is default. dashboardFeatureFlag, } = lensAppServices; @@ -111,6 +113,7 @@ export function App({ undefined ); const [isGoBackToVizEditorModalVisible, setIsGoBackToVizEditorModalVisible] = useState(false); + const savedObjectId = (initialInput as LensByReferenceInput)?.savedObjectId; useEffect(() => { if (currentDoc) { @@ -122,6 +125,12 @@ export function App({ setIndicateNoData(true); }, [setIndicateNoData]); + useExecutionContext(executionContext, { + type: 'application', + id: savedObjectId || 'new', + page: 'editor', + }); + useEffect(() => { if (indicateNoData) { setIndicateNoData(false); @@ -132,11 +141,9 @@ export function App({ () => Boolean( // Temporarily required until the 'by value' paradigm is default. - dashboardFeatureFlag.allowByValueEmbeddables && - isLinkedToOriginatingApp && - !(initialInput as LensByReferenceInput)?.savedObjectId + dashboardFeatureFlag.allowByValueEmbeddables && isLinkedToOriginatingApp && !savedObjectId ), - [dashboardFeatureFlag.allowByValueEmbeddables, isLinkedToOriginatingApp, initialInput] + [dashboardFeatureFlag.allowByValueEmbeddables, isLinkedToOriginatingApp, savedObjectId] ); useEffect(() => { @@ -270,6 +277,7 @@ export function App({ const runSave = useCallback( (saveProps: SaveProps, options: { saveToLibrary: boolean }) => { + dispatch(applyChanges()); return runSaveLensVisualization( { lastKnownDoc, @@ -310,6 +318,7 @@ export function App({ redirectTo, lensAppServices, dispatchSetState, + dispatch, setIsSaveModalVisible, ] ); diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index 8e8b7045fc25..dcd328ff5c48 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -28,10 +28,16 @@ import { DispatchSetState, } from '../state_management'; import { getIndexPatternsObjects, getIndexPatternsIds, getResolvedDateRange } from '../utils'; +import { + combineQueryAndFilters, + getLayerMetaInfo, + getShowUnderlyingDataLabel, +} from './show_underlying_data'; function getLensTopNavConfig(options: { showSaveAndReturn: boolean; enableExportToCSV: boolean; + showOpenInDiscover?: boolean; showCancel: boolean; isByValueMode: boolean; allowByValue: boolean; @@ -46,6 +52,7 @@ function getLensTopNavConfig(options: { showCancel, allowByValue, enableExportToCSV, + showOpenInDiscover, showSaveAndReturn, savingToLibraryPermitted, savingToDashboardPermitted, @@ -90,6 +97,21 @@ function getLensTopNavConfig(options: { }); } + if (showOpenInDiscover) { + topNavMenu.push({ + label: getShowUnderlyingDataLabel(), + run: () => {}, + testId: 'lnsApp_openInDiscover', + description: i18n.translate('xpack.lens.app.openInDiscoverAriaLabel', { + defaultMessage: 'Open underlying data in Discover', + }), + disableButton: Boolean(tooltips.showUnderlyingDataWarning()), + tooltip: tooltips.showUnderlyingDataWarning, + target: '_blank', + href: actions.getUnderlyingDataUrl(), + }); + } + topNavMenu.push({ label: i18n.translate('xpack.lens.app.inspect', { defaultMessage: 'Inspect', @@ -183,6 +205,7 @@ export const LensTopNavMenu = ({ uiSettings, application, attributeService, + discover, dashboardFeatureFlag, } = useKibana().services; @@ -290,6 +313,26 @@ export const LensTopNavMenu = ({ filters, initialContext, ]); + + const layerMetaInfo = useMemo(() => { + if (!activeDatasourceId || !discover) { + return; + } + return getLayerMetaInfo( + datasourceMap[activeDatasourceId], + datasourceStates[activeDatasourceId].state, + activeData, + application.capabilities + ); + }, [ + activeData, + activeDatasourceId, + datasourceMap, + datasourceStates, + discover, + application.capabilities, + ]); + const topNavConfig = useMemo(() => { const baseMenuEntries = getLensTopNavConfig({ showSaveAndReturn: @@ -299,6 +342,7 @@ export const LensTopNavMenu = ({ (dashboardFeatureFlag.allowByValueEmbeddables || Boolean(initialInput)) ) || Boolean(initialContextIsEmbedded), enableExportToCSV: Boolean(isSaveable && activeData && Object.keys(activeData).length), + showOpenInDiscover: Boolean(layerMetaInfo?.isVisible), isByValueMode: getIsByValueMode(), allowByValue: dashboardFeatureFlag.allowByValueEmbeddables, showCancel: Boolean(isLinkedToOriginatingApp), @@ -321,6 +365,9 @@ export const LensTopNavMenu = ({ } return undefined; }, + showUnderlyingDataWarning: () => { + return layerMetaInfo?.error; + }, }, actions: { inspect: () => lensInspector.inspect({ title }), @@ -388,6 +435,31 @@ export const LensTopNavMenu = ({ redirectToOrigin(); } }, + getUnderlyingDataUrl: () => { + if (!layerMetaInfo) { + return; + } + const { error, meta } = layerMetaInfo; + // If Discover is not available, return + // If there's no data, return + if (error || !discover || !meta) { + return; + } + const { filters: newFilters, query: newQuery } = combineQueryAndFilters( + query, + filters, + meta, + indexPatterns + ); + + return discover.locator!.getRedirectUrl({ + indexPatternId: meta.id, + timeRange: data.query.timefilter.timefilter.getTime(), + filters: newFilters, + query: newQuery, + columns: meta.columns, + }); + }, }, }); return [...(additionalMenuEntries || []), ...baseMenuEntries]; @@ -398,6 +470,7 @@ export const LensTopNavMenu = ({ initialContextIsEmbedded, isSaveable, activeData, + layerMetaInfo, getIsByValueMode, savingToLibraryPermitted, savingToDashboardPermitted, @@ -414,6 +487,11 @@ export const LensTopNavMenu = ({ setIsSaveModalVisible, goBackToOriginatingApp, redirectToOrigin, + discover, + query, + filters, + indexPatterns, + data.query.timefilter.timefilter, ]); const onQuerySubmitWrapped = useCallback( diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index 28db5e9f4c43..131dd5e66b6a 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -61,6 +61,7 @@ export async function getLensServices( usageCollection, fieldFormats, spaces, + discover, } = startDependencies; const storage = new Storage(localStorage); @@ -77,6 +78,7 @@ export async function getLensServices( usageCollection, savedObjectsTagging, attributeService, + executionContext: coreStart.executionContext, http: coreStart.http, chrome: coreStart.chrome, overlays: coreStart.overlays, @@ -94,6 +96,7 @@ export async function getLensServices( // Temporarily required until the 'by value' paradigm is default. dashboardFeatureFlag: startDependencies.dashboard.dashboardFeatureFlagConfig, spaces, + discover, }; } @@ -113,8 +116,10 @@ export async function mountApp( getPresentationUtilContext, topNavMenuEntryGenerators, } = mountProps; - const [coreStart, startDependencies] = await core.getStartServices(); - const instance = await createEditorFrame(); + const [[coreStart, startDependencies], instance] = await Promise.all([ + core.getStartServices(), + createEditorFrame(), + ]); const historyLocationState = params.history.location.state as HistoryLocationState; const lensServices = await getLensServices(coreStart, startDependencies, attributeService); diff --git a/x-pack/plugins/lens/public/app_plugin/show_underlying_data.test.ts b/x-pack/plugins/lens/public/app_plugin/show_underlying_data.test.ts new file mode 100644 index 000000000000..e74dd139e42c --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/show_underlying_data.test.ts @@ -0,0 +1,602 @@ +/* + * Copyright 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 { createMockDatasource } from '../mocks'; +import { combineQueryAndFilters, getLayerMetaInfo } from './show_underlying_data'; +import { Filter } from '@kbn/es-query'; +import { DatasourcePublicAPI } from '../types'; +import { RecursiveReadonly } from '@kbn/utility-types'; +import { Capabilities } from 'kibana/public'; + +describe('getLayerMetaInfo', () => { + const capabilities = { + navLinks: { discover: true }, + discover: { show: true }, + } as unknown as RecursiveReadonly; + it('should return error in case of no data', () => { + expect( + getLayerMetaInfo(createMockDatasource('testDatasource'), {}, undefined, capabilities).error + ).toBe('Visualization has no data available to show'); + }); + + it('should return error in case of multiple layers', () => { + expect( + getLayerMetaInfo( + createMockDatasource('testDatasource'), + {}, + { + datatable1: { type: 'datatable', columns: [], rows: [] }, + datatable2: { type: 'datatable', columns: [], rows: [] }, + }, + capabilities + ).error + ).toBe('Cannot show underlying data for visualizations with multiple layers'); + }); + + it('should return error in case of missing activeDatasource', () => { + expect(getLayerMetaInfo(undefined, {}, undefined, capabilities).error).toBe( + 'Visualization has no data available to show' + ); + }); + + it('should return error in case of missing configuration/state', () => { + expect( + getLayerMetaInfo(createMockDatasource('testDatasource'), undefined, {}, capabilities).error + ).toBe('Visualization has no data available to show'); + }); + + it('should return error in case of a timeshift declared in a column', () => { + const mockDatasource = createMockDatasource('testDatasource'); + const updatedPublicAPI: DatasourcePublicAPI = { + datasourceId: 'testDatasource', + getOperationForColumnId: jest.fn(() => ({ + dataType: 'number', + isBucketed: false, + scale: 'ratio', + label: 'A field', + isStaticValue: false, + sortingHint: undefined, + hasTimeShift: true, + })), + getTableSpec: jest.fn(), + getVisualDefaults: jest.fn(), + getSourceId: jest.fn(), + getFilters: jest.fn(), + }; + mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI); + expect( + getLayerMetaInfo(createMockDatasource('testDatasource'), {}, {}, capabilities).error + ).toBe('Visualization has no data available to show'); + }); + + it('should not be visible if discover is not available', () => { + // both capabilities should be enabled to enable discover + expect( + getLayerMetaInfo( + createMockDatasource('testDatasource'), + {}, + { + datatable1: { type: 'datatable', columns: [], rows: [] }, + }, + { + navLinks: { discover: false }, + discover: { show: true }, + } as unknown as RecursiveReadonly + ).isVisible + ).toBeFalsy(); + expect( + getLayerMetaInfo( + createMockDatasource('testDatasource'), + {}, + { + datatable1: { type: 'datatable', columns: [], rows: [] }, + }, + { + navLinks: { discover: true }, + discover: { show: false }, + } as unknown as RecursiveReadonly + ).isVisible + ).toBeFalsy(); + }); + + it('should basically work collecting fields and filters in the visualization', () => { + const mockDatasource = createMockDatasource('testDatasource'); + const updatedPublicAPI: DatasourcePublicAPI = { + datasourceId: 'indexpattern', + getOperationForColumnId: jest.fn(), + getTableSpec: jest.fn(() => [{ columnId: 'col1', fields: ['bytes'] }]), + getVisualDefaults: jest.fn(), + getSourceId: jest.fn(), + getFilters: jest.fn(() => ({ + kuery: [[{ language: 'kuery', query: 'memory > 40000' }]], + lucene: [], + })), + }; + mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI); + const { error, meta } = getLayerMetaInfo( + mockDatasource, + {}, // the publicAPI has been mocked, so no need for a state here + { + datatable1: { type: 'datatable', columns: [], rows: [] }, + }, + capabilities + ); + expect(error).toBeUndefined(); + expect(meta?.columns).toEqual(['bytes']); + expect(meta?.filters).toEqual({ + kuery: [ + [ + { + language: 'kuery', + query: 'memory > 40000', + }, + ], + ], + lucene: [], + }); + }); + + it('should return an error if datasource is not supported', () => { + const mockDatasource = createMockDatasource('testDatasource'); + const updatedPublicAPI: DatasourcePublicAPI = { + datasourceId: 'unsupportedDatasource', + getOperationForColumnId: jest.fn(), + getTableSpec: jest.fn(() => [{ columnId: 'col1', fields: ['bytes'] }]), + getVisualDefaults: jest.fn(), + getSourceId: jest.fn(), + getFilters: jest.fn(() => ({ + kuery: [[{ language: 'kuery', query: 'memory > 40000' }]], + lucene: [], + })), + }; + mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI); + const { error, meta } = getLayerMetaInfo( + mockDatasource, + {}, // the publicAPI has been mocked, so no need for a state here + { + datatable1: { type: 'datatable', columns: [], rows: [] }, + }, + capabilities + ); + expect(error).toBe('Underlying data does not support the current datasource'); + expect(meta).toBeUndefined(); + }); +}); +describe('combineQueryAndFilters', () => { + it('should just return same query and filters if no fields or filters are in layer meta', () => { + expect( + combineQueryAndFilters( + { language: 'kuery', query: 'myfield: *' }, + [], + { + id: 'testDatasource', + columns: [], + filters: { kuery: [], lucene: [] }, + }, + undefined + ) + ).toEqual({ query: { language: 'kuery', query: 'myfield: *' }, filters: [] }); + }); + + it('should concatenate filters with existing query if languages match (AND it)', () => { + expect( + combineQueryAndFilters( + { language: 'kuery', query: 'myfield: *' }, + [], + { + id: 'testDatasource', + columns: [], + filters: { kuery: [[{ language: 'kuery', query: 'otherField: *' }]], lucene: [] }, + }, + undefined + ) + ).toEqual({ + query: { language: 'kuery', query: '( myfield: * ) AND ( otherField: * )' }, + filters: [], + }); + }); + + it('should build single kuery expression from meta filters and assign it as query for final use', () => { + expect( + combineQueryAndFilters( + undefined, + [], + { + id: 'testDatasource', + columns: [], + filters: { kuery: [[{ language: 'kuery', query: 'otherField: *' }]], lucene: [] }, + }, + undefined + ) + ).toEqual({ query: { language: 'kuery', query: '( otherField: * )' }, filters: [] }); + }); + + it('should build single kuery expression from meta filters and join using OR and AND at the right level', () => { + // OR queries from the same array, AND queries from different arrays + expect( + combineQueryAndFilters( + undefined, + [], + { + id: 'testDatasource', + columns: [], + filters: { + kuery: [ + [ + { language: 'kuery', query: 'myfield: *' }, + { language: 'kuery', query: 'otherField: *' }, + ], + [ + { language: 'kuery', query: 'myfieldCopy: *' }, + { language: 'kuery', query: 'otherFieldCopy: *' }, + ], + ], + lucene: [], + }, + }, + undefined + ) + ).toEqual({ + query: { + language: 'kuery', + query: + '( ( ( myfield: * ) OR ( otherField: * ) ) AND ( ( myfieldCopy: * ) OR ( otherFieldCopy: * ) ) )', + }, + filters: [], + }); + }); + it('should assign kuery meta filters to app filters if existing query is using lucene language', () => { + expect( + combineQueryAndFilters( + { language: 'lucene', query: 'myField' }, + [], + { + id: 'testDatasource', + columns: [], + filters: { + kuery: [[{ language: 'kuery', query: 'myfield: *' }]], + lucene: [], + }, + }, + undefined + ) + ).toEqual({ + query: { language: 'lucene', query: 'myField' }, + filters: [ + { + $state: { + store: 'appState', + }, + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + exists: { + field: 'myfield', + }, + }, + ], + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + meta: { + alias: 'Lens context (kuery)', + disabled: false, + index: 'testDatasource', + negate: false, + type: 'custom', + }, + }, + ], + }); + }); + it('should append lucene meta filters to app filters even if existing filters are using kuery', () => { + expect( + combineQueryAndFilters( + { language: 'kuery', query: 'myField: *' }, + [ + { + $state: { + store: 'appState', + }, + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + exists: { + field: 'myfield', + }, + }, + ], + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + meta: { + alias: 'Existing kuery filters', + disabled: false, + index: 'testDatasource', + negate: false, + type: 'custom', + }, + } as Filter, + ], + { + id: 'testDatasource', + columns: [], + filters: { + kuery: [], + lucene: [[{ language: 'lucene', query: 'anotherField' }]], + }, + }, + undefined + ) + ).toEqual({ + filters: [ + { + $state: { + store: 'appState', + }, + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + exists: { + field: 'myfield', + }, + }, + ], + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + meta: { + alias: 'Existing kuery filters', + disabled: false, + index: 'testDatasource', + negate: false, + type: 'custom', + }, + }, + { + $state: { + store: 'appState', + }, + bool: { + filter: [], + must: [ + { + query_string: { + query: '( anotherField )', + }, + }, + ], + must_not: [], + should: [], + }, + meta: { + alias: 'Lens context (lucene)', + disabled: false, + index: 'testDatasource', + negate: false, + type: 'custom', + }, + }, + ], + query: { + language: 'kuery', + query: 'myField: *', + }, + }); + }); + it('should append lucene meta filters to an existing lucene query', () => { + expect( + combineQueryAndFilters( + { language: 'lucene', query: 'myField' }, + [], + { + id: 'testDatasource', + columns: [], + filters: { + kuery: [[{ language: 'kuery', query: 'myfield: *' }]], + lucene: [[{ language: 'lucene', query: 'anotherField' }]], + }, + }, + undefined + ) + ).toEqual({ + filters: [ + { + $state: { + store: 'appState', + }, + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + exists: { + field: 'myfield', + }, + }, + ], + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + meta: { + alias: 'Lens context (kuery)', + disabled: false, + index: 'testDatasource', + negate: false, + type: 'custom', + }, + }, + ], + query: { + language: 'lucene', + query: '( myField ) AND ( anotherField )', + }, + }); + }); + it('should work for complex cases of nested meta filters', () => { + // scenario overview: + // A kuery query + // A kuery filter pill + // 4 kuery table filter groups (1 from filtered column, 2 from filters, 1 from top values, 1 from custom ranges) + // 2 lucene table filter groups (1 from filtered column + 2 from filters ) + expect( + combineQueryAndFilters( + { language: 'kuery', query: 'myField: *' }, + [ + { + $state: { + store: 'appState', + }, + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + exists: { + field: 'myfield', + }, + }, + ], + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + meta: { + alias: 'Existing kuery filters', + disabled: false, + index: 'testDatasource', + negate: false, + type: 'custom', + }, + } as Filter, + ], + { + id: 'testDatasource', + columns: [], + filters: { + kuery: [ + [{ language: 'kuery', query: 'bytes > 4000' }], + [ + { language: 'kuery', query: 'memory > 5000' }, + { language: 'kuery', query: 'memory >= 15000' }, + ], + [{ language: 'kuery', query: 'myField: *' }], + [{ language: 'kuery', query: 'otherField >= 15' }], + ], + lucene: [ + [{ language: 'lucene', query: 'filteredField: 400' }], + [ + { language: 'lucene', query: 'aNewField' }, + { language: 'lucene', query: 'anotherNewField: 200' }, + ], + ], + }, + }, + undefined + ) + ).toEqual({ + filters: [ + { + $state: { + store: 'appState', + }, + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + exists: { + field: 'myfield', + }, + }, + ], + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + meta: { + alias: 'Existing kuery filters', + disabled: false, + index: 'testDatasource', + negate: false, + type: 'custom', + }, + }, + { + $state: { + store: 'appState', + }, + bool: { + filter: [], + must: [ + { + query_string: { + query: + '( ( filteredField: 400 ) AND ( ( aNewField ) OR ( anotherNewField: 200 ) ) )', + }, + }, + ], + must_not: [], + should: [], + }, + meta: { + alias: 'Lens context (lucene)', + disabled: false, + index: 'testDatasource', + negate: false, + type: 'custom', + }, + }, + ], + query: { + language: 'kuery', + query: + '( myField: * ) AND ( ( bytes > 4000 ) AND ( ( memory > 5000 ) OR ( memory >= 15000 ) ) AND ( myField: * ) AND ( otherField >= 15 ) )', + }, + }); + }); +}); diff --git a/x-pack/plugins/lens/public/app_plugin/show_underlying_data.ts b/x-pack/plugins/lens/public/app_plugin/show_underlying_data.ts new file mode 100644 index 000000000000..e1956542f8de --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/show_underlying_data.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + Query, + Filter, + DataViewBase, + buildCustomFilter, + buildEsQuery, + FilterStateStore, +} from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import { RecursiveReadonly } from '@kbn/utility-types'; +import { Capabilities } from 'kibana/public'; +import { TableInspectorAdapter } from '../editor_frame_service/types'; +import { Datasource } from '../types'; + +export const getShowUnderlyingDataLabel = () => + i18n.translate('xpack.lens.app.openInDiscover', { + defaultMessage: 'Open in Discover', + }); + +function joinQueries(queries: Query[][] | undefined) { + if (!queries) { + return ''; + } + const expression = queries + .filter((subQueries) => subQueries.length) + .map((subQueries) => + // reduce the amount of round brackets in case of one query + subQueries.length > 1 + ? `( ${subQueries.map(({ query: filterQuery }) => `( ${filterQuery} )`).join(' OR ')} )` + : `( ${subQueries[0].query} )` + ) + .join(' AND '); + return queries.length > 1 ? `( ${expression} )` : expression; +} + +interface LayerMetaInfo { + id: string; + columns: string[]; + filters: { + kuery: Query[][] | undefined; + lucene: Query[][] | undefined; + }; +} + +export function getLayerMetaInfo( + currentDatasource: Datasource | undefined, + datasourceState: unknown, + activeData: TableInspectorAdapter | undefined, + capabilities: RecursiveReadonly +): { meta: LayerMetaInfo | undefined; isVisible: boolean; error: string | undefined } { + const isVisible = Boolean(capabilities.navLinks?.discover && capabilities.discover?.show); + // If Multiple tables, return + // If there are time shifts, return + const [datatable, ...otherTables] = Object.values(activeData || {}); + if (!datatable || !currentDatasource || !datasourceState) { + return { + meta: undefined, + error: i18n.translate('xpack.lens.app.showUnderlyingDataNoData', { + defaultMessage: 'Visualization has no data available to show', + }), + isVisible, + }; + } + if (otherTables.length) { + return { + meta: undefined, + error: i18n.translate('xpack.lens.app.showUnderlyingDataMultipleLayers', { + defaultMessage: 'Cannot show underlying data for visualizations with multiple layers', + }), + isVisible, + }; + } + const [firstLayerId] = currentDatasource.getLayers(datasourceState); + const datasourceAPI = currentDatasource.getPublicAPI({ + layerId: firstLayerId, + state: datasourceState, + }); + // maybe add also datasourceId validation here? + if (datasourceAPI.datasourceId !== 'indexpattern') { + return { + meta: undefined, + error: i18n.translate('xpack.lens.app.showUnderlyingDataUnsupportedDatasource', { + defaultMessage: 'Underlying data does not support the current datasource', + }), + isVisible, + }; + } + const tableSpec = datasourceAPI.getTableSpec(); + + const columnsWithNoTimeShifts = tableSpec.filter( + ({ columnId }) => !datasourceAPI.getOperationForColumnId(columnId)?.hasTimeShift + ); + if (columnsWithNoTimeShifts.length < tableSpec.length) { + return { + meta: undefined, + error: i18n.translate('xpack.lens.app.showUnderlyingDataTimeShifts', { + defaultMessage: "Cannot show underlying data when there's a time shift configured", + }), + isVisible, + }; + } + + const uniqueFields = [...new Set(columnsWithNoTimeShifts.map(({ fields }) => fields).flat())]; + return { + meta: { + id: datasourceAPI.getSourceId()!, + columns: uniqueFields, + filters: datasourceAPI.getFilters(activeData), + }, + error: undefined, + isVisible, + }; +} + +// This enforces on assignment time that the two props are not the same +type LanguageAssignments = + | { queryLanguage: 'lucene'; filtersLanguage: 'kuery' } + | { queryLanguage: 'kuery'; filtersLanguage: 'lucene' }; + +export function combineQueryAndFilters( + query: Query | undefined, + filters: Filter[], + meta: LayerMetaInfo, + dataViews: DataViewBase[] | undefined +) { + // Unless a lucene query is already defined, kuery is assigned to it + const { queryLanguage, filtersLanguage }: LanguageAssignments = + query?.language === 'lucene' + ? { queryLanguage: 'lucene', filtersLanguage: 'kuery' } + : { queryLanguage: 'kuery', filtersLanguage: 'lucene' }; + + let newQuery = query; + if (meta.filters[queryLanguage]?.length) { + const filtersQuery = joinQueries(meta.filters[queryLanguage]); + newQuery = { + language: queryLanguage, + query: query?.query.trim() + ? `( ${query.query} ) ${filtersQuery ? `AND ${filtersQuery}` : ''}` + : filtersQuery, + }; + } + + // make a copy as the original filters are readonly + const newFilters = [...filters]; + if (meta.filters[filtersLanguage]?.length) { + const queryExpression = joinQueries(meta.filters[filtersLanguage]); + // Append the new filter based on the queryExpression to the existing ones + newFilters.push( + buildCustomFilter( + meta.id!, + buildEsQuery( + dataViews?.find(({ id }) => id === meta.id), + { language: filtersLanguage, query: queryExpression }, + [] + ), + false, + false, + i18n.translate('xpack.lens.app.lensContext', { + defaultMessage: 'Lens context ({language})', + values: { language: filtersLanguage }, + }), + FilterStateStore.APP_STATE + ) + ); + } + return { filters: newFilters, query: newQuery }; +} diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts index bdd7bebd991e..003e458b8114 100644 --- a/x-pack/plugins/lens/public/app_plugin/types.ts +++ b/x-pack/plugins/lens/public/app_plugin/types.ts @@ -7,11 +7,13 @@ import type { History } from 'history'; import type { OnSaveProps } from 'src/plugins/saved_objects/public'; +import { DiscoverStart } from 'src/plugins/discover/public'; import { SpacesApi } from '../../../spaces/public'; import type { ApplicationStart, AppMountParameters, ChromeStart, + ExecutionContextStart, HttpStart, IUiSettingsClient, NotificationsStart, @@ -114,6 +116,7 @@ export interface HistoryLocationState { export interface LensAppServices { http: HttpStart; + executionContext: ExecutionContextStart; chrome: ChromeStart; overlays: OverlayStart; storage: IStorageWrapper; @@ -133,6 +136,7 @@ export interface LensAppServices { getOriginatingAppName: () => string | undefined; presentationUtil: PresentationUtilPluginStart; spaces: SpacesApi; + discover?: DiscoverStart; // Temporarily required until the 'by value' paradigm is default. dashboardFeatureFlag: DashboardFeatureFlagConfig; @@ -140,6 +144,7 @@ export interface LensAppServices { export interface LensTopNavTooltips { showExportWarning: () => string | undefined; + showUnderlyingDataWarning: () => string | undefined; } export interface LensTopNavActions { @@ -149,4 +154,5 @@ export interface LensTopNavActions { goBack: () => void; cancel: () => void; exportToCSV: () => void; + getUnderlyingDataUrl: () => string | undefined; } diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap index 40eb546dfc20..6100bc204d45 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap +++ b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap @@ -227,6 +227,13 @@ exports[`DatatableComponent it renders actions column when there are row actions onColumnResize={[Function]} renderCellValue={[Function]} rowCount={1} + rowHeightsOptions={ + Object { + "defaultHeight": Object { + "lineCount": 1, + }, + } + } sorting={ Object { "columns": Array [], @@ -472,6 +479,13 @@ exports[`DatatableComponent it renders the title and value 1`] = ` onColumnResize={[Function]} renderCellValue={[Function]} rowCount={1} + rowHeightsOptions={ + Object { + "defaultHeight": Object { + "lineCount": 1, + }, + } + } sorting={ Object { "columns": Array [], @@ -712,6 +726,13 @@ exports[`DatatableComponent it should render hide, reset, and sort actions on he onColumnResize={[Function]} renderCellValue={[Function]} rowCount={1} + rowHeightsOptions={ + Object { + "defaultHeight": Object { + "lineCount": 1, + }, + } + } sorting={ Object { "columns": Array [], diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx index bd35874bc352..f383c13e275a 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx @@ -86,7 +86,7 @@ describe('datatable cell renderer', () => { /> ); - expect(cell.find('.lnsTableCell').prop('className')).toContain('--right'); + expect(cell.find('.lnsTableCell--right').exists()).toBeTruthy(); }); describe('dynamic coloring', () => { @@ -127,6 +127,7 @@ describe('datatable cell renderer', () => { ], sortingColumnId: '', sortingDirection: 'none', + rowHeightLines: 1, }; } diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx index a493ed2eb169..9ecd7579aa17 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx @@ -20,7 +20,8 @@ export const createGridCell = ( columnConfig: ColumnConfig, DataContext: React.Context, uiSettings: IUiSettingsClient, - fitRowToContent?: boolean + fitRowToContent?: boolean, + rowHeight?: number ) => { // Changing theme requires a full reload of the page, so we can cache here const IS_DARK_THEME = uiSettings.get('theme:darkMode'); @@ -31,7 +32,7 @@ export const createGridCell = ( const currentAlignment = alignments && alignments[columnId]; const alignmentClassName = `lnsTableCell--${currentAlignment}`; const className = classNames(alignmentClassName, { - lnsTableCell: !fitRowToContent, + lnsTableCell: !fitRowToContent && rowHeight === 1, }); const { colorMode, palette } = diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx index d8dabd81441d..b6c72cc5fe6f 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx @@ -7,7 +7,11 @@ import React from 'react'; import { EuiButtonGroup, EuiComboBox, EuiFieldText } from '@elastic/eui'; -import { FramePublicAPI, Operation, VisualizationDimensionEditorProps } from '../../types'; +import { + FramePublicAPI, + OperationDescriptor, + VisualizationDimensionEditorProps, +} from '../../types'; import { DatatableVisualizationState } from '../visualization'; import { createMockDatasource, createMockFramePublicAPI } from '../../mocks'; import { mountWithIntl } from '@kbn/test-jest-helpers'; @@ -218,7 +222,7 @@ describe('data table dimension editor', () => { it('should not show the dynamic coloring option for a bucketed operation', () => { frame.activeData!.first.columns[0].meta.type = 'number'; frame.datasourceLayers.first.getOperationForColumnId = jest.fn( - () => ({ isBucketed: true } as Operation) + () => ({ isBucketed: true } as OperationDescriptor) ); state.columns[0].colorMode = 'cell'; const instance = mountWithIntl(); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx index 6cab22cd08cc..36fd1581cb9b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx @@ -84,6 +84,7 @@ function sampleArgs() { ], sortingColumnId: '', sortingDirection: 'none', + rowHeightLines: 1, }; return { data, args }; @@ -299,6 +300,7 @@ describe('DatatableComponent', () => { ], sortingColumnId: '', sortingDirection: 'none', + rowHeightLines: 1, }; const wrapper = mountWithIntl( diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx index 403858f4ba48..45ceb5854152 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -334,9 +334,16 @@ export const DatatableComponent = (props: DatatableRenderProps) => { columnConfig, DataContext, props.uiSettings, - props.args.fitRowToContent + props.args.fitRowToContent, + props.args.rowHeightLines ), - [formatters, columnConfig, props.uiSettings, props.args.fitRowToContent] + [ + formatters, + columnConfig, + props.uiSettings, + props.args.fitRowToContent, + props.args.rowHeightLines, + ] ); const columnVisibility = useMemo( @@ -414,13 +421,15 @@ export const DatatableComponent = (props: DatatableRenderProps) => { { + const original = jest.requireActual('@elastic/eui'); + + return { + ...original, + htmlIdGenerator: (fn: unknown) => { + return () => ''; + }, + }; +}); class Harness { wrapper: ReactWrapper; @@ -25,12 +38,25 @@ class Harness { this.wrapper.find(ToolbarButton).simulate('click'); } - public get fitRowToContentSwitch() { - return this.wrapper.find('EuiSwitch[data-test-subj="lens-legend-auto-height-switch"]'); + public get rowHeight() { + return this.wrapper.find(EuiButtonGroup); } - toggleFitRowToContent() { - this.fitRowToContentSwitch.prop('onChange')!({} as FormEvent); + changeRowHeight(newMode: 'single' | 'auto' | 'custom') { + this.rowHeight.prop('onChange')!(newMode); + } + + public get rowHeightLines() { + return this.wrapper.find(EuiRange); + } + + changeRowHeightLines(lineCount: number) { + this.rowHeightLines.prop('onChange')!( + { + currentTarget: { value: lineCount }, + } as unknown as ChangeEvent, + true + ); } public get paginationSwitch() { @@ -56,7 +82,7 @@ describe('datatable toolbar', () => { setState: jest.fn(), frame: {} as FramePublicAPI, state: { - fitRowToContent: false, + rowHeight: 'single', } as DatatableVisualizationState, }; @@ -66,37 +92,55 @@ describe('datatable toolbar', () => { it('should reflect state in the UI', async () => { harness.togglePopover(); - expect(harness.fitRowToContentSwitch.prop('checked')).toBe(false); + expect(harness.rowHeight.prop('idSelected')).toBe('single'); expect(harness.paginationSwitch.prop('checked')).toBe(false); harness.wrapper.setProps({ state: { - fitRowToContent: true, + rowHeight: 'auto', paging: defaultPagingState, }, }); - expect(harness.fitRowToContentSwitch.prop('checked')).toBe(true); + expect(harness.rowHeight.prop('idSelected')).toBe('auto'); expect(harness.paginationSwitch.prop('checked')).toBe(true); }); - it('should toggle fit-row-to-content', async () => { + it('should change row height to "Auto" mode', async () => { harness.togglePopover(); - harness.toggleFitRowToContent(); + harness.changeRowHeight('auto'); expect(defaultProps.setState).toHaveBeenCalledTimes(1); expect(defaultProps.setState).toHaveBeenNthCalledWith(1, { - fitRowToContent: true, + rowHeight: 'auto', + rowHeightLines: undefined, }); - harness.wrapper.setProps({ state: { fitRowToContent: true } }); // update state manually - harness.toggleFitRowToContent(); // turn it off + harness.wrapper.setProps({ state: { rowHeight: 'auto' } }); // update state manually + harness.changeRowHeight('single'); // turn it off expect(defaultProps.setState).toHaveBeenCalledTimes(2); expect(defaultProps.setState).toHaveBeenNthCalledWith(2, { - fitRowToContent: false, + rowHeight: 'single', + rowHeightLines: 1, + }); + }); + + it('should change row height to "Custom" mode', async () => { + harness.togglePopover(); + + harness.changeRowHeight('custom'); + + expect(defaultProps.setState).toHaveBeenCalledTimes(1); + expect(defaultProps.setState).toHaveBeenNthCalledWith(1, { + rowHeight: 'custom', + rowHeightLines: 2, }); + + harness.wrapper.setProps({ state: { rowHeight: 'custom' } }); // update state manually + + expect(harness.rowHeightLines.prop('value')).toBe(2); }); it('should toggle table pagination', async () => { @@ -107,18 +151,18 @@ describe('datatable toolbar', () => { expect(defaultProps.setState).toHaveBeenCalledTimes(1); expect(defaultProps.setState).toHaveBeenNthCalledWith(1, { paging: defaultPagingState, - fitRowToContent: false, + rowHeight: 'single', }); // update state manually harness.wrapper.setProps({ - state: { fitRowToContent: false, paging: defaultPagingState }, + state: { rowHeight: 'single', paging: defaultPagingState }, }); harness.togglePagination(); // turn it off. this should disable pagination but preserve the default page size expect(defaultProps.setState).toHaveBeenCalledTimes(2); expect(defaultProps.setState).toHaveBeenNthCalledWith(2, { - fitRowToContent: false, + rowHeight: 'single', paging: { ...defaultPagingState, enabled: false }, }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx index e95ae98e3756..548af6917c18 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx @@ -7,22 +7,47 @@ import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFlexGroup, EuiFormRow, EuiSwitch, EuiToolTip } from '@elastic/eui'; +import { + EuiButtonGroup, + EuiFlexGroup, + EuiFormRow, + EuiRange, + EuiSwitch, + EuiToolTip, + htmlIdGenerator, +} from '@elastic/eui'; import { ToolbarPopover } from '../../shared_components'; import type { VisualizationToolbarProps } from '../../types'; import type { DatatableVisualizationState } from '../visualization'; import { DEFAULT_PAGE_SIZE } from './table_basic'; +const idPrefix = htmlIdGenerator()(); + export function DataTableToolbar(props: VisualizationToolbarProps) { const { state, setState } = props; - const onToggleFitRow = useCallback(() => { - const current = state.fitRowToContent ?? false; - setState({ - ...state, - fitRowToContent: !current, - }); - }, [setState, state]); + const onChangeRowHeight = useCallback( + (newHeightMode) => { + const rowHeightLines = + newHeightMode === 'single' ? 1 : newHeightMode !== 'auto' ? 2 : undefined; + setState({ + ...state, + rowHeight: newHeightMode, + rowHeightLines, + }); + }, + [setState, state] + ); + + const onChangeRowHeightLines = useCallback( + (newRowHeightLines) => { + setState({ + ...state, + rowHeightLines: newRowHeightLines, + }); + }, + [state, setState] + ); const onTogglePagination = useCallback(() => { const current = state.paging ?? { size: DEFAULT_PAGE_SIZE, enabled: false }; @@ -33,6 +58,30 @@ export function DataTableToolbar(props: VisualizationToolbarProps - { + const newMode = optionId.replace( + idPrefix, + '' + ) as DatatableVisualizationState['rowHeight']; + onChangeRowHeight(newMode); + }} /> + {state.rowHeight === 'custom' ? ( + + { + const lineCount = Number(e.currentTarget.value); + onChangeRowHeightLines(lineCount); + }} + data-test-subj="lens-table-row-height-lineCountNumber" + /> + + ) : null} { const datasource = createMockDatasource('test'); const frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; - datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); + datasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'c', fields: [] }, + { columnId: 'b', fields: [] }, + ]); expect( datatableVisualization.getConfiguration({ @@ -501,7 +504,10 @@ describe('Datatable Visualization', () => { beforeEach(() => { datasource = createMockDatasource('test'); - datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); + datasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'c', fields: [] }, + { columnId: 'b', fields: [] }, + ]); frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; @@ -512,6 +518,8 @@ describe('Datatable Visualization', () => { dataType: 'string', isBucketed: false, // <= make them metrics label: 'label', + isStaticValue: false, + hasTimeShift: false, }); const expression = datatableVisualization.toExpression( @@ -559,6 +567,8 @@ describe('Datatable Visualization', () => { dataType: 'string', isBucketed: true, // move it from the metric to the break down by side label: 'label', + isStaticValue: false, + hasTimeShift: false, }); const expression = datatableVisualization.toExpression( @@ -587,21 +597,62 @@ describe('Datatable Visualization', () => { ).toEqual([20]); }); - it('sets fitRowToContent based on state', () => { + it('sets rowHeight "auto" fit based on state', () => { expect( getDatatableExpressionArgs({ ...defaultExpressionTableState }).fitRowToContent ).toEqual([false]); expect( - getDatatableExpressionArgs({ ...defaultExpressionTableState, fitRowToContent: false }) + getDatatableExpressionArgs({ ...defaultExpressionTableState, rowHeight: 'single' }) .fitRowToContent ).toEqual([false]); expect( - getDatatableExpressionArgs({ ...defaultExpressionTableState, fitRowToContent: true }) + getDatatableExpressionArgs({ ...defaultExpressionTableState, rowHeight: 'custom' }) + .fitRowToContent + ).toEqual([false]); + + expect( + getDatatableExpressionArgs({ ...defaultExpressionTableState, rowHeight: 'auto' }) .fitRowToContent ).toEqual([true]); }); + + it('sets rowHeightLines fit based on state', () => { + expect(getDatatableExpressionArgs({ ...defaultExpressionTableState }).rowHeightLines).toEqual( + [1] + ); + + expect( + getDatatableExpressionArgs({ ...defaultExpressionTableState, rowHeight: 'single' }) + .rowHeightLines + ).toEqual([1]); + + // should ignore lines value based on mode + expect( + getDatatableExpressionArgs({ + ...defaultExpressionTableState, + rowHeight: 'single', + rowHeightLines: 5, + }).rowHeightLines + ).toEqual([1]); + + expect( + getDatatableExpressionArgs({ + ...defaultExpressionTableState, + rowHeight: 'custom', + rowHeightLines: 5, + }).rowHeightLines + ).toEqual([5]); + + // should fallback to 2 for custom in case it's not set + expect( + getDatatableExpressionArgs({ + ...defaultExpressionTableState, + rowHeight: 'custom', + }).rowHeightLines + ).toEqual([2]); + }); }); describe('#getErrorMessages', () => { @@ -609,11 +660,16 @@ describe('Datatable Visualization', () => { const datasource = createMockDatasource('test'); const frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; - datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); + datasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'c', fields: [] }, + { columnId: 'b', fields: [] }, + ]); datasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ dataType: 'string', isBucketed: true, // move it from the metric to the break down by side label: 'label', + isStaticValue: false, + hasTimeShift: false, }); const error = datatableVisualization.getErrorMessages({ @@ -629,11 +685,16 @@ describe('Datatable Visualization', () => { const datasource = createMockDatasource('test'); const frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; - datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); + datasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'c', fields: [] }, + { columnId: 'b', fields: [] }, + ]); datasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ dataType: 'string', isBucketed: false, // keep it a metric label: 'label', + isStaticValue: false, + hasTimeShift: false, }); const error = datatableVisualization.getErrorMessages({ diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 8cc3709cade0..3e9f35f80b2b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -32,7 +32,8 @@ export interface DatatableVisualizationState { layerId: string; layerType: LayerType; sorting?: SortingState; - fitRowToContent?: boolean; + rowHeight?: 'auto' | 'single' | 'custom'; + rowHeightLines?: number; paging?: PagingState; } @@ -400,7 +401,10 @@ export const getDatatableVisualization = ({ }), sortingColumnId: [state.sorting?.columnId || ''], sortingDirection: [state.sorting?.direction || 'none'], - fitRowToContent: [state.fitRowToContent ?? false], + fitRowToContent: [state.rowHeight === 'auto'], + rowHeightLines: [ + !state.rowHeight || state.rowHeight === 'single' ? 1 : state.rowHeightLines ?? 2, + ], pageSize: state.paging?.enabled ? [state.paging.size] : [], }, }, diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 2543687a4220..143edddd8b7b 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -644,7 +644,6 @@ const DropsInner = memo(function DropsInner(props: DropsInnerProps) {
    diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index f7402e78ebd9..e660df8ff7bb 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -24,6 +24,26 @@ import { import { i18n } from '@kbn/i18n'; +/** + * The dimension container is set up to close when it detects a click outside it. + * Use this CSS class to exclude particular elements from this behavior. + */ +export const DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS = + 'lensDontCloseDimensionContainerOnClick'; + +function fromExcludedClickTarget(event: Event) { + for ( + let node: HTMLElement | null = event.target as HTMLElement; + node !== null; + node = node!.parentElement + ) { + if (node.classList!.contains(DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS)) { + return true; + } + } + return false; +} + export function DimensionContainer({ isOpen, groupLabel, @@ -77,8 +97,8 @@ export function DimensionContainer({ { - if (isFullscreen) { + onOutsideClick={(event) => { + if (isFullscreen || fromExcludedClickTarget(event)) { return; } closeFlyout(); @@ -135,7 +155,13 @@ export function DimensionContainer({
    {panel}
    - + {i18n.translate('xpack.lens.dimensionContainer.close', { defaultMessage: 'Close', })} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.test.tsx new file mode 100644 index 000000000000..64656a2eedf6 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 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 { DataPanelWrapper } from './data_panel_wrapper'; +import { Datasource, DatasourceDataPanelProps } from '../../types'; +import { DragDropIdentifier } from '../../drag_drop'; +import { UiActionsStart } from 'src/plugins/ui_actions/public'; +import { mockStoreDeps, mountWithProvider } from '../../mocks'; +import { disableAutoApply } from '../../state_management/lens_slice'; +import { selectTriggerApplyChanges } from '../../state_management'; + +describe('Data Panel Wrapper', () => { + describe('Datasource data panel properties', () => { + let datasourceDataPanelProps: DatasourceDataPanelProps; + let lensStore: Awaited>['lensStore']; + beforeEach(async () => { + const renderDataPanel = jest.fn(); + + const datasourceMap = { + activeDatasource: { + renderDataPanel, + } as unknown as Datasource, + }; + + const mountResult = await mountWithProvider( + {}} + core={{} as DatasourceDataPanelProps['core']} + dropOntoWorkspace={(field: DragDropIdentifier) => {}} + hasSuggestionForField={(field: DragDropIdentifier) => true} + plugins={{ uiActions: {} as UiActionsStart }} + />, + { + preloadedState: { + activeDatasourceId: 'activeDatasource', + datasourceStates: { + activeDatasource: { + isLoading: false, + state: { + age: 'old', + }, + }, + }, + }, + storeDeps: mockStoreDeps({ datasourceMap }), + } + ); + + lensStore = mountResult.lensStore; + + datasourceDataPanelProps = renderDataPanel.mock.calls[0][1] as DatasourceDataPanelProps; + }); + + describe('setState', () => { + it('applies state immediately when option true', async () => { + lensStore.dispatch(disableAutoApply()); + selectTriggerApplyChanges(lensStore.getState()); + + const newDatasourceState = { age: 'new' }; + datasourceDataPanelProps.setState(newDatasourceState, { applyImmediately: true }); + + expect(lensStore.getState().lens.datasourceStates.activeDatasource.state).toEqual( + newDatasourceState + ); + expect(selectTriggerApplyChanges(lensStore.getState())).toBeTruthy(); + }); + + it('does not apply state immediately when option false', async () => { + lensStore.dispatch(disableAutoApply()); + selectTriggerApplyChanges(lensStore.getState()); + + const newDatasourceState = { age: 'new' }; + datasourceDataPanelProps.setState(newDatasourceState, { applyImmediately: false }); + + const lensState = lensStore.getState().lens; + expect(lensState.datasourceStates.activeDatasource.state).toEqual(newDatasourceState); + expect(selectTriggerApplyChanges(lensStore.getState())).toBeFalsy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx index b77d31397343..17f3d385123c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx @@ -20,6 +20,7 @@ import { updateDatasourceState, useLensSelector, setState, + applyChanges, selectExecutionContext, selectActiveDatasourceId, selectDatasourceStates, @@ -45,8 +46,8 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { : true; const dispatchLens = useLensDispatch(); - const setDatasourceState: StateSetter = useMemo(() => { - return (updater) => { + const setDatasourceState: StateSetter = useMemo(() => { + return (updater: unknown | ((prevState: unknown) => unknown), options) => { dispatchLens( updateDatasourceState({ updater, @@ -54,6 +55,9 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { clearStagedPreview: true, }) ); + if (options?.applyImmediately) { + dispatchLens(applyChanges()); + } }; }, [activeDatasourceId, dispatchLens]); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index 174bb48bc9e4..a54161863ed2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -476,6 +476,8 @@ describe('editor_frame', () => { getOperationForColumnId: jest.fn(), getTableSpec: jest.fn(), getVisualDefaults: jest.fn(), + getSourceId: jest.fn(), + getFilters: jest.fn(), }; mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index f2e4af61ddbd..7f1c673d0d1d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -79,7 +79,7 @@ export function EditorFrame(props: EditorFrameProps) { const suggestion = getSuggestionForField.current!(field); if (suggestion) { trackUiEvent('drop_onto_workspace'); - switchToSuggestion(dispatchLens, suggestion, true); + switchToSuggestion(dispatchLens, suggestion, { clearStagedPreview: true }); } }, [getSuggestionForField, dispatchLens] diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss index c8c0a6e2ebbd..b49c77bb8b41 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss @@ -73,7 +73,6 @@ a tilemap in an iframe: https://github.com/elastic/kibana/issues/16457 */ } &.lnsFrameLayout__pageBody-isFullscreen { - background: $euiColorEmptyShade; flex: 1; padding: 0; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.tsx index 5175158e077f..13e8dd6f8563 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.tsx @@ -49,7 +49,6 @@ export function FrameLayout(props: FrameLayoutProps) {
    { defaultParams = [ { '1': { - getTableSpec: () => [{ columnId: 'col1' }], + getTableSpec: () => [{ columnId: 'col1', fields: [] }], datasourceId: '', getOperationForColumnId: jest.fn(), getVisualDefaults: jest.fn(), + getSourceId: jest.fn(), + getFilters: jest.fn(), }, }, { activeId: 'testVis', state: {} }, @@ -764,6 +766,8 @@ describe('suggestion helpers', () => { datasourceId: '', getOperationForColumnId: jest.fn(), getVisualDefaults: jest.fn(), + getSourceId: jest.fn(), + getFilters: jest.fn(), }, }; mockVisualization1.getSuggestions.mockReturnValue([]); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index b8ce851f2534..0dddf982bcbc 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -27,6 +27,7 @@ import { switchVisualization, DatasourceStates, VisualizationState, + applyChanges, } from '../../state_management'; /** @@ -147,7 +148,8 @@ export function getSuggestions({ }, currentVisualizationState, subVisualizationId, - palette + palette, + visualizeTriggerFieldContext && 'isVisualizeAction' in visualizeTriggerFieldContext ); }); }) @@ -204,7 +206,8 @@ function getVisualizationSuggestions( datasourceSuggestion: DatasourceSuggestion & { datasourceId: string }, currentVisualizationState: unknown, subVisualizationId?: string, - mainPalette?: PaletteOutput + mainPalette?: PaletteOutput, + isFromContext?: boolean ) { return visualization .getSuggestions({ @@ -213,6 +216,7 @@ function getVisualizationSuggestions( keptLayerIds: datasourceSuggestion.keptLayerIds, subVisualizationId, mainPalette, + isFromContext, }) .map(({ state, ...visualizationSuggestion }) => ({ ...visualizationSuggestion, @@ -232,7 +236,10 @@ export function switchToSuggestion( Suggestion, 'visualizationId' | 'visualizationState' | 'datasourceState' | 'datasourceId' >, - clearStagedPreview?: boolean + options?: { + clearStagedPreview?: boolean; + applyImmediately?: boolean; + } ) { dispatchLens( switchVisualization({ @@ -242,9 +249,12 @@ export function switchToSuggestion( datasourceState: suggestion.datasourceState, datasourceId: suggestion.datasourceId!, }, - clearStagedPreview, + clearStagedPreview: options?.clearStagedPreview, }) ); + if (options?.applyImmediately) { + dispatchLens(applyChanges()); + } } export function getTopSuggestionForField( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss index 37a4a88c32f2..804bfbf11d74 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss @@ -88,3 +88,7 @@ text-align: center; flex-grow: 0; } + +.lnsSuggestionPanel__applyChangesPrompt { + height: $lnsSuggestionHeight; +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx index c9ddc0ea6551..8d9ea9b3c70b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx @@ -21,7 +21,21 @@ import { getSuggestions } from './suggestion_helpers'; import { EuiIcon, EuiPanel, EuiToolTip, EuiAccordion } from '@elastic/eui'; import { LensIconChartDatatable } from '../../assets/chart_datatable'; import { mountWithProvider } from '../../mocks'; -import { LensAppState, PreviewState, setState, setToggleFullscreen } from '../../state_management'; +import { + applyChanges, + LensAppState, + PreviewState, + setState, + setToggleFullscreen, + VisualizationState, +} from '../../state_management'; +import { setChangesApplied } from '../../state_management/lens_slice'; + +const SELECTORS = { + APPLY_CHANGES_BUTTON: 'button[data-test-subj="lnsSuggestionApplyChanges"]', + SUGGESTIONS_PANEL: '[data-test-subj="lnsSuggestionsPanel"]', + SUGGESTION_TILE_BUTTON: 'button[data-test-subj="lnsSuggestion"]', +}; jest.mock('./suggestion_helpers'); @@ -108,6 +122,38 @@ describe('suggestion_panel', () => { expect(instance.find(SuggestionPanel).exists()).toBe(true); }); + it('should display apply-changes prompt when changes not applied', async () => { + const { instance, lensStore } = await mountWithProvider(, { + preloadedState: { + ...preloadedState, + visualization: { + ...preloadedState.visualization, + state: { + something: 'changed', + }, + } as VisualizationState, + changesApplied: false, + autoApplyDisabled: true, + }, + }); + + expect(instance.exists(SELECTORS.APPLY_CHANGES_BUTTON)).toBeTruthy(); + expect(instance.exists(SELECTORS.SUGGESTION_TILE_BUTTON)).toBeFalsy(); + + instance.find(SELECTORS.APPLY_CHANGES_BUTTON).simulate('click'); + + // check changes applied + expect(lensStore.dispatch).toHaveBeenCalledWith(applyChanges()); + + // simulate workspace panel behavior + lensStore.dispatch(setChangesApplied(true)); + instance.update(); + + // check UI updated + expect(instance.exists(SELECTORS.APPLY_CHANGES_BUTTON)).toBeFalsy(); + expect(instance.exists(SELECTORS.SUGGESTION_TILE_BUTTON)).toBeTruthy(); + }); + it('should list passed in suggestions', async () => { const { instance } = await mountWithProvider(, { preloadedState, @@ -173,12 +219,12 @@ describe('suggestion_panel', () => { preloadedState, }); act(() => { - instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click'); + instance.find(SELECTORS.SUGGESTION_TILE_BUTTON).at(2).simulate('click'); }); instance.update(); - expect(instance.find('[data-test-subj="lnsSuggestion"]').at(2).prop('className')).toContain( + expect(instance.find(SELECTORS.SUGGESTION_TILE_BUTTON).at(2).prop('className')).toContain( 'lnsSuggestionPanel__button-isSelected' ); }); @@ -189,13 +235,13 @@ describe('suggestion_panel', () => { ); act(() => { - instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click'); + instance.find(SELECTORS.SUGGESTION_TILE_BUTTON).at(2).simulate('click'); }); instance.update(); act(() => { - instance.find('[data-test-subj="lnsSuggestion"]').at(0).simulate('click'); + instance.find(SELECTORS.SUGGESTION_TILE_BUTTON).at(0).simulate('click'); }); instance.update(); @@ -203,6 +249,10 @@ describe('suggestion_panel', () => { expect(lensStore.dispatch).toHaveBeenCalledWith({ type: 'lens/rollbackSuggestion', }); + // check that it immediately applied any state changes in case auto-apply disabled + expect(lensStore.dispatch).toHaveBeenLastCalledWith({ + type: applyChanges.type, + }); }); }); @@ -212,7 +262,7 @@ describe('suggestion_panel', () => { }); act(() => { - instance.find('button[data-test-subj="lnsSuggestion"]').at(1).simulate('click'); + instance.find(SELECTORS.SUGGESTION_TILE_BUTTON).at(1).simulate('click'); }); expect(lensStore.dispatch).toHaveBeenCalledWith( @@ -228,6 +278,7 @@ describe('suggestion_panel', () => { }, }) ); + expect(lensStore.dispatch).toHaveBeenLastCalledWith({ type: applyChanges.type }); }); it('should render render icon if there is no preview expression', async () => { @@ -264,10 +315,10 @@ describe('suggestion_panel', () => { preloadedState, }); - expect(instance.find('[data-test-subj="lnsSuggestionsPanel"]').find(EuiIcon)).toHaveLength(1); - expect( - instance.find('[data-test-subj="lnsSuggestionsPanel"]').find(EuiIcon).prop('type') - ).toEqual(LensIconChartDatatable); + expect(instance.find(SELECTORS.SUGGESTIONS_PANEL).find(EuiIcon)).toHaveLength(1); + expect(instance.find(SELECTORS.SUGGESTIONS_PANEL).find(EuiIcon).prop('type')).toEqual( + LensIconChartDatatable + ); }); it('should return no suggestion if visualization has missing index-patterns', async () => { @@ -301,7 +352,7 @@ describe('suggestion_panel', () => { instance.find(EuiAccordion).at(0).simulate('change'); }); - expect(instance.find('[data-test-subj="lnsSuggestionsPanel"]')).toEqual({}); + expect(instance.find(SELECTORS.SUGGESTIONS_PANEL)).toEqual({}); }); it('should render preview expression if there is one', () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index f6fccbb831ea..1556be13a334 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -19,6 +19,10 @@ import { EuiToolTip, EuiButtonEmpty, EuiAccordion, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiSpacer, } from '@elastic/eui'; import { IconType } from '@elastic/eui/src/components/icon/icon'; import { Ast, toExpression } from '@kbn/interpreter'; @@ -55,7 +59,10 @@ import { selectActiveDatasourceId, selectActiveData, selectDatasourceStates, + selectChangesApplied, + applyChanges, } from '../../state_management'; +import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from './config_panel/dimension_container'; const MAX_SUGGESTIONS_DISPLAYED = 5; const LOCAL_STORAGE_SUGGESTIONS_PANEL = 'LENS_SUGGESTIONS_PANEL_HIDDEN'; @@ -96,7 +103,6 @@ const PreviewRenderer = ({ return (
    @@ -142,7 +148,6 @@ const SuggestionPreview = ({ hasBorder hasShadow={false} className={classNames('lnsSuggestionPanel__button', { - // eslint-disable-next-line @typescript-eslint/naming-convention 'lnsSuggestionPanel__button-isSelected': selected, })} paddingSize="none" @@ -190,6 +195,7 @@ export function SuggestionPanel({ const existsStagedPreview = useLensSelector((state) => Boolean(state.lens.stagedPreview)); const currentVisualization = useLensSelector(selectCurrentVisualization); const currentDatasourceStates = useLensSelector(selectCurrentDatasourceStates); + const changesApplied = useLensSelector(selectChangesApplied); // get user's selection from localStorage, this key defines if the suggestions panel will be hidden or not const [hideSuggestions, setHideSuggestions] = useLocalStorage( LOCAL_STORAGE_SUGGESTIONS_PANEL, @@ -327,9 +333,92 @@ export function SuggestionPanel({ trackSuggestionEvent('back_to_current'); setLastSelectedSuggestion(-1); dispatchLens(rollbackSuggestion()); + dispatchLens(applyChanges()); } } + const applyChangesPrompt = ( + + + +

    + +

    + + dispatchLens(applyChanges())} + data-test-subj="lnsSuggestionApplyChanges" + > + + +
    +
    +
    + ); + + const suggestionsUI = ( + <> + {currentVisualization.activeId && !hideSuggestions && ( + + )} + {!hideSuggestions && + suggestions.map((suggestion, index) => { + return ( + { + trackUiEvent('suggestion_clicked'); + if (lastSelectedSuggestion === index) { + rollbackToCurrentVisualization(); + } else { + setLastSelectedSuggestion(index); + switchToSuggestion(dispatchLens, suggestion, { applyImmediately: true }); + } + }} + selected={index === lastSelectedSuggestion} + /> + ); + })} + + ); + return (
    - {currentVisualization.activeId && !hideSuggestions && ( - - )} - {!hideSuggestions && - suggestions.map((suggestion, index) => { - return ( - { - trackUiEvent('suggestion_clicked'); - if (lastSelectedSuggestion === index) { - rollbackToCurrentVisualization(); - } else { - setLastSelectedSuggestion(index); - switchToSuggestion(dispatchLens, suggestion); - } - }} - selected={index === lastSelectedSuggestion} - /> - ); - })} + {changesApplied ? suggestionsUI : applyChangesPrompt}
    diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx index c325e6d516c8..9288b49824dc 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx @@ -31,6 +31,7 @@ jest.mock('react-virtualized-auto-sizer', () => { import { Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../../types'; import { ChartSwitch } from './chart_switch'; import { PaletteOutput } from 'src/plugins/charts/public'; +import { applyChanges } from '../../../state_management'; describe('chart_switch', () => { function generateVisualization(id: string): jest.Mocked { @@ -189,6 +190,7 @@ describe('chart_switch', () => { clearStagedPreview: true, }, }); + expect(lensStore.dispatch).not.toHaveBeenCalledWith({ type: applyChanges.type }); // should not apply changes automatically }); it('should use initial state if there is no suggestion from the target visualization', async () => { @@ -269,9 +271,9 @@ describe('chart_switch', () => { }, ]); datasourceMap.testDatasource.publicAPIMock.getTableSpec.mockReturnValue([ - { columnId: 'col1' }, - { columnId: 'col2' }, - { columnId: 'col3' }, + { columnId: 'col1', fields: [] }, + { columnId: 'col2', fields: [] }, + { columnId: 'col3', fields: [] }, ]); const { instance } = await mountWithProvider( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index d24ed0a736ae..5c528832ac5b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -132,7 +132,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { ...selection, visualizationState: selection.getVisualizationState(), }, - true + { clearStagedPreview: true } ); if ( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index ccd9e8aace2a..7359f7cdc185 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -35,9 +35,15 @@ import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/publ import { uiActionsPluginMock } from '../../../../../../../src/plugins/ui_actions/public/mocks'; import { TriggerContract } from '../../../../../../../src/plugins/ui_actions/public/triggers'; import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public/embeddable'; -import { LensRootStore, setState } from '../../../state_management'; +import { + applyChanges, + setState, + updateDatasourceState, + updateVisualizationState, +} from '../../../state_management'; import { getLensInspectorService } from '../../../lens_inspector_service'; import { inspectorPluginMock } from '../../../../../../../src/plugins/inspector/public/mocks'; +import { disableAutoApply, enableAutoApply } from '../../../state_management/lens_slice'; const defaultPermissions: Record>> = { navLinks: { management: true }, @@ -102,12 +108,13 @@ describe('workspace_panel', () => { }} ExpressionRenderer={expressionRendererMock} />, - { preloadedState: { visualization: { activeId: null, state: {} }, datasourceStates: {} }, } ); instance = mounted.instance; + instance.update(); + expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2); expect(instance.find(expressionRendererMock)).toHaveLength(0); }); @@ -124,6 +131,7 @@ describe('workspace_panel', () => { { preloadedState: { datasourceStates: {} } } ); instance = mounted.instance; + instance.update(); expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2); expect(instance.find(expressionRendererMock)).toHaveLength(0); @@ -141,6 +149,7 @@ describe('workspace_panel', () => { { preloadedState: { datasourceStates: {} } } ); instance = mounted.instance; + instance.update(); expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2); expect(instance.find(expressionRendererMock)).toHaveLength(0); @@ -170,6 +179,8 @@ describe('workspace_panel', () => { instance = mounted.instance; + instance.update(); + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` "kibana | lens_merge_tables layerIds=\\"first\\" tables={datasource} @@ -177,6 +188,188 @@ describe('workspace_panel', () => { `); }); + it('should give user control when auto-apply disabled', async () => { + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + mockDatasource.toExpression.mockReturnValue('datasource'); + mockDatasource.getLayers.mockReturnValue(['first']); + + const mounted = await mountWithProvider( + 'testVis' }, + }} + ExpressionRenderer={expressionRendererMock} + />, + { + preloadedState: { + autoApplyDisabled: true, + }, + } + ); + + instance = mounted.instance; + instance.update(); + + // allows initial render + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + "kibana + | lens_merge_tables layerIds=\\"first\\" tables={datasource} + | testVis" + `); + + mockDatasource.toExpression.mockReturnValue('new-datasource'); + act(() => { + instance.setProps({ + visualizationMap: { + testVis: { ...mockVisualization, toExpression: () => 'new-vis' }, + }, + }); + }); + + // nothing should change + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + "kibana + | lens_merge_tables layerIds=\\"first\\" tables={datasource} + | testVis" + `); + + act(() => { + mounted.lensStore.dispatch(applyChanges()); + }); + instance.update(); + + // should update + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + "kibana + | lens_merge_tables layerIds=\\"first\\" tables={new-datasource} + | new-vis" + `); + + mockDatasource.toExpression.mockReturnValue('other-new-datasource'); + act(() => { + instance.setProps({ + visualizationMap: { + testVis: { ...mockVisualization, toExpression: () => 'other-new-vis' }, + }, + }); + }); + + // should not update + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + "kibana + | lens_merge_tables layerIds=\\"first\\" tables={new-datasource} + | new-vis" + `); + + act(() => { + mounted.lensStore.dispatch(enableAutoApply()); + }); + instance.update(); + + // reenabling auto-apply triggers an update as well + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + "kibana + | lens_merge_tables layerIds=\\"first\\" tables={other-new-datasource} + | other-new-vis" + `); + }); + + it('should base saveability on working changes when auto-apply disabled', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockVisualization.getErrorMessages.mockImplementation((currentVisualizationState: any) => { + if (currentVisualizationState.hasProblem) { + return [{ shortMessage: 'An error occurred', longMessage: 'An long description here' }]; + } else { + return []; + } + }); + + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + mockDatasource.toExpression.mockReturnValue('datasource'); + mockDatasource.getLayers.mockReturnValue(['first']); + + const mounted = await mountWithProvider( + 'testVis' }, + }} + ExpressionRenderer={expressionRendererMock} + /> + ); + + instance = mounted.instance; + const isSaveable = () => mounted.lensStore.getState().lens.isSaveable; + + instance.update(); + + // allows initial render + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + "kibana + | lens_merge_tables layerIds=\\"first\\" tables={datasource} + | testVis" + `); + expect(isSaveable()).toBe(true); + + act(() => { + mounted.lensStore.dispatch( + updateVisualizationState({ + visualizationId: 'testVis', + newState: { activeId: 'testVis', hasProblem: true }, + }) + ); + }); + instance.update(); + + expect(isSaveable()).toBe(false); + }); + + it('should allow empty workspace as initial render when auto-apply disabled', async () => { + mockVisualization.toExpression.mockReturnValue('testVis'); + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + + const mounted = await mountWithProvider( + , + { + preloadedState: { + autoApplyDisabled: true, + }, + } + ); + + instance = mounted.instance; + instance.update(); + + expect(instance.exists('[data-test-subj="empty-workspace"]')).toBeTruthy(); + }); + it('should execute a trigger on expression event', async () => { const framePublicAPI = createMockFramePublicAPI(); framePublicAPI.datasourceLayers = { @@ -289,6 +482,7 @@ describe('workspace_panel', () => { } ); instance = mounted.instance; + instance.update(); const ast = fromExpression(instance.find(expressionRendererMock).prop('expression') as string); @@ -342,27 +536,25 @@ describe('workspace_panel', () => { expressionRendererMock = jest.fn((_arg) => ); - await act(async () => { - const mounted = await mountWithProvider( - 'testVis' }, - }} - ExpressionRenderer={expressionRendererMock} - /> - ); - instance = mounted.instance; - }); + const mounted = await mountWithProvider( + 'testVis' }, + }} + ExpressionRenderer={expressionRendererMock} + /> + ); + instance = mounted.instance; instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(1); + expect(expressionRendererMock).toHaveBeenCalledTimes(2); - await act(async () => { + act(() => { instance.setProps({ framePublicAPI: { ...framePublicAPI, @@ -373,7 +565,7 @@ describe('workspace_panel', () => { instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(2); + expect(expressionRendererMock).toHaveBeenCalledTimes(3); }); it('should run the expression again if the filters change', async () => { @@ -388,31 +580,29 @@ describe('workspace_panel', () => { .mockReturnValueOnce('datasource second'); expressionRendererMock = jest.fn((_arg) => ); - await act(async () => { - const mounted = await mountWithProvider( - 'testVis' }, - }} - ExpressionRenderer={expressionRendererMock} - /> - ); - instance = mounted.instance; - }); + const mounted = await mountWithProvider( + 'testVis' }, + }} + ExpressionRenderer={expressionRendererMock} + /> + ); + instance = mounted.instance; instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(1); + expect(expressionRendererMock).toHaveBeenCalledTimes(2); const indexPattern = { id: 'index1' } as unknown as IndexPattern; const field = { name: 'myfield' } as unknown as FieldSpec; - await act(async () => { + act(() => { instance.setProps({ framePublicAPI: { ...framePublicAPI, @@ -423,7 +613,7 @@ describe('workspace_panel', () => { instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(2); + expect(expressionRendererMock).toHaveBeenCalledTimes(3); }); it('should show an error message if there are missing indexpatterns in the visualization', async () => { @@ -572,6 +762,9 @@ describe('workspace_panel', () => { /> ); instance = mounted.instance; + act(() => { + instance.update(); + }); expect(instance.find('[data-test-subj="configuration-failure"]').exists()).toBeTruthy(); expect(instance.find(expressionRendererMock)).toHaveLength(0); @@ -642,6 +835,97 @@ describe('workspace_panel', () => { expect(instance.find(expressionRendererMock)).toHaveLength(0); }); + it('should NOT display errors for unapplied changes', async () => { + // this test is important since we don't want the workspace panel to + // display errors if the user has disabled auto-apply, messed something up, + // but not yet applied their changes + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockDatasource.getErrorMessages.mockImplementation((currentDatasourceState: any) => { + if (currentDatasourceState.hasProblem) { + return [{ shortMessage: 'An error occurred', longMessage: 'An long description here' }]; + } else { + return []; + } + }); + mockDatasource.getLayers.mockReturnValue(['first']); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockVisualization.getErrorMessages.mockImplementation((currentVisualizationState: any) => { + if (currentVisualizationState.hasProblem) { + return [{ shortMessage: 'An error occurred', longMessage: 'An long description here' }]; + } else { + return []; + } + }); + mockVisualization.toExpression.mockReturnValue('testVis'); + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + + const mounted = await mountWithProvider( + + ); + + instance = mounted.instance; + const lensStore = mounted.lensStore; + + const showingErrors = () => + instance.exists('[data-test-subj="configuration-failure-error"]') || + instance.exists('[data-test-subj="configuration-failure-more-errors"]'); + + expect(showingErrors()).toBeFalsy(); + + act(() => { + lensStore.dispatch(disableAutoApply()); + }); + instance.update(); + + expect(showingErrors()).toBeFalsy(); + + // introduce some issues + act(() => { + lensStore.dispatch( + updateDatasourceState({ + datasourceId: 'testDatasource', + updater: { hasProblem: true }, + }) + ); + }); + instance.update(); + + expect(showingErrors()).toBeFalsy(); + + act(() => { + lensStore.dispatch( + updateVisualizationState({ + visualizationId: 'testVis', + newState: { activeId: 'testVis', hasProblem: true }, + }) + ); + }); + instance.update(); + + expect(showingErrors()).toBeFalsy(); + + // errors should appear when problem changes are applied + act(() => { + lensStore.dispatch(applyChanges()); + }); + instance.update(); + + expect(showingErrors()).toBeTruthy(); + }); + it('should show an error message if the expression fails to parse', async () => { mockDatasource.toExpression.mockReturnValue('|||'); mockDatasource.getLayers.mockReturnValue(['first']); @@ -676,30 +960,27 @@ describe('workspace_panel', () => { first: mockDatasource.publicAPIMock, }; - await act(async () => { - const mounted = await mountWithProvider( - 'testVis' }, - }} - ExpressionRenderer={expressionRendererMock} - /> - ); - instance = mounted.instance; - }); - + const mounted = await mountWithProvider( + 'testVis' }, + }} + ExpressionRenderer={expressionRendererMock} + /> + ); + instance = mounted.instance; instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(1); + expect(expressionRendererMock).toHaveBeenCalledTimes(2); instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(1); + expect(expressionRendererMock).toHaveBeenCalledTimes(2); }); it('should attempt to run the expression again if it changes', async () => { @@ -709,28 +990,25 @@ describe('workspace_panel', () => { framePublicAPI.datasourceLayers = { first: mockDatasource.publicAPIMock, }; - let lensStore: LensRootStore; - await act(async () => { - const mounted = await mountWithProvider( - 'testVis' }, - }} - ExpressionRenderer={expressionRendererMock} - /> - ); - instance = mounted.instance; - lensStore = mounted.lensStore; - }); + const mounted = await mountWithProvider( + 'testVis' }, + }} + ExpressionRenderer={expressionRendererMock} + /> + ); + instance = mounted.instance; + const lensStore = mounted.lensStore; instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(1); + expect(expressionRendererMock).toHaveBeenCalledTimes(2); expressionRendererMock.mockImplementation((_) => { return ; @@ -746,7 +1024,7 @@ describe('workspace_panel', () => { ); instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(2); + expect(expressionRendererMock).toHaveBeenCalledTimes(3); expect(instance.find(expressionRendererMock)).toHaveLength(1); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index a26d72f1b4fc..81fef751ae46 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useEffect, useMemo, useContext, useCallback } from 'react'; +import React, { useState, useEffect, useMemo, useContext, useCallback, useRef } from 'react'; import classNames from 'classnames'; import { FormattedMessage } from '@kbn/i18n-react'; import { toExpression } from '@kbn/interpreter'; @@ -66,9 +66,12 @@ import { selectDatasourceStates, selectActiveDatasourceId, selectSearchSessionId, + selectAutoApplyEnabled, + selectTriggerApplyChanges, } from '../../../state_management'; import type { LensInspector } from '../../../lens_inspector_service'; import { inferTimeField } from '../../../utils'; +import { setChangesApplied } from '../../../state_management/lens_slice'; export interface WorkspacePanelProps { visualizationMap: VisualizationMap; @@ -88,6 +91,7 @@ interface WorkspaceState { fixAction?: DatasourceFixAction; }>; expandError: boolean; + expressionToRender: string | null | undefined; } const dropProps = { @@ -136,13 +140,22 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ const visualization = useLensSelector(selectVisualization); const activeDatasourceId = useLensSelector(selectActiveDatasourceId); const datasourceStates = useLensSelector(selectDatasourceStates); + const autoApplyEnabled = useLensSelector(selectAutoApplyEnabled); + const triggerApply = useLensSelector(selectTriggerApplyChanges); - const { datasourceLayers } = framePublicAPI; const [localState, setLocalState] = useState({ expressionBuildError: undefined, expandError: false, + expressionToRender: undefined, }); + // const expressionToRender = useRef(); + const initialRenderComplete = useRef(); + + const shouldApplyExpression = autoApplyEnabled || !initialRenderComplete.current || triggerApply; + + const { datasourceLayers } = framePublicAPI; + const activeVisualization = visualization.activeId ? visualizationMap[visualization.activeId] : null; @@ -186,7 +199,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ [activeVisualization, visualization.state, activeDatasourceId, datasourceMap, datasourceStates] ); - const expression = useMemo(() => { + const _expression = useMemo(() => { if (!configurationValidationError?.length && !missingRefsErrors.length && !unknownVisError) { try { const ast = buildExpression({ @@ -238,10 +251,32 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ visualization.activeId, ]); - const expressionExists = Boolean(expression); useEffect(() => { - dispatchLens(setSaveable(expressionExists)); - }, [expressionExists, dispatchLens]); + dispatchLens(setSaveable(Boolean(_expression))); + }, [_expression, dispatchLens]); + + useEffect(() => { + if (!autoApplyEnabled) { + dispatchLens(setChangesApplied(_expression === localState.expressionToRender)); + } + }); + + useEffect(() => { + if (shouldApplyExpression) { + setLocalState((s) => ({ ...s, expressionToRender: _expression })); + } + }, [_expression, shouldApplyExpression]); + + const expressionExists = Boolean(localState.expressionToRender); + useEffect(() => { + // null signals an empty workspace which should count as an initial render + if ( + (expressionExists || localState.expressionToRender === null) && + !initialRenderComplete.current + ) { + initialRenderComplete.current = true; + } + }, [expressionExists, localState.expressionToRender]); const onEvent = useCallback( (event: ExpressionRendererEvent) => { @@ -291,7 +326,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ if (suggestionForDraggedField) { trackUiEvent('drop_onto_workspace'); trackUiEvent(expressionExists ? 'drop_non_empty' : 'drop_empty'); - switchToSuggestion(dispatchLens, suggestionForDraggedField, true); + switchToSuggestion(dispatchLens, suggestionForDraggedField, { clearStagedPreview: true }); } }, [suggestionForDraggedField, expressionExists, dispatchLens]); @@ -343,12 +378,12 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ }; const renderVisualization = () => { - if (expression === null) { + if (localState.expressionToRender === null) { return renderEmptyWorkspace(); } return ( { @@ -381,7 +414,6 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ ) : ( - {element} + {renderVisualization()} ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss index 9a87f1ba46e9..9b4502ea8194 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss @@ -32,6 +32,7 @@ &.lnsWorkspacePanelWrapper--fullscreen { margin-bottom: 0; } + } .lnsWorkspacePanel__dragDrop { @@ -80,6 +81,14 @@ .lnsWorkspacePanelWrapper__toolbar { margin-bottom: 0; + + &.lnsWorkspacePanelWrapper__toolbar--fullscreen { + padding: $euiSizeS $euiSizeS 0 $euiSizeS; + } + + & > .euiFlexItem { + min-height: $euiButtonHeightSmall; + } } .lnsDropIllustration__adjustFill { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx index fb77ff75324f..3aab4d6e7d85 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx @@ -10,6 +10,14 @@ import { Visualization } from '../../../types'; import { createMockVisualization, createMockFramePublicAPI, FrameMock } from '../../../mocks'; import { WorkspacePanelWrapper } from './workspace_panel_wrapper'; import { mountWithProvider } from '../../../mocks'; +import { ReactWrapper } from 'enzyme'; +import { + selectAutoApplyEnabled, + updateVisualizationState, + disableAutoApply, + selectTriggerApplyChanges, +} from '../../../state_management'; +import { setChangesApplied } from '../../../state_management/lens_slice'; describe('workspace_panel_wrapper', () => { let mockVisualization: jest.Mocked; @@ -61,4 +69,144 @@ describe('workspace_panel_wrapper', () => { setState: expect.anything(), }); }); + + describe('auto-apply controls', () => { + class Harness { + private _instance: ReactWrapper; + + constructor(instance: ReactWrapper) { + this._instance = instance; + } + + update() { + this._instance.update(); + } + + private get applyChangesButton() { + return this._instance.find('button[data-test-subj="lensApplyChanges"]'); + } + + private get autoApplyToggleSwitch() { + return this._instance.find('button[data-test-subj="lensToggleAutoApply"]'); + } + + toggleAutoApply() { + this.autoApplyToggleSwitch.simulate('click'); + } + + public get autoApplySwitchOn() { + return this.autoApplyToggleSwitch.prop('aria-checked'); + } + + applyChanges() { + this.applyChangesButton.simulate('click'); + } + + public get applyChangesExists() { + return this.applyChangesButton.exists(); + } + + public get applyChangesDisabled() { + if (!this.applyChangesExists) { + throw Error('apply changes button doesnt exist'); + } + return this.applyChangesButton.prop('disabled'); + } + } + + let store: Awaited>['lensStore']; + let harness: Harness; + beforeEach(async () => { + const { instance, lensStore } = await mountWithProvider( + +
    + + ); + + store = lensStore; + harness = new Harness(instance); + }); + + it('toggles auto-apply', async () => { + store.dispatch(disableAutoApply()); + harness.update(); + + expect(selectAutoApplyEnabled(store.getState())).toBeFalsy(); + expect(harness.autoApplySwitchOn).toBeFalsy(); + expect(harness.applyChangesExists).toBeTruthy(); + + harness.toggleAutoApply(); + + expect(selectAutoApplyEnabled(store.getState())).toBeTruthy(); + expect(harness.autoApplySwitchOn).toBeTruthy(); + expect(harness.applyChangesExists).toBeFalsy(); + + harness.toggleAutoApply(); + + expect(selectAutoApplyEnabled(store.getState())).toBeFalsy(); + expect(harness.autoApplySwitchOn).toBeFalsy(); + expect(harness.applyChangesExists).toBeTruthy(); + }); + + it('apply-changes button works', () => { + store.dispatch(disableAutoApply()); + harness.update(); + + expect(selectAutoApplyEnabled(store.getState())).toBeFalsy(); + expect(harness.applyChangesDisabled).toBeTruthy(); + + // make a change + store.dispatch( + updateVisualizationState({ + visualizationId: store.getState().lens.visualization.activeId as string, + newState: { something: 'changed' }, + }) + ); + // simulate workspace panel behavior + store.dispatch(setChangesApplied(false)); + harness.update(); + + expect(harness.applyChangesDisabled).toBeFalsy(); + + harness.applyChanges(); + + expect(selectTriggerApplyChanges(store.getState())).toBeTruthy(); + // simulate workspace panel behavior + store.dispatch(setChangesApplied(true)); + harness.update(); + + expect(harness.applyChangesDisabled).toBeTruthy(); + }); + + it('enabling auto apply while having unapplied changes works', () => { + // setup + store.dispatch(disableAutoApply()); + store.dispatch( + updateVisualizationState({ + visualizationId: store.getState().lens.visualization.activeId as string, + newState: { something: 'changed' }, + }) + ); + store.dispatch(setChangesApplied(false)); // simulate workspace panel behavior + harness.update(); + + expect(harness.applyChangesDisabled).toBeFalsy(); + expect(harness.autoApplySwitchOn).toBeFalsy(); + expect(harness.applyChangesExists).toBeTruthy(); + + // enable auto apply + harness.toggleAutoApply(); + + expect(harness.autoApplySwitchOn).toBeTruthy(); + expect(harness.applyChangesExists).toBeFalsy(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index be2306348861..ab7dad2cb5fe 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -8,8 +8,12 @@ import './workspace_panel_wrapper.scss'; import React, { useCallback } from 'react'; -import { EuiPageContent, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiPageContent, EuiFlexGroup, EuiFlexItem, EuiSwitch, EuiButton } from '@elastic/eui'; import classNames from 'classnames'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { trackUiEvent } from '../../../lens_ui_telemetry'; +import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; import { DatasourceMap, FramePublicAPI, VisualizationMap } from '../../../types'; import { NativeRenderer } from '../../../native_renderer'; import { ChartSwitch } from './chart_switch'; @@ -20,8 +24,18 @@ import { DatasourceStates, VisualizationState, updateDatasourceState, + useLensSelector, + selectChangesApplied, + applyChanges, + enableAutoApply, + disableAutoApply, + selectAutoApplyEnabled, } from '../../../state_management'; import { WorkspaceTitle } from './title'; +import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from '../config_panel/dimension_container'; +import { writeToStorage } from '../../../settings_storage'; + +export const AUTO_APPLY_DISABLED_STORAGE_KEY = 'autoApplyDisabled'; export interface WorkspacePanelWrapperProps { children: React.ReactNode | React.ReactNode[]; @@ -46,6 +60,9 @@ export function WorkspacePanelWrapper({ }: WorkspacePanelWrapperProps) { const dispatchLens = useLensDispatch(); + const changesApplied = useLensSelector(selectChangesApplied); + const autoApplyEnabled = useLensSelector(selectAutoApplyEnabled); + const activeVisualization = visualizationId ? visualizationMap[visualizationId] : null; const setVisualizationState = useCallback( (newState: unknown) => { @@ -72,6 +89,18 @@ export function WorkspacePanelWrapper({ }, [dispatchLens] ); + + const toggleAutoApply = useCallback(() => { + trackUiEvent('toggle_autoapply'); + + writeToStorage( + new Storage(localStorage), + AUTO_APPLY_DISABLED_STORAGE_KEY, + String(autoApplyEnabled) + ); + dispatchLens(autoApplyEnabled ? disableAutoApply() : enableAutoApply()); + }, [dispatchLens, autoApplyEnabled]); + const warningMessages: React.ReactNode[] = []; if (activeVisualization?.getWarningMessages) { warningMessages.push( @@ -93,44 +122,92 @@ export function WorkspacePanelWrapper({
    - {!isFullscreen ? ( - - + + + {!isFullscreen && ( - + + + + + {activeVisualization && activeVisualization.renderToolbar && ( + + + + )} + - {activeVisualization && activeVisualization.renderToolbar && ( + )} + + - - )} - - - ) : null} + {!autoApplyEnabled && ( + +
    + dispatchLens(applyChanges())} + size="s" + data-test-subj="lensApplyChanges" + > + + +
    +
    + )} +
    +
    +
    +
    {warningMessages && warningMessages.length ? ( {warningMessages} diff --git a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx index 482a5b931ed7..802e1f2b3821 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx @@ -11,6 +11,7 @@ import type { Action, UiActionsStart } from 'src/plugins/ui_actions/public'; import type { Start as InspectorStartContract } from 'src/plugins/inspector/public'; import { EuiLoadingChart } from '@elastic/eui'; import { + EmbeddableFactory, EmbeddableInput, EmbeddableOutput, EmbeddablePanel, @@ -23,8 +24,7 @@ import type { LensByReferenceInput, LensByValueInput } from './embeddable'; import type { Document } from '../persistence'; import type { IndexPatternPersistedState } from '../indexpattern_datasource/types'; import type { XYState } from '../xy_visualization/types'; -import type { MetricState } from '../../common/expressions'; -import type { PieVisualizationState } from '../../common'; +import type { PieVisualizationState, MetricState } from '../../common'; import type { DatatableVisualizationState } from '../datatable_visualization/visualization'; import type { HeatmapVisualizationState } from '../heatmap_visualization/types'; import type { GaugeVisualizationState } from '../visualizations/gauge/constants'; @@ -69,41 +69,48 @@ interface PluginsStartDependencies { } export function getEmbeddableComponent(core: CoreStart, plugins: PluginsStartDependencies) { + const { embeddable: embeddableStart, uiActions, inspector } = plugins; + const factory = embeddableStart.getEmbeddableFactory('lens')!; + const theme = core.theme; return (props: EmbeddableComponentProps) => { - const { embeddable: embeddableStart, uiActions, inspector } = plugins; - const factory = embeddableStart.getEmbeddableFactory('lens')!; const input = { ...props }; - const [embeddable, loading, error] = useEmbeddableFactory({ factory, input }); const hasActions = - Boolean(props.withDefaultActions) || (props.extraActions && props.extraActions?.length > 0); + Boolean(input.withDefaultActions) || (input.extraActions && input.extraActions?.length > 0); - const theme = core.theme; - - if (loading) { - return ; - } - - if (embeddable && hasActions) { + if (hasActions) { return ( } + factory={factory} uiActions={uiActions} inspector={inspector} actionPredicate={() => hasActions} input={input} theme={theme} - extraActions={props.extraActions} - withDefaultActions={props.withDefaultActions} + extraActions={input.extraActions} + withDefaultActions={input.withDefaultActions} /> ); } - - return ; + return ; }; } +function EmbeddableRootWrapper({ + factory, + input, +}: { + factory: EmbeddableFactory; + input: EmbeddableComponentProps; +}) { + const [embeddable, loading, error] = useEmbeddableFactory({ factory, input }); + if (loading) { + return ; + } + return ; +} + interface EmbeddablePanelWrapperProps { - embeddable: IEmbeddable; + factory: EmbeddableFactory; uiActions: PluginsStartDependencies['uiActions']; inspector: PluginsStartDependencies['inspector']; actionPredicate: (id: string) => boolean; @@ -114,7 +121,7 @@ interface EmbeddablePanelWrapperProps { } const EmbeddablePanelWrapper: FC = ({ - embeddable, + factory, uiActions, actionPredicate, inspector, @@ -123,10 +130,17 @@ const EmbeddablePanelWrapper: FC = ({ extraActions, withDefaultActions, }) => { + const [embeddable, loading] = useEmbeddableFactory({ factory, input }); useEffect(() => { - embeddable.updateInput(input); + if (embeddable) { + embeddable.updateInput(input); + } }, [embeddable, input]); + if (loading || !embeddable) { + return ; + } + return ( { + setState({ + ...state, + legend: { + ...state.legend, + legendSize, + }, + }); + }} />
    diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts index a08b12ca9ae6..f181167cd8d9 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts @@ -20,7 +20,7 @@ import { } from './constants'; import { Position } from '@elastic/charts'; import type { HeatmapVisualizationState } from './types'; -import type { DatasourcePublicAPI, Operation } from '../types'; +import type { DatasourcePublicAPI, OperationDescriptor } from '../types'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; import { layerTypes } from '../../common'; import { themeServiceMock } from '../../../../../src/core/public/mocks'; @@ -99,7 +99,7 @@ describe('heatmap', () => { mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ dataType: 'string', label: 'MyOperation', - } as Operation); + } as OperationDescriptor); frame.datasourceLayers = { first: mockDatasource.publicAPIMock, @@ -363,7 +363,7 @@ describe('heatmap', () => { mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ dataType: 'string', label: 'MyOperation', - } as Operation); + } as OperationDescriptor); datasourceLayers = { first: mockDatasource.publicAPIMock, @@ -418,6 +418,7 @@ describe('heatmap', () => { arguments: { isVisible: [true], position: [Position.Right], + legendSize: [], }, }, ], @@ -483,7 +484,7 @@ describe('heatmap', () => { mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ dataType: 'string', label: 'MyOperation', - } as Operation); + } as OperationDescriptor); datasourceLayers = { first: mockDatasource.publicAPIMock, @@ -612,7 +613,7 @@ describe('heatmap', () => { mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ dataType: 'string', label: 'MyOperation', - } as Operation); + } as OperationDescriptor); frame.datasourceLayers = { first: mockDatasource.publicAPIMock, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index 5eebcc0fd6a9..f8c641e4395e 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -340,6 +340,7 @@ export const getHeatmapVisualization = ({ arguments: { isVisible: [state.legend.isVisible], position: [state.legend.position], + legendSize: state.legend.legendSize ? [state.legend.legendSize] : [], }, }, ], diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index f6ccb071075a..e9a458f6e3a2 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -14,7 +14,6 @@ export type { export type { XYState } from './xy_visualization/types'; export type { DataType, OperationMetadata, Visualization } from './types'; export type { - MetricState, AxesSettingsConfig, XYLayerConfig, LegendConfig, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 199131564f7c..d8b5874050b2 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -52,7 +52,7 @@ export type Props = Omit, 'co changeIndexPattern: ( id: string, state: IndexPatternPrivateState, - setState: StateSetter + setState: StateSetter ) => void; charts: ChartsPluginSetup; core: CoreStart; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index ae73d61de74b..70baa1d772e1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -216,7 +216,12 @@ describe('IndexPatternDimensionEditorPanel', () => { deserialize: jest.fn().mockReturnValue({ convert: () => 'formatted', }), - } as unknown as DataPublicPluginStart['fieldFormats'], + }, + search: { + aggs: { + calculateAutoTimeExpression: jest.fn(), + }, + }, } as unknown as DataPublicPluginStart, core: {} as CoreSetup, dimensionGroups: [], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts index d85cbd438ffe..f3c48bace4a5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts @@ -134,7 +134,7 @@ function getDropPropsForField({ const isTheSameIndexPattern = state.layers[layerId].indexPatternId === dragging.indexPatternId; const newOperation = getNewOperation(dragging.field, filterOperations, targetColumn); - if (!!(isTheSameIndexPattern && newOperation)) { + if (isTheSameIndexPattern && newOperation) { const nextLabel = operationLabels[newOperation].displayName; if (!targetColumn) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx index f775026d5492..931e7f364fa2 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx @@ -106,9 +106,7 @@ export function FieldSelect({ exists, compatible, className: classNames({ - // eslint-disable-next-line @typescript-eslint/naming-convention 'lnFieldSelect__option--incompatible': !compatible, - // eslint-disable-next-line @typescript-eslint/naming-convention 'lnFieldSelect__option--nonExistant': !exists, }), 'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${field}`, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx index 11e9110171f4..ffa2dd7b7d2f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx @@ -4,7 +4,7 @@ * 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 React, { useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { isEqual } from 'lodash'; import { @@ -23,6 +23,7 @@ import { GenericIndexPatternColumn, operationDefinitionMap } from '../operations import { validateQuery } from '../operations/definitions/filters'; import { QueryInput } from '../query_input'; import type { IndexPattern, IndexPatternLayer } from '../types'; +import { useDebouncedValue } from '../../shared_components'; const filterByLabel = i18n.translate('xpack.lens.indexPattern.filterBy.label', { defaultMessage: 'Filter by', @@ -65,23 +66,27 @@ export function Filtering({ helpMessage: string | null; }) { const inputFilter = selectedColumn.filter; - const [queryInput, setQueryInput] = useState(inputFilter ?? defaultFilter); + const onChange = useCallback( + (query) => { + const { isValid } = validateQuery(query, indexPattern); + if (isValid && !isEqual(inputFilter, query)) { + updateLayer(setFilter(columnId, layer, query)); + } + }, + [columnId, indexPattern, inputFilter, layer, updateLayer] + ); + const { inputValue: queryInput, handleInputChange: setQueryInput } = useDebouncedValue({ + value: inputFilter ?? defaultFilter, + onChange, + }); const [filterPopoverOpen, setFilterPopoverOpen] = useState(isInitiallyOpen); - useEffect(() => { - const { isValid } = validateQuery(queryInput, indexPattern); - - if (isValid && !isEqual(inputFilter, queryInput)) { - updateLayer(setFilter(columnId, layer, queryInput)); - } - }, [columnId, layer, queryInput, indexPattern, updateLayer, inputFilter]); - const onClosePopup: EuiPopoverProps['closePopover'] = useCallback(() => { setFilterPopoverOpen(false); if (inputFilter) { setQueryInput(inputFilter); } - }, [inputFilter]); + }, [inputFilter, setQueryInput]); const selectedOperation = operationDefinitionMap[selectedColumn.operationType]; @@ -166,10 +171,10 @@ export function Filtering({ isInvalid={!isQueryInputValid} error={queryInputError} fullWidth={true} + data-test-subj="indexPattern-filter-by-input" > { describe('getTableSpec', () => { it('should include col1', () => { - expect(publicAPI.getTableSpec()).toEqual([{ columnId: 'col1' }]); + expect(publicAPI.getTableSpec()).toEqual([expect.objectContaining({ columnId: 'col1' })]); + }); + + it('should include fields prop for each column', () => { + expect(publicAPI.getTableSpec()).toEqual([expect.objectContaining({ fields: ['op'] })]); }); it('should skip columns that are being referenced', () => { @@ -1252,7 +1258,98 @@ describe('IndexPattern Data Source', () => { layerId: 'first', }); - expect(publicAPI.getTableSpec()).toEqual([{ columnId: 'col2' }]); + expect(publicAPI.getTableSpec()).toEqual([expect.objectContaining({ columnId: 'col2' })]); + }); + + it('should collect all fields (also from referenced columns)', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Sum', + dataType: 'number', + isBucketed: false, + + operationType: 'sum', + sourceField: 'test', + params: {}, + } as GenericIndexPatternColumn, + col2: { + label: 'Cumulative sum', + dataType: 'number', + isBucketed: false, + + operationType: 'cumulative_sum', + references: ['col1'], + params: {}, + } as GenericIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + // The cumulative sum column has no field, but it references a sum column (hidden) which has it + // The getTableSpec() should walk the reference tree and assign all fields to the root column + expect(publicAPI.getTableSpec()).toEqual([{ columnId: 'col2', fields: ['test'] }]); + }); + + it('should collect and organize fields per visible column', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'Sum', + dataType: 'number', + isBucketed: false, + + operationType: 'sum', + sourceField: 'test', + params: {}, + } as GenericIndexPatternColumn, + col2: { + label: 'Cumulative sum', + dataType: 'number', + isBucketed: false, + + operationType: 'cumulative_sum', + references: ['col1'], + params: {}, + } as GenericIndexPatternColumn, + col3: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'op', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + } as TermsIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + + // col1 is skipped as referenced but its field gets inherited by col2 + expect(publicAPI.getTableSpec()).toEqual([ + { columnId: 'col2', fields: ['test'] }, + { columnId: 'col3', fields: ['op'] }, + ]); }); }); @@ -1263,7 +1360,8 @@ describe('IndexPattern Data Source', () => { dataType: 'string', isBucketed: true, isStaticValue: false, - } as Operation); + hasTimeShift: false, + } as OperationDescriptor); }); it('should return null for non-existant columns', () => { @@ -1306,6 +1404,752 @@ describe('IndexPattern Data Source', () => { expect(publicAPI.getOperationForColumnId('col1')).toEqual(null); }); }); + + describe('getSourceId', () => { + it('should basically return the datasource internal id', () => { + expect(publicAPI.getSourceId()).toEqual('1'); + }); + }); + + describe('getFilters', () => { + it('should return all filters in metrics, grouped by language', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Sum', + dataType: 'number', + isBucketed: false, + operationType: 'sum', + sourceField: 'test', + params: {}, + filter: { language: 'kuery', query: 'bytes > 1000' }, + } as GenericIndexPatternColumn, + col2: { + label: 'Sum', + dataType: 'number', + isBucketed: false, + operationType: 'sum', + sourceField: 'test', + params: {}, + filter: { language: 'lucene', query: 'memory' }, + } as GenericIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + expect(publicAPI.getFilters()).toEqual({ + kuery: [[{ language: 'kuery', query: 'bytes > 1000' }]], + lucene: [[{ language: 'lucene', query: 'memory' }]], + }); + }); + it('should ignore empty filtered metrics', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Sum', + dataType: 'number', + isBucketed: false, + operationType: 'sum', + sourceField: 'test', + params: {}, + filter: { language: 'kuery', query: '' }, + } as GenericIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + expect(publicAPI.getFilters()).toEqual({ kuery: [], lucene: [] }); + }); + it('shuold collect top values fields as kuery existence filters if no data is provided', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Terms', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'geo.src', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 10, + }, + } as TermsIndexPatternColumn, + col2: { + label: 'Terms', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'geo.dest', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 10, + secondaryFields: ['myField'], + }, + } as TermsIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + expect(publicAPI.getFilters()).toEqual({ + kuery: [ + [{ language: 'kuery', query: 'geo.src: *' }], + [ + { language: 'kuery', query: 'geo.dest: *' }, + { language: 'kuery', query: 'myField: *' }, + ], + ], + lucene: [], + }); + }); + it('shuold collect top values fields and terms as kuery filters if data is provided', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Terms', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'geo.src', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 10, + }, + } as TermsIndexPatternColumn, + col2: { + label: 'Terms', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'geo.dest', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 10, + secondaryFields: ['myField'], + }, + } as TermsIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + const data = { + first: { + type: 'datatable' as const, + columns: [ + { id: 'col1', name: 'geo.src', meta: { type: 'string' as const } }, + { id: 'col2', name: 'geo.dest > myField', meta: { type: 'string' as const } }, + ], + rows: [ + { col1: 'US', col2: { keys: ['IT', 'MyValue'] } }, + { col1: 'IN', col2: { keys: ['DE', 'MyOtherValue'] } }, + ], + }, + }; + expect(publicAPI.getFilters(data)).toEqual({ + kuery: [ + [ + { language: 'kuery', query: 'geo.src: "US"' }, + { language: 'kuery', query: 'geo.src: "IN"' }, + ], + [ + { language: 'kuery', query: 'geo.dest: "IT" AND myField: "MyValue"' }, + { language: 'kuery', query: 'geo.dest: "DE" AND myField: "MyOtherValue"' }, + ], + ], + lucene: [], + }); + }); + it('shuold collect top values fields and terms and carefully handle empty string values', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Terms', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'geo.src', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 10, + }, + } as TermsIndexPatternColumn, + col2: { + label: 'Terms', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'geo.dest', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 10, + secondaryFields: ['myField'], + }, + } as TermsIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + const data = { + first: { + type: 'datatable' as const, + columns: [ + { id: 'col1', name: 'geo.src', meta: { type: 'string' as const } }, + { id: 'col2', name: 'geo.dest > myField', meta: { type: 'string' as const } }, + ], + rows: [ + { col1: 'US', col2: { keys: ['IT', ''] } }, + { col1: 'IN', col2: { keys: ['DE', 'MyOtherValue'] } }, + ], + }, + }; + expect(publicAPI.getFilters(data)).toEqual({ + kuery: [ + [ + { language: 'kuery', query: 'geo.src: "US"' }, + { language: 'kuery', query: 'geo.src: "IN"' }, + ], + [ + { language: 'kuery', query: `geo.dest: "IT" AND myField: ""` }, + { language: 'kuery', query: `geo.dest: "DE" AND myField: "MyOtherValue"` }, + ], + ], + lucene: [], + }); + }); + it('should ignore top values fields if other/missing option is enabled', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Terms', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'geo.src', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 10, + otherBucket: true, + }, + } as TermsIndexPatternColumn, + col2: { + label: 'Terms', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'geo.src', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 10, + missingBucket: true, + }, + } as TermsIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + expect(publicAPI.getFilters()).toEqual({ kuery: [], lucene: [] }); + }); + it('should collect custom ranges as kuery filters', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Single range', + dataType: 'number', + isBucketed: true, + operationType: 'range', + sourceField: 'bytes', + params: { + type: 'range', + ranges: [{ from: 100, to: 150, label: 'Range 1' }], + }, + } as RangeIndexPatternColumn, + col2: { + label: 'Multiple ranges', + dataType: 'number', + isBucketed: true, + operationType: 'range', + sourceField: 'bytes', + params: { + type: 'range', + ranges: [ + { from: 200, to: 300, label: 'Range 2' }, + { from: 300, to: 400, label: 'Range 3' }, + ], + }, + } as RangeIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + expect(publicAPI.getFilters()).toEqual({ + kuery: [ + [{ language: 'kuery', query: 'bytes >= 100 AND bytes <= 150' }], + [ + { language: 'kuery', query: 'bytes >= 200 AND bytes <= 300' }, + { language: 'kuery', query: 'bytes >= 300 AND bytes <= 400' }, + ], + ], + lucene: [], + }); + }); + it('should collect custom ranges as kuery filters as partial', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'Empty range', + dataType: 'number', + isBucketed: true, + operationType: 'range', + sourceField: 'bytes', + params: { + type: 'range', + ranges: [{ label: 'Empty range' }], + }, + } as RangeIndexPatternColumn, + col2: { + label: 'From range', + dataType: 'number', + isBucketed: true, + operationType: 'range', + sourceField: 'bytes', + params: { + type: 'range', + ranges: [{ from: 100, label: 'Partial range 1' }], + }, + } as RangeIndexPatternColumn, + col3: { + label: 'To ranges', + dataType: 'number', + isBucketed: true, + operationType: 'range', + sourceField: 'bytes', + params: { + type: 'range', + ranges: [{ to: 300, label: 'Partial Range 2' }], + }, + } as RangeIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + expect(publicAPI.getFilters()).toEqual({ + kuery: [ + [{ language: 'kuery', query: 'bytes >= 100' }], + [{ language: 'kuery', query: 'bytes <= 300' }], + ], + lucene: [], + }); + }); + it('should collect filters within filters operation grouped by language', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'kuery Filter', + dataType: 'string', + isBucketed: true, + operationType: 'filters', + scale: 'ordinal', + params: { + filters: [{ label: '', input: { language: 'kuery', query: 'bytes > 1000' } }], + }, + } as FiltersIndexPatternColumn, + col2: { + label: 'Lucene Filter', + dataType: 'string', + isBucketed: true, + operationType: 'filters', + scale: 'ordinal', + params: { + filters: [{ label: '', input: { language: 'lucene', query: 'memory' } }], + }, + } as FiltersIndexPatternColumn, + col3: { + label: 'Mixed filters', + dataType: 'string', + isBucketed: true, + operationType: 'filters', + scale: 'ordinal', + params: { + filters: [ + { label: '', input: { language: 'kuery', query: 'bytes > 5000' } }, + { label: '', input: { language: 'kuery', query: 'memory > 500000' } }, + { label: '', input: { language: 'lucene', query: 'phpmemory' } }, + { label: '', input: { language: 'lucene', query: 'memory: 5000000' } }, + ], + }, + } as FiltersIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + expect(publicAPI.getFilters()).toEqual({ + kuery: [ + [{ language: 'kuery', query: 'bytes > 1000' }], + [ + { language: 'kuery', query: 'bytes > 5000' }, + { language: 'kuery', query: 'memory > 500000' }, + ], + ], + lucene: [ + [{ language: 'lucene', query: 'memory' }], + [ + { language: 'lucene', query: 'phpmemory' }, + { language: 'lucene', query: 'memory: 5000000' }, + ], + ], + }); + }); + it('should ignore filtered metrics if at least one metric is unfiltered', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Sum', + dataType: 'number', + isBucketed: false, + operationType: 'sum', + sourceField: 'test', + params: {}, + filter: { language: 'kuery', query: 'bytes > 1000' }, + } as GenericIndexPatternColumn, + col2: { + label: 'Sum', + dataType: 'number', + isBucketed: false, + operationType: 'sum', + sourceField: 'test', + params: {}, + } as GenericIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + expect(publicAPI.getFilters()).toEqual({ + kuery: [], + lucene: [], + }); + }); + it('should ignore filtered metrics if at least one metric is unfiltered in formula', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['formula'], + columns: { + formula: { + label: 'Formula', + dataType: 'number', + operationType: 'formula', + isBucketed: false, + scale: 'ratio', + params: { + formula: "count(kql='memory > 5000') + count()", + isFormulaBroken: false, + }, + references: ['math'], + } as FormulaIndexPatternColumn, + countX0: { + label: 'countX0', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + customLabel: true, + filter: { language: 'kuery', query: 'memory > 5000' }, + }, + countX1: { + label: 'countX1', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + customLabel: true, + }, + math: { + label: 'math', + dataType: 'number', + operationType: 'math', + isBucketed: false, + scale: 'ratio', + params: { + tinymathAst: { + type: 'function', + name: 'add', + args: ['countX0', 'countX1'] as unknown as TinymathAST[], + location: { + min: 0, + max: 17, + }, + text: "count(kql='memory > 5000') + count()", + }, + }, + references: ['countX0', 'countX1'], + customLabel: true, + } as MathIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + expect(publicAPI.getFilters()).toEqual({ + kuery: [], + lucene: [], + }); + }); + it('should support complete scenarios', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2', 'col3', 'col4'], + columns: { + col1: { + label: 'Mixed filters', + dataType: 'string', + isBucketed: true, + operationType: 'filters', + scale: 'ordinal', + params: { + filters: [ + { label: '', input: { language: 'kuery', query: 'bytes > 5000' } }, + { label: '', input: { language: 'kuery', query: 'memory > 500000' } }, + { label: '', input: { language: 'lucene', query: 'phpmemory' } }, + { label: '', input: { language: 'lucene', query: 'memory: 5000000' } }, + ], + }, + } as FiltersIndexPatternColumn, + col2: { + label: 'Sum', + dataType: 'number', + isBucketed: false, + operationType: 'sum', + sourceField: 'test', + params: {}, + filter: { language: 'kuery', query: 'bytes > 1000' }, + } as GenericIndexPatternColumn, + col3: { + label: 'Sum', + dataType: 'number', + isBucketed: false, + operationType: 'sum', + sourceField: 'test', + params: {}, + filter: { language: 'lucene', query: 'memory' }, + } as GenericIndexPatternColumn, + col4: { + label: 'Terms', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'geo.src', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 10, + secondaryFields: ['myField'], + }, + } as TermsIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + expect(publicAPI.getFilters()).toEqual({ + kuery: [ + [{ language: 'kuery', query: 'bytes > 1000' }], + [ + { language: 'kuery', query: 'bytes > 5000' }, + { language: 'kuery', query: 'memory > 500000' }, + ], + [ + { language: 'kuery', query: 'geo.src: *' }, + { language: 'kuery', query: 'myField: *' }, + ], + ], + lucene: [ + [{ language: 'lucene', query: 'memory' }], + [ + { language: 'lucene', query: 'phpmemory' }, + { language: 'lucene', query: 'memory: 5000000' }, + ], + ], + }); + }); + + it('should avoid duplicate filters when formula has a global filter', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + ...enrichBaseState(baseState), + layers: { + first: { + indexPatternId: '1', + columnOrder: ['formula'], + columns: { + formula: { + label: 'Formula', + dataType: 'number', + operationType: 'formula', + isBucketed: false, + scale: 'ratio', + filter: { language: 'kuery', query: 'bytes > 4000' }, + params: { + formula: "count(kql='memory > 5000') + count()", + isFormulaBroken: false, + }, + references: ['math'], + } as FormulaIndexPatternColumn, + countX0: { + label: 'countX0', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + customLabel: true, + filter: { language: 'kuery', query: 'bytes > 4000 AND memory > 5000' }, + }, + countX1: { + label: 'countX1', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + customLabel: true, + filter: { language: 'kuery', query: 'bytes > 4000' }, + }, + math: { + label: 'math', + dataType: 'number', + operationType: 'math', + isBucketed: false, + scale: 'ratio', + params: { + tinymathAst: { + type: 'function', + name: 'add', + args: ['countX0', 'countX1'] as unknown as TinymathAST[], + location: { + min: 0, + max: 17, + }, + text: "count(kql='memory > 5000') + count()", + }, + }, + references: ['countX0', 'countX1'], + customLabel: true, + } as MathIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + }); + expect(publicAPI.getFilters()).toEqual({ + kuery: [ + [ + { language: 'kuery', query: 'bytes > 4000 AND memory > 5000' }, + { language: 'kuery', query: 'bytes > 4000' }, + ], + ], + lucene: [], + }); + }); + }); }); describe('#getErrorMessages', () => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 0ac77696d598..3578796ab1d6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -18,10 +18,11 @@ import type { DatasourceDimensionEditorProps, DatasourceDimensionTriggerProps, DatasourceDataPanelProps, - Operation, DatasourceLayerPanelProps, PublicAPIProps, InitializationOptions, + OperationDescriptor, + FramePublicAPI, } from '../types'; import { loadInitialState, @@ -45,7 +46,7 @@ import { getDatasourceSuggestionsForVisualizeCharts, } from './indexpattern_suggestions'; -import { getVisualDefaultsForLayer, isColumnInvalid } from './utils'; +import { getFiltersInLayer, getVisualDefaultsForLayer, isColumnInvalid } from './utils'; import { normalizeOperationDataType, isDraggedField } from './pure_utils'; import { LayerPanel } from './layerpanel'; import { @@ -53,7 +54,9 @@ import { GenericIndexPatternColumn, getErrorMessages, insertNewColumn, + TermsIndexPatternColumn, } from './operations'; +import { getReferenceRoot } from './operations/layer_helpers'; import { IndexPatternField, IndexPatternPrivateState, @@ -75,6 +78,7 @@ import { GeoFieldWorkspacePanel } from '../editor_frame_service/editor_frame/wor import { DraggingIdentifier } from '../drag_drop'; import { getStateTimeShiftWarningMessages } from './time_shift_utils'; import { getPrecisionErrorWarningMessages } from './utils'; +import { DOCUMENT_FIELD_NAME } from '../../common/constants'; import { isColumnOfType } from './operations/definitions/helpers'; export type { OperationType, GenericIndexPatternColumn } from './operations'; export { deleteColumn } from './operations'; @@ -83,8 +87,8 @@ export function columnToOperation( column: GenericIndexPatternColumn, uniqueLabel?: string, dataView?: IndexPattern -): Operation { - const { dataType, label, isBucketed, scale, operationType } = column; +): OperationDescriptor { + const { dataType, label, isBucketed, scale, operationType, timeShift } = column; const fieldTypes = 'sourceField' in column ? dataView?.getFieldByName(column.sourceField)?.esTypes : undefined; return { @@ -97,6 +101,7 @@ export function columnToOperation( column.dataType === 'string' && fieldTypes?.includes(ES_FIELD_TYPES.VERSION) ? 'version' : undefined, + hasTimeShift: Boolean(timeShift), }; } @@ -138,7 +143,7 @@ export function getIndexPatternDatasource({ const handleChangeIndexPattern = ( id: string, state: IndexPatternPrivateState, - setState: StateSetter + setState: StateSetter ) => { changeIndexPattern({ id, @@ -451,18 +456,35 @@ export function getIndexPatternDatasource({ getPublicAPI({ state, layerId }: PublicAPIProps) { const columnLabelMap = indexPatternDatasource.uniqueLabels(state); + const layer = state.layers[layerId]; + const visibleColumnIds = layer.columnOrder.filter((colId) => !isReferenced(layer, colId)); return { datasourceId: 'indexpattern', - getTableSpec: () => { - return state.layers[layerId].columnOrder - .filter((colId) => !isReferenced(state.layers[layerId], colId)) - .map((colId) => ({ columnId: colId })); + // consider also referenced columns in this case + // but map fields to the top referencing column + const fieldsPerColumn: Record = {}; + Object.keys(layer.columns).forEach((colId) => { + const visibleColumnId = getReferenceRoot(layer, colId); + fieldsPerColumn[visibleColumnId] = fieldsPerColumn[visibleColumnId] || []; + + const column = layer.columns[colId]; + if (isColumnOfType('terms', column)) { + fieldsPerColumn[visibleColumnId].push( + ...[column.sourceField].concat(column.params.secondaryFields ?? []) + ); + } + if ('sourceField' in column && column.sourceField !== DOCUMENT_FIELD_NAME) { + fieldsPerColumn[visibleColumnId].push(column.sourceField); + } + }); + return visibleColumnIds.map((colId, i) => ({ + columnId: colId, + fields: [...new Set(fieldsPerColumn[colId] || [])], + })); }, getOperationForColumnId: (columnId: string) => { - const layer = state.layers[layerId]; - if (layer && layer.columns[columnId]) { if (!isReferenced(layer, columnId)) { return columnToOperation( @@ -474,10 +496,10 @@ export function getIndexPatternDatasource({ } return null; }, - getVisualDefaults: () => { - const layer = state.layers[layerId]; - return getVisualDefaultsForLayer(layer); - }, + getSourceId: () => layer.indexPatternId, + getFilters: (activeData: FramePublicAPI['activeData']) => + getFiltersInLayer(layer, visibleColumnIds, activeData?.[layerId]), + getVisualDefaults: () => getVisualDefaultsForLayer(layer), }; }, getDatasourceSuggestionsForField(state, draggedField, filterLayers) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index c25b8b726407..cca739c949d4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -1189,6 +1189,7 @@ describe('IndexPattern Data Source suggestions', () => { label: '', scale: undefined, isStaticValue: false, + hasTimeShift: false, }, }, { @@ -1199,6 +1200,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'Count of records', scale: 'ratio', isStaticValue: false, + hasTimeShift: false, }, }, ], @@ -1276,6 +1278,7 @@ describe('IndexPattern Data Source suggestions', () => { label: '', scale: undefined, isStaticValue: false, + hasTimeShift: false, }, }, { @@ -1286,6 +1289,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'Count of records', scale: 'ratio', isStaticValue: false, + hasTimeShift: false, }, }, ], @@ -1604,7 +1608,7 @@ describe('IndexPattern Data Source suggestions', () => { const updatedContext = [ { ...context[0], - splitField: 'source', + splitFields: ['source'], splitMode: 'terms', termsParams: { size: 10, @@ -1831,6 +1835,61 @@ describe('IndexPattern Data Source suggestions', () => { }) ); }); + + it('should apply a static layer if it is provided', () => { + const updatedContext = [ + { + ...context[0], + metrics: [ + { + agg: 'static_value', + isFullReference: true, + fieldName: 'document', + params: { + value: '10', + }, + color: '#68BC00', + }, + ], + }, + ]; + const suggestions = getDatasourceSuggestionsForVisualizeCharts( + stateWithoutLayer(), + updatedContext + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id2'], + columns: { + id2: expect.objectContaining({ + operationType: 'static_value', + isStaticValue: true, + params: expect.objectContaining({ + value: '10', + }), + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: false, + columns: [ + expect.objectContaining({ + columnId: 'id2', + }), + ], + layerId: 'id1', + }, + }) + ); + }); }); describe('#getDatasourceSuggestionsForVisualizeField', () => { @@ -1977,6 +2036,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: true, scale: undefined, isStaticValue: false, + hasTimeShift: false, }, }, ], @@ -2000,6 +2060,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: true, scale: undefined, isStaticValue: false, + hasTimeShift: false, }, }, ], @@ -2047,6 +2108,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: true, scale: 'interval', isStaticValue: false, + hasTimeShift: false, }, }, { @@ -2057,6 +2119,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, scale: 'ratio', isStaticValue: false, + hasTimeShift: false, }, }, ], @@ -2118,6 +2181,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: true, scale: 'ordinal', isStaticValue: false, + hasTimeShift: false, }, }, { @@ -2128,6 +2192,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: true, scale: 'interval', isStaticValue: false, + hasTimeShift: false, }, }, { @@ -2138,6 +2203,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, scale: 'ratio', isStaticValue: false, + hasTimeShift: false, }, }, ], @@ -2218,6 +2284,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: true, scale: 'ordinal', isStaticValue: false, + hasTimeShift: false, }, }, { @@ -2228,6 +2295,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: true, scale: 'interval', isStaticValue: false, + hasTimeShift: false, }, }, { @@ -2238,6 +2306,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, scale: 'ratio', isStaticValue: false, + hasTimeShift: false, }, }, ], @@ -2341,6 +2410,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Custom Range', scale: 'ordinal', isStaticValue: false, + hasTimeShift: false, }, }, { @@ -2351,6 +2421,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'timestampLabel', scale: 'interval', isStaticValue: false, + hasTimeShift: false, }, }, { @@ -2361,6 +2432,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'Unique count of dest', scale: undefined, isStaticValue: false, + hasTimeShift: false, }, }, ], @@ -2873,6 +2945,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', scale: undefined, isStaticValue: false, + hasTimeShift: false, }, }, { @@ -2883,6 +2956,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'Top 5', scale: undefined, isStaticValue: false, + hasTimeShift: false, }, }, ], @@ -2947,6 +3021,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'timestampLabel', scale: 'interval', isStaticValue: false, + hasTimeShift: false, }, }, { @@ -2957,6 +3032,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'Cumulative sum of Records label', scale: undefined, isStaticValue: false, + hasTimeShift: false, }, }, { @@ -2967,6 +3043,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'Cumulative sum of (incomplete)', scale: undefined, isStaticValue: false, + hasTimeShift: false, }, }, ], @@ -3029,6 +3106,7 @@ describe('IndexPattern Data Source suggestions', () => { label: '', scale: undefined, isStaticValue: false, + hasTimeShift: false, }, }, { @@ -3039,6 +3117,7 @@ describe('IndexPattern Data Source suggestions', () => { label: '', scale: undefined, isStaticValue: false, + hasTimeShift: false, }, }, { @@ -3049,6 +3128,7 @@ describe('IndexPattern Data Source suggestions', () => { label: '', scale: undefined, isStaticValue: false, + hasTimeShift: false, }, }, ], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index 0e6fbf02a491..8b1c2a3b799e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -179,12 +179,19 @@ function createNewTimeseriesLayerWithMetricAggregationFromVizEditor( indexPattern: IndexPattern, layer: VisualizeEditorLayersContext ): IndexPatternLayer | undefined { - const { timeFieldName, splitMode, splitFilters, metrics, timeInterval } = layer; + const { timeFieldName, splitMode, splitFilters, metrics, timeInterval, dropPartialBuckets } = + layer; const dateField = indexPattern.getFieldByName(timeFieldName!); - const splitField = layer.splitField ? indexPattern.getFieldByName(layer.splitField) : null; + + const splitFields = layer.splitFields + ? (layer.splitFields + .map((item) => indexPattern.getFieldByName(item)) + .filter(Boolean) as IndexPatternField[]) + : null; + // generate the layer for split by terms - if (splitMode === 'terms' && splitField) { - return getSplitByTermsLayer(indexPattern, splitField, dateField, layer); + if (splitMode === 'terms' && splitFields?.length) { + return getSplitByTermsLayer(indexPattern, splitFields, dateField, layer); // generate the layer for split by filters } else if (splitMode?.includes('filter') && splitFilters && splitFilters.length) { return getSplitByFiltersLayer(indexPattern, dateField, layer); @@ -197,6 +204,10 @@ function createNewTimeseriesLayerWithMetricAggregationFromVizEditor( layer.format, layer.label ); + // static values layers do not need a date histogram column + if (Object.values(computedLayer.columns)[0].isStaticValue) { + return computedLayer; + } return insertNewColumn({ op: 'date_histogram', @@ -207,6 +218,7 @@ function createNewTimeseriesLayerWithMetricAggregationFromVizEditor( visualizationGroups: [], columnParams: { interval: timeInterval, + dropPartials: dropPartialBuckets, }, }); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index 9099b68cdaf0..d992e36a0c6d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -854,7 +854,9 @@ describe('loader', () => { }); expect(setState).toHaveBeenCalledTimes(1); - expect(setState.mock.calls[0][0](state)).toMatchObject({ + const [fn, options] = setState.mock.calls[0]; + expect(options).toEqual({ applyImmediately: true }); + expect(fn(state)).toMatchObject({ currentIndexPatternId: '1', indexPatterns: { '1': { @@ -1071,7 +1073,8 @@ describe('loader', () => { expect(fetchJson).toHaveBeenCalledTimes(3); expect(setState).toHaveBeenCalledTimes(1); - const [fn] = setState.mock.calls[0]; + const [fn, options] = setState.mock.calls[0]; + expect(options).toEqual({ applyImmediately: true }); const newState = fn({ foo: 'bar', existingFields: {}, @@ -1155,7 +1158,8 @@ describe('loader', () => { await syncExistingFields(args); - const [fn] = setState.mock.calls[0]; + const [fn, options] = setState.mock.calls[0]; + expect(options).toEqual({ applyImmediately: true }); const newState = fn({ foo: 'bar', existingFields: {}, @@ -1204,7 +1208,8 @@ describe('loader', () => { await syncExistingFields(args); - const [fn] = setState.mock.calls[0]; + const [fn, options] = setState.mock.calls[0]; + expect(options).toEqual({ applyImmediately: true }); const newState = fn({ foo: 'bar', existingFields: {}, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index 8b3a0556b032..9495276f1596 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -9,7 +9,11 @@ import { uniq, mapValues, difference } from 'lodash'; import type { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import type { DataView } from 'src/plugins/data_views/public'; import type { HttpSetup, SavedObjectReference } from 'kibana/public'; -import type { InitializationOptions, StateSetter, VisualizeEditorContext } from '../types'; +import type { + DatasourceDataPanelProps, + InitializationOptions, + VisualizeEditorContext, +} from '../types'; import { IndexPattern, IndexPatternRef, @@ -33,7 +37,7 @@ import { readFromStorage, writeToStorage } from '../settings_storage'; import { getFieldByNameFactory } from './pure_helpers'; import { memoizedGetAvailableOperationsByMetadata } from './operations'; -type SetState = StateSetter; +type SetState = DatasourceDataPanelProps['setState']; type IndexPatternsService = Pick; type ErrorHandler = (err: Error) => void; @@ -326,17 +330,20 @@ export async function changeIndexPattern({ } try { - setState((s) => ({ - ...s, - layers: isSingleEmptyLayer(state.layers) - ? mapValues(state.layers, (layer) => updateLayerIndexPattern(layer, indexPatterns[id])) - : state.layers, - indexPatterns: { - ...s.indexPatterns, - [id]: indexPatterns[id], - }, - currentIndexPatternId: id, - })); + setState( + (s) => ({ + ...s, + layers: isSingleEmptyLayer(state.layers) + ? mapValues(state.layers, (layer) => updateLayerIndexPattern(layer, indexPatterns[id])) + : state.layers, + indexPatterns: { + ...s.indexPatterns, + [id]: indexPatterns[id], + }, + currentIndexPatternId: id, + }), + { applyImmediately: true } + ); setLastUsedIndexPatternId(storage, id); } catch (err) { onError(err); @@ -458,33 +465,39 @@ export async function syncExistingFields({ } } - setState((state) => ({ - ...state, - isFirstExistenceFetch: false, - existenceFetchFailed: false, - existenceFetchTimeout: false, - existingFields: emptinessInfo.reduce( - (acc, info) => { - acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); - return acc; - }, - { ...state.existingFields } - ), - })); + setState( + (state) => ({ + ...state, + isFirstExistenceFetch: false, + existenceFetchFailed: false, + existenceFetchTimeout: false, + existingFields: emptinessInfo.reduce( + (acc, info) => { + acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); + return acc; + }, + { ...state.existingFields } + ), + }), + { applyImmediately: true } + ); } catch (e) { // show all fields as available if fetch failed or timed out - setState((state) => ({ - ...state, - existenceFetchFailed: e.res?.status !== 408, - existenceFetchTimeout: e.res?.status === 408, - existingFields: indexPatterns.reduce( - (acc, pattern) => { - acc[pattern.title] = booleanMap(pattern.fields.map((field) => field.name)); - return acc; - }, - { ...state.existingFields } - ), - })); + setState( + (state) => ({ + ...state, + existenceFetchFailed: e.res?.status !== 408, + existenceFetchTimeout: e.res?.status === 408, + existingFields: indexPatterns.reduce( + (acc, pattern) => { + acc[pattern.title] = booleanMap(pattern.fields.map((field) => field.name)); + return acc; + }, + { ...state.existingFields } + ), + }), + { applyImmediately: true } + ); } } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx index beca7cfa4c39..4534270cf4b3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx @@ -194,6 +194,7 @@ describe('date_histogram', () => { interval: ['42w'], field: ['timestamp'], useNormalizedEsInterval: [true], + drop_partials: [false], }), }) ); @@ -249,6 +250,7 @@ describe('date_histogram', () => { field: ['timestamp'], time_zone: ['UTC'], useNormalizedEsInterval: [false], + drop_partials: [false], }), }) ); @@ -382,7 +384,7 @@ describe('date_histogram', () => { ); expect(instance.find('[data-test-subj="lensDateHistogramValue"]').exists()).toBeFalsy(); expect(instance.find('[data-test-subj="lensDateHistogramUnit"]').exists()).toBeFalsy(); - expect(instance.find(EuiSwitch).prop('checked')).toBe(false); + expect(instance.find(EuiSwitch).at(1).prop('checked')).toBe(false); }); it('should allow switching to manual interval', () => { @@ -415,9 +417,12 @@ describe('date_histogram', () => { currentColumn={thirdLayer.columns.col1 as DateHistogramIndexPatternColumn} /> ); - instance.find(EuiSwitch).simulate('change', { - target: { checked: true }, - }); + instance + .find(EuiSwitch) + .at(1) + .simulate('change', { + target: { checked: true }, + }); expect(updateLayerSpy).toHaveBeenCalled(); const newLayer = updateLayerSpy.mock.calls[0][0]; expect(newLayer).toHaveProperty('columns.col1.params.interval', '30d'); @@ -456,7 +461,7 @@ describe('date_histogram', () => { ); instance .find(EuiSwitch) - .at(1) + .last() .simulate('change', { target: { checked: false }, }); @@ -499,7 +504,7 @@ describe('date_histogram', () => { ); instance .find(EuiSwitch) - .at(0) + .at(1) .simulate('change', { target: { checked: false }, }); @@ -509,6 +514,41 @@ describe('date_histogram', () => { expect(newLayer).toHaveProperty('columns.col1.params.interval', 'auto'); }); + it('turns off drop partial bucket on tuning off time range ignore', () => { + const thirdLayer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1h', + ignoreTimeRange: true, + }, + sourceField: 'timestamp', + } as DateHistogramIndexPatternColumn, + }, + }; + + const updateLayerSpy = jest.fn(); + const instance = shallow( + + ); + expect(instance.find(EuiSwitch).first().prop('disabled')).toBeTruthy(); + }); + it('should force calendar values to 1', () => { const updateLayerSpy = jest.fn(); const instance = shallow( @@ -657,6 +697,47 @@ describe('date_histogram', () => { expect(instance.find('[data-test-subj="lensDateHistogramValue"]').exists()).toBeFalsy(); }); + + it('should allow the drop of partial buckets', () => { + const thirdLayer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: 'auto', + }, + sourceField: 'timestamp', + } as DateHistogramIndexPatternColumn, + }, + }; + + const updateLayerSpy = jest.fn(); + const instance = shallow( + + ); + instance + .find(EuiSwitch) + .first() + .simulate('change', { + target: { checked: true }, + }); + expect(updateLayerSpy).toHaveBeenCalled(); + const newLayer = updateLayerSpy.mock.calls[0][0]; + expect(newLayer).toHaveProperty('columns.col1.params.dropPartials', true); + }); }); describe('getDefaultLabel', () => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx index e269778b5ad5..8b7ec3cc32e5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -38,6 +38,7 @@ import { buildExpressionFunction } from '../../../../../../../src/plugins/expres import { getInvalidFieldMessage, getSafeName } from './helpers'; import { HelpPopover, HelpPopoverButton } from '../../help_popover'; import { IndexPatternLayer } from '../../types'; +import { TooltipWrapper } from '../../../shared_components'; const { isValidInterval } = search.aggs; const autoInterval = 'auto'; @@ -48,6 +49,7 @@ export interface DateHistogramIndexPatternColumn extends FieldBasedIndexPatternC params: { interval: string; ignoreTimeRange?: boolean; + dropPartials?: boolean; }; } @@ -76,7 +78,7 @@ function getMultipleDateHistogramsErrorMessage(layer: IndexPatternLayer, columnI export const dateHistogramOperation: OperationDefinition< DateHistogramIndexPatternColumn, 'field', - { interval: string } + { interval: string; dropPartials?: boolean } > = { type: 'date_histogram', displayName: i18n.translate('xpack.lens.indexPattern.dateHistogram', { @@ -118,6 +120,7 @@ export const dateHistogramOperation: OperationDefinition< scale: 'interval', params: { interval: columnParams?.interval ?? autoInterval, + dropPartials: Boolean(columnParams?.dropPartials), }, }; }, @@ -142,6 +145,11 @@ export const dateHistogramOperation: OperationDefinition< const usedField = indexPattern.getFieldByName(column.sourceField); let timeZone: string | undefined; let interval = column.params?.interval ?? autoInterval; + const dropPartials = Boolean( + column.params?.dropPartials && + // set to false when detached from time picker + (indexPattern.timeFieldName === usedField?.name || !column.params?.ignoreTimeRange) + ); if ( usedField && usedField.aggregationRestrictions && @@ -158,7 +166,7 @@ export const dateHistogramOperation: OperationDefinition< time_zone: timeZone, useNormalizedEsInterval: !usedField?.aggregationRestrictions?.date_histogram, interval, - drop_partials: false, + drop_partials: dropPartials, min_doc_count: 0, extended_bounds: extendedBoundsToAst({}), }).toAst(); @@ -186,20 +194,37 @@ export const dateHistogramOperation: OperationDefinition< restrictedInterval(field!.aggregationRestrictions) ); - function onChangeAutoInterval(ev: EuiSwitchEvent) { - const { fromDate, toDate } = dateRange; - const value = ev.target.checked - ? data.search.aggs.calculateAutoTimeExpression({ from: fromDate, to: toDate }) || '1h' - : autoInterval; - updateLayer( - updateColumnParam({ - layer: updateColumnParam({ layer, columnId, paramName: 'interval', value }), - columnId, - paramName: 'ignoreTimeRange', - value: false, - }) - ); - } + const onChangeAutoInterval = useCallback( + (ev: EuiSwitchEvent) => { + const { fromDate, toDate } = dateRange; + const value = ev.target.checked + ? data.search.aggs.calculateAutoTimeExpression({ from: fromDate, to: toDate }) || '1h' + : autoInterval; + updateLayer( + updateColumnParam({ + layer: updateColumnParam({ layer, columnId, paramName: 'interval', value }), + columnId, + paramName: 'ignoreTimeRange', + value: false, + }) + ); + }, + [dateRange, data.search.aggs, updateLayer, layer, columnId] + ); + + const onChangeDropPartialBuckets = useCallback( + (ev: EuiSwitchEvent) => { + updateLayer( + updateColumnParam({ + layer, + columnId, + paramName: 'dropPartials', + value: ev.target.checked, + }) + ); + }, + [columnId, layer, updateLayer] + ); const setInterval = (newInterval: typeof interval) => { const isCalendarInterval = calendarOnlyIntervals.has(newInterval.unit); @@ -208,8 +233,33 @@ export const dateHistogramOperation: OperationDefinition< updateLayer(updateColumnParam({ layer, columnId, paramName: 'interval', value })); }; + const bindToGlobalTimePickerValue = + indexPattern.timeFieldName === field?.name || !currentColumn.params.ignoreTimeRange; + return ( <> + + + + + {!intervalIsRestricted && ( } disabled={indexPattern.timeFieldName === field?.name} - checked={ - indexPattern.timeFieldName === field?.name || - !currentColumn.params.ignoreTimeRange - } + checked={bindToGlobalTimePickerValue} onChange={() => { updateLayer( updateColumnParam({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.test.ts index bf24e31ad4f5..616c1f5719cc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.test.ts @@ -7,6 +7,7 @@ import { createMockedIndexPattern } from '../../mocks'; import { getInvalidFieldMessage } from './helpers'; +import type { TermsIndexPatternColumn } from './terms'; describe('helpers', () => { describe('getInvalidFieldMessage', () => { @@ -16,13 +17,13 @@ describe('helpers', () => { dataType: 'number', isBucketed: false, label: 'Foo', - operationType: 'count', // <= invalid - sourceField: 'bytes', + operationType: 'count', + sourceField: 'NoBytes', // <= invalid }, createMockedIndexPattern() ); expect(messages).toHaveLength(1); - expect(messages![0]).toEqual('Field bytes was not found'); + expect(messages![0]).toEqual('Field NoBytes was not found'); }); it('returns an error if a field is the wrong type', () => { @@ -31,8 +32,8 @@ describe('helpers', () => { dataType: 'number', isBucketed: false, label: 'Foo', - operationType: 'average', // <= invalid - sourceField: 'timestamp', + operationType: 'average', + sourceField: 'timestamp', // <= invalid type for average }, createMockedIndexPattern() ); @@ -40,6 +41,78 @@ describe('helpers', () => { expect(messages![0]).toEqual('Field timestamp is of the wrong type'); }); + it('returns an error if one field amongst multiples does not exist', () => { + const messages = getInvalidFieldMessage( + { + dataType: 'number', + isBucketed: false, + label: 'Foo', + operationType: 'terms', + sourceField: 'geo.src', + params: { + secondaryFields: ['NoBytes'], // <= field does not exist + }, + } as TermsIndexPatternColumn, + createMockedIndexPattern() + ); + expect(messages).toHaveLength(1); + expect(messages![0]).toEqual('Field NoBytes was not found'); + }); + + it('returns an error if multiple fields do not exist', () => { + const messages = getInvalidFieldMessage( + { + dataType: 'number', + isBucketed: false, + label: 'Foo', + operationType: 'terms', + sourceField: 'NotExisting', + params: { + secondaryFields: ['NoBytes'], // <= field does not exist + }, + } as TermsIndexPatternColumn, + createMockedIndexPattern() + ); + expect(messages).toHaveLength(1); + expect(messages![0]).toEqual('Fields NotExisting, NoBytes were not found'); + }); + + it('returns an error if one field amongst multiples has the wrong type', () => { + const messages = getInvalidFieldMessage( + { + dataType: 'number', + isBucketed: false, + label: 'Foo', + operationType: 'terms', + sourceField: 'geo.src', + params: { + secondaryFields: ['timestamp'], // <= invalid type + }, + } as TermsIndexPatternColumn, + createMockedIndexPattern() + ); + expect(messages).toHaveLength(1); + expect(messages![0]).toEqual('Field timestamp is of the wrong type'); + }); + + it('returns an error if multiple fields are of the wrong type', () => { + const messages = getInvalidFieldMessage( + { + dataType: 'number', + isBucketed: false, + label: 'Foo', + operationType: 'terms', + sourceField: 'start_date', // <= invalid type + params: { + secondaryFields: ['timestamp'], // <= invalid type + }, + } as TermsIndexPatternColumn, + createMockedIndexPattern() + ); + expect(messages).toHaveLength(1); + expect(messages![0]).toEqual('Fields start_date, timestamp are of the wrong type'); + }); + it('returns no message if all fields are matching', () => { const messages = getInvalidFieldMessage( { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx index 73e0e61a6895..c464ce0da027 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx @@ -12,7 +12,8 @@ import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn, } from './column_types'; -import { IndexPattern } from '../../types'; +import { IndexPattern, IndexPatternField } from '../../types'; +import { hasField } from '../../pure_utils'; export function getInvalidFieldMessage( column: FieldBasedIndexPatternColumn, @@ -21,47 +22,66 @@ export function getInvalidFieldMessage( if (!indexPattern) { return; } - const { sourceField, operationType } = column; - const field = sourceField ? indexPattern.getFieldByName(sourceField) : undefined; - const operationDefinition = operationType && operationDefinitionMap[operationType]; + const { operationType } = column; + const operationDefinition = operationType ? operationDefinitionMap[operationType] : undefined; + const fieldNames = + hasField(column) && operationDefinition + ? operationDefinition?.getCurrentFields?.(column) ?? [column.sourceField] + : []; + const fields = fieldNames.map((fieldName) => indexPattern.getFieldByName(fieldName)); + const filteredFields = fields.filter(Boolean) as IndexPatternField[]; const isInvalid = Boolean( - sourceField && - operationDefinition && + fields.length > filteredFields.length || !( - field && operationDefinition?.input === 'field' && - operationDefinition.getPossibleOperationForField(field) !== undefined + filteredFields.every( + (field) => operationDefinition.getPossibleOperationForField(field) != null + ) ) ); const isWrongType = Boolean( - sourceField && - operationDefinition && - field && - !operationDefinition.isTransferable( + filteredFields.length && + !operationDefinition?.isTransferable( column as GenericIndexPatternColumn, indexPattern, operationDefinitionMap ) ); + if (isInvalid) { + // Missing fields have priority over wrong type + // This has been moved as some transferable checks also perform exist checks internally and fail eventually + // but that would make type mismatch error appear in place of missing fields scenarios + const missingFields = fields.map((field, i) => (field ? null : fieldNames[i])).filter(Boolean); + if (missingFields.length) { + return [ + i18n.translate('xpack.lens.indexPattern.fieldsNotFound', { + defaultMessage: + '{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found', + values: { + count: missingFields.length, + missingFields: missingFields.join(', '), + }, + }), + ]; + } if (isWrongType) { + // as fallback show all the fields as invalid? + const wrongTypeFields = + operationDefinition?.getNonTransferableFields?.(column, indexPattern) ?? fieldNames; return [ - i18n.translate('xpack.lens.indexPattern.fieldWrongType', { - defaultMessage: 'Field {invalidField} is of the wrong type', + i18n.translate('xpack.lens.indexPattern.fieldsWrongType', { + defaultMessage: + '{count, plural, one {Field} other {Fields}} {invalidFields} {count, plural, one {is} other {are}} of the wrong type', values: { - invalidField: sourceField, + count: wrongTypeFields.length, + invalidFields: wrongTypeFields.join(', '), }, }), ]; } - return [ - i18n.translate('xpack.lens.indexPattern.fieldNotFound', { - defaultMessage: 'Field {invalidField} was not found', - values: { invalidField: sourceField }, - }), - ]; } return undefined; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index a048f2b55919..dec70130d128 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -336,6 +336,12 @@ interface BaseOperationDefinitionProps * Operation can influence some visual default settings. This function is used to collect default values offered */ getDefaultVisualSettings?: (column: C) => { truncateText?: boolean }; + + /** + * Utility function useful for multi fields operation in order to get fields + * are not pass the transferable checks + */ + getNonTransferableFields?: (column: C, indexPattern: IndexPattern) => string[]; } interface BaseBuildColumnArgs { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx index c0a39750f359..6937b1eba72b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx @@ -125,6 +125,70 @@ describe('last_value', () => { expect(column.label).toContain('bytes'); }); + it('should adjust filter if it is exists filter on the current field', () => { + const oldColumn: LastValueIndexPatternColumn = { + operationType: 'last_value', + sourceField: 'source', + label: 'Last value of source', + isBucketed: true, + dataType: 'string', + filter: { language: 'kuery', query: 'source: *' }, + params: { + sortField: 'datefield', + }, + }; + const indexPattern = createMockedIndexPattern(); + const newNumberField = indexPattern.getFieldByName('bytes')!; + const column = lastValueOperation.onFieldChange(oldColumn, newNumberField); + + expect(column).toEqual( + expect.objectContaining({ + filter: { language: 'kuery', query: 'bytes: *' }, + }) + ); + }); + + it('should not adjust filter if it has some other filter', () => { + const oldColumn: LastValueIndexPatternColumn = { + operationType: 'last_value', + sourceField: 'source', + label: 'Last value of source', + isBucketed: true, + dataType: 'string', + filter: { language: 'kuery', query: 'something_else: 123' }, + params: { + sortField: 'datefield', + }, + }; + const indexPattern = createMockedIndexPattern(); + const newNumberField = indexPattern.getFieldByName('bytes')!; + const column = lastValueOperation.onFieldChange(oldColumn, newNumberField); + + expect(column).toEqual( + expect.objectContaining({ + filter: { language: 'kuery', query: 'something_else: 123' }, + }) + ); + }); + + it('should not adjust filter if it is undefined', () => { + const oldColumn: LastValueIndexPatternColumn = { + operationType: 'last_value', + sourceField: 'source', + label: 'Last value of source', + isBucketed: true, + dataType: 'string', + params: { + sortField: 'datefield', + }, + }; + const indexPattern = createMockedIndexPattern(); + const newNumberField = indexPattern.getFieldByName('bytes')!; + const column = lastValueOperation.onFieldChange(oldColumn, newNumberField); + + expect(column.filter).toBeFalsy(); + }); + it('should remove numeric parameters when changing away from number', () => { const oldColumn: LastValueIndexPatternColumn = { operationType: 'last_value', @@ -232,6 +296,21 @@ describe('last_value', () => { expect(lastValueColumn.dataType).toEqual('boolean'); }); + it('should set exists filter on field', () => { + const lastValueColumn = lastValueOperation.buildColumn({ + indexPattern: createMockedIndexPattern(), + field: { + aggregatable: true, + searchable: true, + type: 'boolean', + name: 'test', + displayName: 'test', + }, + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, + }); + expect(lastValueColumn.filter).toEqual({ language: 'kuery', query: 'test: *' }); + }); + it('should use indexPattern timeFieldName as a default sortField', () => { const lastValueColumn = lastValueOperation.buildColumn({ indexPattern: createMockedIndexPattern(), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx index 3b31844bc4ae..bc4a710af177 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { AggFunctionsMapping } from '../../../../../../../src/plugins/data/public'; @@ -108,6 +109,13 @@ export interface LastValueIndexPatternColumn extends FieldBasedIndexPatternColum }; } +function getExistsFilter(field: string) { + return { + query: `${field}: *`, + language: 'kuery', + }; +} + export const lastValueOperation: OperationDefinition = { type: 'last_value', displayName: i18n.translate('xpack.lens.indexPattern.lastValue', { @@ -129,6 +137,10 @@ export const lastValueOperation: OperationDefinition { @@ -186,7 +198,7 @@ export const lastValueOperation: OperationDefinition; onChange: (newValues: string[]) => void; } @@ -51,6 +53,7 @@ export function FieldInputs({ indexPattern, existingFields, operationSupportMatrix, + invalidFields, }: FieldInputsProps) { const onChangeWrapped = useCallback( (values: WrappedValue[]) => @@ -90,7 +93,7 @@ export function FieldInputs({ return ( <> { // need to filter the available fields for multiple terms // * a scripted field should be removed - // * if a field has been used, should it be removed? Probably yes? - // * if a scripted field was used in a singular term, should it be marked as invalid for multi-terms? Probably yes? + // * a field of unsupported type should be removed + // * a field that has been used + // * a scripted field was used in a singular term, should be marked as invalid for multi-terms const filteredOperationByField = Object.keys(operationSupportMatrix.operationByField) - .filter( - (key) => - (!rawValuesLookup.has(key) && !indexPattern.getFieldByName(key)?.scripted) || - key === value - ) + .filter((key) => { + if (key === value) { + return true; + } + const field = indexPattern.getFieldByName(key); + return ( + !rawValuesLookup.has(key) && + field && + !field.scripted && + supportedTypes.has(field.type) + ); + }) .reduce((memo, key) => { memo[key] = operationSupportMatrix.operationByField[key]; return memo; }, {}); - const shouldShowScriptedFieldError = Boolean( - value && indexPattern.getFieldByName(value)?.scripted && localValuesFilled.length > 1 + const shouldShowError = Boolean( + value && + ((indexPattern.getFieldByName(value)?.scripted && localValuesFilled.length > 1) || + invalidFields?.includes(value)) ); return ( { onFieldSelectChange(choice, index); }} - isInvalid={shouldShowScriptedFieldError} + isInvalid={shouldShowError} data-test-subj={`indexPattern-dimension-field-${index}`} /> @@ -233,3 +246,21 @@ export function FieldInputs({ ); } + +export function getInputFieldErrorMessage(isScriptedField: boolean, invalidFields: string[]) { + if (isScriptedField) { + return i18n.translate('xpack.lens.indexPattern.terms.scriptedFieldErrorShort', { + defaultMessage: 'Scripted fields are not supported when using multiple fields', + }); + } + if (invalidFields.length) { + return i18n.translate('xpack.lens.indexPattern.terms.invalidFieldsErrorShort', { + defaultMessage: + 'Invalid {invalidFieldsCount, plural, one {field} other {fields}}: {invalidFields}. Check your data view or pick another field.', + values: { + invalidFieldsCount: invalidFields.length, + invalidFields: invalidFields.map((fieldName) => `"${fieldName}"`).join(', '), + }, + }); + } +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts index 628a5d6a7740..cdc0f92121f0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts @@ -15,9 +15,9 @@ import { getDisallowedTermsMessage, getMultiTermsScriptedFieldErrorMessage, isSortableByColumn, - MULTI_KEY_VISUAL_SEPARATOR, } from './helpers'; import { ReferenceBasedIndexPatternColumn } from '../column_types'; +import { MULTI_KEY_VISUAL_SEPARATOR } from './constants'; const indexPattern = createMockedIndexPattern(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts index 2917abbf848f..49e612f8bb0d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts @@ -10,7 +10,7 @@ import { uniq } from 'lodash'; import type { CoreStart } from 'kibana/public'; import { buildEsQuery } from '@kbn/es-query'; import { getEsQueryConfig } from '../../../../../../../../src/plugins/data/public'; -import { operationDefinitionMap } from '../index'; +import { GenericIndexPatternColumn, operationDefinitionMap } from '../index'; import { defaultLabel } from '../filters'; import { isReferenced } from '../../layer_helpers'; @@ -18,9 +18,9 @@ import type { FieldStatsResponse } from '../../../../../common'; import type { FrameDatasourceAPI } from '../../../../types'; import type { FiltersIndexPatternColumn } from '../index'; import type { TermsIndexPatternColumn } from './types'; -import type { IndexPatternLayer, IndexPattern } from '../../../types'; - -export const MULTI_KEY_VISUAL_SEPARATOR = '›'; +import type { IndexPatternLayer, IndexPattern, IndexPatternField } from '../../../types'; +import { MULTI_KEY_VISUAL_SEPARATOR, supportedTypes } from './constants'; +import { isColumnOfType } from '../helpers'; const fullSeparatorString = ` ${MULTI_KEY_VISUAL_SEPARATOR} `; @@ -213,3 +213,63 @@ export function isSortableByColumn(layer: IndexPatternLayer, columnId: string) { !isReferenced(layer, columnId) ); } + +export function isScriptedField(field: IndexPatternField): boolean; +export function isScriptedField(fieldName: string, indexPattern: IndexPattern): boolean; +export function isScriptedField( + fieldName: string | IndexPatternField, + indexPattern?: IndexPattern +) { + if (typeof fieldName === 'string') { + const field = indexPattern?.getFieldByName(fieldName); + return field && field.scripted; + } + return fieldName.scripted; +} + +export function getFieldsByValidationState( + newIndexPattern: IndexPattern, + column?: GenericIndexPatternColumn, + field?: string | IndexPatternField +): { + allFields: Array; + validFields: string[]; + invalidFields: string[]; +} { + const newFieldNames: string[] = []; + if (column && 'sourceField' in column) { + if (column.sourceField) { + newFieldNames.push(column.sourceField); + } + if (isColumnOfType('terms', column)) { + newFieldNames.push(...(column.params?.secondaryFields ?? [])); + } + } + if (field) { + newFieldNames.push(typeof field === 'string' ? field : field.name || field.displayName); + } + const newFields = newFieldNames.map((fieldName) => newIndexPattern.getFieldByName(fieldName)); + // lodash groupby does not provide the index arg, so had to write it manually :( + const validFields: string[] = []; + const invalidFields: string[] = []; + // mind to check whether a column was passed, in such case single term with scripted field is ok + const canAcceptScripted = Boolean(column && newFields.length === 1); + newFieldNames.forEach((fieldName, i) => { + const newField = newFields[i]; + const isValid = + newField && + supportedTypes.has(newField.type) && + newField.aggregatable && + (!newField.aggregationRestrictions || newField.aggregationRestrictions.terms) && + (canAcceptScripted || !isScriptedField(newField)); + + const arrayToPush = isValid ? validFields : invalidFields; + arrayToPush.push(fieldName); + }); + + return { + allFields: newFields, + validFields, + invalidFields, + }; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index 78129cc8c123..68df9ff444fc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -26,19 +26,26 @@ import type { DataType } from '../../../../types'; import { OperationDefinition } from '../index'; import { FieldBasedIndexPatternColumn } from '../column_types'; import { ValuesInput } from './values_input'; -import { getInvalidFieldMessage, isColumnOfType } from '../helpers'; -import { FieldInputs, MAX_MULTI_FIELDS_SIZE } from './field_inputs'; +import { getInvalidFieldMessage } from '../helpers'; +import { FieldInputs, getInputFieldErrorMessage, MAX_MULTI_FIELDS_SIZE } from './field_inputs'; import { FieldInput as FieldInputBase, getErrorMessage, } from '../../../dimension_panel/field_input'; import type { TermsIndexPatternColumn } from './types'; -import type { IndexPattern, IndexPatternField } from '../../../types'; +import type { IndexPatternField } from '../../../types'; import { getDisallowedTermsMessage, getMultiTermsScriptedFieldErrorMessage, + getFieldsByValidationState, isSortableByColumn, } from './helpers'; +import { + DEFAULT_MAX_DOC_COUNT, + DEFAULT_SIZE, + MAXIMUM_MAX_DOC_COUNT, + supportedTypes, +} from './constants'; export function supportsRarityRanking(field?: IndexPatternField) { // these es field types can't be sorted by rarity @@ -79,27 +86,12 @@ function ofName(name?: string, count: number = 0, rare: boolean = false) { }); } -function isScriptedField(field: IndexPatternField): boolean; -function isScriptedField(fieldName: string, indexPattern: IndexPattern): boolean; -function isScriptedField(fieldName: string | IndexPatternField, indexPattern?: IndexPattern) { - if (typeof fieldName === 'string') { - const field = indexPattern?.getFieldByName(fieldName); - return field && field.scripted; - } - return fieldName.scripted; -} - // It is not always possible to know if there's a numeric field, so just ignore it for now function getParentFormatter(params: Partial) { return { id: params.secondaryFields?.length ? 'multi_terms' : 'terms' }; } const idPrefix = htmlIdGenerator()(); -const DEFAULT_SIZE = 3; -// Elasticsearch limit -const MAXIMUM_MAX_DOC_COUNT = 100; -export const DEFAULT_MAX_DOC_COUNT = 1; -const supportedTypes = new Set(['string', 'boolean', 'number', 'ip']); export const termsOperation: OperationDefinition = { type: 'terms', @@ -112,30 +104,18 @@ export const termsOperation: OperationDefinition { - const secondaryFields = new Set(); - if (targetColumn.params?.secondaryFields?.length) { - targetColumn.params.secondaryFields.forEach((fieldName) => { - if (!isScriptedField(fieldName, indexPattern)) { - secondaryFields.add(fieldName); - } - }); - } - if (sourceColumn && 'sourceField' in sourceColumn && sourceColumn?.sourceField) { - if (!isScriptedField(sourceColumn.sourceField, indexPattern)) { - secondaryFields.add(sourceColumn.sourceField); - } - } - if (sourceColumn && isColumnOfType('terms', sourceColumn)) { - if (sourceColumn?.params?.secondaryFields?.length) { - sourceColumn.params.secondaryFields.forEach((fieldName) => { - if (!isScriptedField(fieldName, indexPattern)) { - secondaryFields.add(fieldName); - } - }); - } - } - if (field && !isScriptedField(field)) { - secondaryFields.add(field.name); + const secondaryFields = new Set( + getFieldsByValidationState(indexPattern, targetColumn).validFields + ); + + const validFieldsToAdd = getFieldsByValidationState( + indexPattern, + sourceColumn, + field + ).validFields; + + for (const validField of validFieldsToAdd) { + secondaryFields.add(validField); } // remove the sourceField secondaryFields.delete(targetColumn.sourceField); @@ -155,27 +135,12 @@ export const termsOperation: OperationDefinition('terms', sourceColumn)) { - counter += - sourceColumn.params.secondaryFields?.filter((f) => { - return !isScriptedField(f, indexPattern) && !originalTerms.has(f); - }).length ?? 0; - } - } - } + const { validFields } = getFieldsByValidationState(indexPattern, sourceColumn, field); + const counter = validFields.filter((fieldName) => !originalTerms.has(fieldName)).length; // reject when there are no new fields to add if (!counter) { return false; @@ -209,14 +174,15 @@ export const termsOperation: OperationDefinition { + return getFieldsByValidationState(newIndexPattern, column).invalidFields; + }, isTransferable: (column, newIndexPattern) => { - const newField = newIndexPattern.getFieldByName(column.sourceField); + const { allFields, invalidFields } = getFieldsByValidationState(newIndexPattern, column); return Boolean( - newField && - supportedTypes.has(newField.type) && - newField.aggregatable && - (!newField.aggregationRestrictions || newField.aggregationRestrictions.terms) && + allFields.length && + invalidFields.length === 0 && (!column.params.otherBucket || !newIndexPattern.hasRestrictions) ); }, @@ -322,11 +288,13 @@ export const termsOperation: OperationDefinition ); @@ -673,6 +639,7 @@ export const termsOperation: OperationDefinition { ).toBeTruthy(); }); - it('should show an error message when field is invalid', () => { + it('should show an error message when first field is invalid', () => { const updateLayerSpy = jest.fn(); const existingFields = getExistingFields(); const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); @@ -1049,7 +1050,7 @@ describe('terms', () => { ).toBe('Invalid field. Check your data view or pick another field.'); }); - it('should show an error message when field is not supported', () => { + it('should show an error message when first field is not supported', () => { const updateLayerSpy = jest.fn(); const existingFields = getExistingFields(); const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); @@ -1083,6 +1084,74 @@ describe('terms', () => { ).toBe('This field does not work with the selected function.'); }); + it('should show an error message when any field but the first is invalid', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + layer.columns.col1 = { + label: 'Top value of geo.src + 1 other', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + secondaryFields: ['unsupported'], + }, + sourceField: 'geo.src', + } as TermsIndexPatternColumn; + const instance = mount( + + ); + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('Invalid field: "unsupported". Check your data view or pick another field.'); + }); + + it('should show an error message when any field but the first is not supported', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + layer.columns.col1 = { + label: 'Top value of geo.src + 1 other', + dataType: 'date', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + secondaryFields: ['timestamp'], + }, + sourceField: 'geo.src', + } as TermsIndexPatternColumn; + const instance = mount( + + ); + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('Invalid field: "timestamp". Check your data view or pick another field.'); + }); + it('should render the an add button for single layer, but no other hints', () => { const updateLayerSpy = jest.fn(); const existingFields = getExistingFields(); @@ -1370,6 +1439,38 @@ describe('terms', () => { ); }); + it('should filter fields with unsupported types when in multi terms mode', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; + const instance = mount( + + ); + + // get inner instance + expect( + instance.find('[data-test-subj="indexPattern-dimension-field-0"]').at(1).prop('options') + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + options: expect.arrayContaining([ + expect.not.objectContaining({ 'data-test-subj': 'lns-fieldOption-timestamp' }), + ]), + }), + ]) + ); + }); + it('should limit the number of multiple fields', () => { const updateLayerSpy = jest.fn(); const existingFields = getExistingFields(); @@ -1481,6 +1582,59 @@ describe('terms', () => { }) ); }); + + it('should preserve custom label when set by the user', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + layer.columns.col1 = { + label: 'MyCustomLabel', + customLabel: true, + dataType: 'string', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + secondaryFields: ['geo.src'], + }, + sourceField: 'source', + } as TermsIndexPatternColumn; + let instance = mount( + + ); + // add a new field + act(() => { + instance.find('[data-test-subj="indexPattern-terms-add-field"]').first().simulate('click'); + }); + instance = instance.update(); + + act(() => { + instance.find(EuiComboBox).last().prop('onChange')!([ + { value: { type: 'field', field: 'bytes' }, label: 'bytes' }, + ]); + }); + + expect(updateLayerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + label: 'MyCustomLabel', + }), + }), + }) + ); + }); }); describe('param editor', () => { @@ -2297,4 +2451,47 @@ describe('terms', () => { }); }); }); + + describe('getNonTransferableFields', () => { + it('should return empty array if all fields are transferable', () => { + expect( + termsOperation.getNonTransferableFields?.( + createMultiTermsColumn(['source']), + defaultProps.indexPattern + ) + ).toEqual([]); + expect( + termsOperation.getNonTransferableFields?.( + createMultiTermsColumn(['source', 'bytes']), + defaultProps.indexPattern + ) + ).toEqual([]); + expect( + termsOperation.getNonTransferableFields?.( + createMultiTermsColumn([]), + defaultProps.indexPattern + ) + ).toEqual([]); + expect( + termsOperation.getNonTransferableFields?.( + createMultiTermsColumn(['source', 'geo.src']), + defaultProps.indexPattern + ) + ).toEqual([]); + }); + it('should return only non transferable fields (invalid or not existence)', () => { + expect( + termsOperation.getNonTransferableFields?.( + createMultiTermsColumn(['source', 'timestamp']), + defaultProps.indexPattern + ) + ).toEqual(['timestamp']); + expect( + termsOperation.getNonTransferableFields?.( + createMultiTermsColumn(['source', 'unsupported']), + defaultProps.indexPattern + ) + ).toEqual(['unsupported']); + }); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index 1b432c4a34ad..f7a8df3d5ef1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -16,6 +16,8 @@ import { updateLayerIndexPattern, getErrorMessages, hasTermsWithManyBuckets, + isReferenced, + getReferenceRoot, } from './layer_helpers'; import { operationDefinitionMap, OperationType } from '../operations'; import { TermsIndexPatternColumn } from './definitions/terms'; @@ -3179,4 +3181,162 @@ describe('state_helpers', () => { expect(hasTermsWithManyBuckets(layer)).toBeTruthy(); }); }); + + describe('isReferenced', () => { + it('should return false for top column which has references', () => { + const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: [], + columns: { + col1: { + operationType: 'managedReference', + references: ['col2'], + label: '', + dataType: 'number', + isBucketed: false, + }, + col2: { + operationType: 'testReference', + references: [], + label: '', + dataType: 'number', + isBucketed: false, + }, + }, + }; + expect(isReferenced(layer, 'col1')).toBeFalsy(); + }); + + it('should return true for referenced column', () => { + const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: [], + columns: { + col1: { + operationType: 'managedReference', + references: ['col2'], + label: '', + dataType: 'number', + isBucketed: false, + }, + col2: { + operationType: 'testReference', + references: [], + label: '', + dataType: 'number', + isBucketed: false, + }, + }, + }; + expect(isReferenced(layer, 'col2')).toBeTruthy(); + }); + }); + + describe('getReferenceRoot', () => { + it("should just return the column id itself if it's not a referenced column", () => { + const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: [], + columns: { + col1: { + operationType: 'managedReference', + references: ['col2'], + label: '', + dataType: 'number', + isBucketed: false, + }, + col2: { + operationType: 'testReference', + references: [], + label: '', + dataType: 'number', + isBucketed: false, + }, + }, + }; + expect(getReferenceRoot(layer, 'col1')).toEqual('col1'); + }); + + it('should return the top column if a referenced column is passed', () => { + const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: [], + columns: { + col1: { + operationType: 'managedReference', + references: ['col2'], + label: '', + dataType: 'number', + isBucketed: false, + }, + col2: { + operationType: 'testReference', + references: [], + label: '', + dataType: 'number', + isBucketed: false, + }, + }, + }; + expect(getReferenceRoot(layer, 'col2')).toEqual('col1'); + }); + + it('should work for a formula chain', () => { + const math = { + customLabel: true, + dataType: 'number' as const, + isBucketed: false, + label: 'math', + operationType: 'math' as const, + }; + const layer: IndexPatternLayer = { + indexPatternId: '', + columnOrder: [], + columns: { + source: { + dataType: 'number' as const, + isBucketed: false, + label: 'Formula', + operationType: 'formula' as const, + params: { + formula: 'moving_average(sum(bytes), window=5)', + isFormulaBroken: false, + }, + references: ['formulaX3'], + } as FormulaIndexPatternColumn, + formulaX0: { + customLabel: true, + dataType: 'number' as const, + isBucketed: false, + label: 'formulaX0', + operationType: 'sum' as const, + scale: 'ratio' as const, + sourceField: 'bytes', + }, + formulaX1: { + ...math, + label: 'formulaX1', + references: ['formulaX0'], + params: { tinymathAst: 'formulaX0' }, + } as MathIndexPatternColumn, + formulaX2: { + customLabel: true, + dataType: 'number' as const, + isBucketed: false, + label: 'formulaX2', + operationType: 'moving_average' as const, + params: { window: 5 }, + references: ['formulaX1'], + } as MovingAverageIndexPatternColumn, + formulaX3: { + ...math, + label: 'formulaX3', + references: ['formulaX2'], + params: { tinymathAst: 'formulaX2' }, + } as MathIndexPatternColumn, + }, + }; + expect(getReferenceRoot(layer, 'formulaX0')).toEqual('source'); + }); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index ab7ee8992f2f..e44a49b4f377 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -8,6 +8,7 @@ import { partition, mapValues, pickBy, isArray } from 'lodash'; import { CoreStart } from 'kibana/public'; import { Query } from 'src/plugins/data/common'; +import memoizeOne from 'memoize-one'; import type { VisualizeEditorLayersContext } from '../../../../../../src/plugins/visualizations/public'; import type { DatasourceFixAction, @@ -227,15 +228,12 @@ export function insertNewColumn({ const possibleOperation = operationDefinition.getPossibleOperation(); const isBucketed = Boolean(possibleOperation?.isBucketed); const addOperationFn = isBucketed ? addBucket : addMetric; + const buildColumnFn = columnParams + ? operationDefinition.buildColumn({ ...baseOptions, layer }, columnParams) + : operationDefinition.buildColumn({ ...baseOptions, layer }); return updateDefaultLabels( - addOperationFn( - layer, - operationDefinition.buildColumn({ ...baseOptions, layer }), - columnId, - visualizationGroups, - targetGroup - ), + addOperationFn(layer, buildColumnFn, columnId, visualizationGroups, targetGroup), indexPattern ); } @@ -1413,6 +1411,35 @@ export function isReferenced(layer: IndexPatternLayer, columnId: string): boolea return allReferences.includes(columnId); } +const computeReferenceLookup = memoizeOne((layer: IndexPatternLayer): Record => { + // speed up things for deep chains as in formula + const refLookup: Record = {}; + for (const [parentId, col] of Object.entries(layer.columns)) { + if ('references' in col) { + for (const colId of col.references) { + refLookup[colId] = parentId; + } + } + } + return refLookup; +}); + +/** + * Given a columnId, returns the visible root column id for it + * This is useful to map internal properties of referenced columns to the visible column + * @param layer + * @param columnId + * @returns id of the reference root + */ +export function getReferenceRoot(layer: IndexPatternLayer, columnId: string): string { + const refLookup = computeReferenceLookup(layer); + let currentId = columnId; + while (isReferenced(layer, currentId)) { + currentId = refLookup[currentId]; + } + return currentId; +} + export function getReferencedColumnIds(layer: IndexPatternLayer, columnId: string): string[] { const referencedIds: string[] = []; function collect(id: string) { @@ -1672,12 +1699,13 @@ export function computeLayerFromContext( export function getSplitByTermsLayer( indexPattern: IndexPattern, - splitField: IndexPatternField, + splitFields: IndexPatternField[], dateField: IndexPatternField | undefined, layer: VisualizeEditorLayersContext ): IndexPatternLayer { - const { termsParams, metrics, timeInterval, splitWithDateHistogram } = layer; + const { termsParams, metrics, timeInterval, splitWithDateHistogram, dropPartialBuckets } = layer; const copyMetricsArray = [...metrics]; + const computedLayer = computeLayerFromContext( metrics.length === 1, copyMetricsArray, @@ -1686,7 +1714,9 @@ export function getSplitByTermsLayer( layer.label ); + const [baseField, ...secondaryFields] = splitFields; const columnId = generateId(); + let termsLayer = insertNewColumn({ op: splitWithDateHistogram ? 'date_histogram' : 'terms', layer: insertNewColumn({ @@ -1698,13 +1728,26 @@ export function getSplitByTermsLayer( visualizationGroups: [], columnParams: { interval: timeInterval, + dropPartials: dropPartialBuckets, }, }), columnId, - field: splitField, + field: baseField, indexPattern, visualizationGroups: [], }); + + if (secondaryFields.length) { + termsLayer = updateColumnParam({ + layer: termsLayer, + columnId, + paramName: 'secondaryFields', + value: secondaryFields.map((i) => i.name), + }); + + termsLayer = updateDefaultLabels(termsLayer, indexPattern); + } + const termsColumnParams = termsParams as TermsIndexPatternColumn['params']; if (termsColumnParams) { for (const [param, value] of Object.entries(termsColumnParams)) { @@ -1739,7 +1782,7 @@ export function getSplitByFiltersLayer( dateField: IndexPatternField | undefined, layer: VisualizeEditorLayersContext ): IndexPatternLayer { - const { splitFilters, metrics, timeInterval } = layer; + const { splitFilters, metrics, timeInterval, dropPartialBuckets } = layer; const filterParams = splitFilters?.map((param) => { const query = param.filter ? param.filter.query : ''; const language = param.filter ? param.filter.language : 'kuery'; @@ -1771,6 +1814,7 @@ export function getSplitByFiltersLayer( visualizationGroups: [], columnParams: { interval: timeInterval, + dropPartials: dropPartialBuckets, }, }), columnId, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx index 1b418ee3b408..2379ca8808be 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx @@ -18,6 +18,7 @@ export const QueryInput = ({ isInvalid, onSubmit, disableAutoFocus, + ['data-test-subj']: dataTestSubj, }: { value: Query; onChange: (input: Query) => void; @@ -25,12 +26,13 @@ export const QueryInput = ({ isInvalid: boolean; onSubmit: () => void; disableAutoFocus?: boolean; + 'data-test-subj'?: string; }) => { const { inputValue, handleInputChange } = useDebouncedValue({ value, onChange }); return ( input).filter(({ query }) => query?.trim() && query !== '*'); +} + +/** + * Given an Interval column in range mode transform the ranges into KQL queries + */ +function extractQueriesFromRanges(column: RangeIndexPatternColumn) { + return column.params.ranges + .map(({ from, to }) => { + let rangeQuery = ''; + if (from != null && isFinite(from)) { + rangeQuery += `${column.sourceField} >= ${from}`; + } + if (to != null && isFinite(to)) { + if (rangeQuery.length) { + rangeQuery += ' AND '; + } + rangeQuery += `${column.sourceField} <= ${to}`; + } + return { + query: rangeQuery, + language: 'kuery', + }; + }) + .filter(({ query }) => query?.trim()); +} + +/** + * Given an Terms/Top values column transform each entry into a "field: term" KQL query + * This works also for multi-terms variant + */ +function extractQueriesFromTerms( + column: TermsIndexPatternColumn, + colId: string, + data: NonNullable[string] +): Query[] { + const fields = [column.sourceField] + .concat(column.params.secondaryFields || []) + .filter(Boolean) as string[]; + + // extract the filters from the columns of the activeData + const queries = data.rows + .map(({ [colId]: value }) => { + if (value == null) { + return; + } + if (typeof value !== 'string' && Array.isArray(value.keys)) { + return value.keys + .map( + (term: string, index: number) => + `${fields[index]}: ${`"${term === '' ? escape(term) : term}"`}` + ) + .join(' AND '); + } + return `${column.sourceField}: ${`"${value === '' ? escape(value) : value}"`}`; + }) + .filter(Boolean) as string[]; + + // dedup queries before returning + return [...new Set(queries)].map((query) => ({ language: 'kuery', query })); +} + +/** + * Used for a Terms column to decide whether to use a simple existence query (fallback) instead + * of more specific queries. + * The check targets the scenarios where no data is available, or when there's a transposed table + * and it's not yet possible to track it back to the original table + */ +function shouldUseTermsFallback( + data: NonNullable[string] | undefined, + colId: string +) { + const dataId = data?.columns.find(({ id }) => getOriginalId(id) === colId)?.id; + return !dataId || dataId !== colId; +} + +/** + * Collect filters from metrics: + * * if there's at least one unfiltered metric, then just return an empty list of filters + * * otherwise get all the filters, with the only exception of those from formula (referenced columns will have it anyway) + */ +function collectFiltersFromMetrics(layer: IndexPatternLayer, columnIds: string[]) { + // Isolate filtered metrics first + // mind to ignore non-filterable columns and formula columns + const metricColumns = Object.keys(layer.columns).filter((colId) => { + const column = layer.columns[colId]; + const operationDefinition = operationDefinitionMap[column?.operationType]; + return ( + !column?.isBucketed && + // global filters for formulas are picked up by referenced columns + !isColumnOfType('formula', column) && + operationDefinition?.filterable + ); + }); + const { filtered = [], unfiltered = [] } = groupBy(metricColumns, (colId) => + layer.columns[colId]?.filter ? 'filtered' : 'unfiltered' + ); + + // extract filters from filtered metrics + // consider all the columns, included referenced ones to cover also the formula case + return ( + filtered + // if there are metric columns not filtered, then ignore filtered columns completely + .filter(() => !unfiltered.length) + .map((colId) => layer.columns[colId]?.filter) + // filter out empty filters as well + .filter((filter) => filter?.query?.trim()) as Query[] + ); +} + +interface GroupedQueries { + kuery?: Query[]; + lucene?: Query[]; +} + +function collectOnlyValidQueries( + filteredQueries: GroupedQueries, + operationQueries: GroupedQueries[], + queryLanguage: 'kuery' | 'lucene' +) { + return [ + filteredQueries[queryLanguage], + ...operationQueries.map(({ [queryLanguage]: filter }) => filter), + ].filter((filters) => filters?.length) as Query[][]; +} + +export function getFiltersInLayer( + layer: IndexPatternLayer, + columnIds: string[], + layerData: NonNullable[string] | undefined +) { + const filtersFromMetricsByLanguage = groupBy( + collectFiltersFromMetrics(layer, columnIds), + 'language' + ) as unknown as GroupedQueries; + + const filterOperation = columnIds + .map((colId) => { + const column = layer.columns[colId]; + + if (isColumnOfType('filters', column)) { + const groupsByLanguage = groupBy( + column.params.filters, + ({ input }) => input.language + ) as Record<'lucene' | 'kuery', FiltersIndexPatternColumn['params']['filters']>; + + return { + kuery: extractQueriesFromFilters(groupsByLanguage.kuery), + lucene: extractQueriesFromFilters(groupsByLanguage.lucene), + }; + } + + if (isColumnOfType('range', column) && column.sourceField) { + return { + kuery: extractQueriesFromRanges(column), + }; + } + + if ( + isColumnOfType('terms', column) && + !(column.params.otherBucket || column.params.missingBucket) + ) { + if (!layerData || shouldUseTermsFallback(layerData, colId)) { + const fields = operationDefinitionMap[column.operationType]!.getCurrentFields!(column); + return { + kuery: fields.map((field) => ({ + query: `${field}: *`, + language: 'kuery', + })), + }; + } + + return { + kuery: extractQueriesFromTerms(column, colId, layerData), + }; + } + }) + .filter(Boolean) as GroupedQueries[]; + return { + kuery: collectOnlyValidQueries(filtersFromMetricsByLanguage, filterOperation, 'kuery'), + lucene: collectOnlyValidQueries(filtersFromMetricsByLanguage, filterOperation, 'lucene'), + }; +} diff --git a/x-pack/plugins/lens/public/metric_visualization/auto_scale.test.tsx b/x-pack/plugins/lens/public/metric_visualization/auto_scale.test.tsx deleted file mode 100644 index b7584ffa9eb7..000000000000 --- a/x-pack/plugins/lens/public/metric_visualization/auto_scale.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { computeScale, AutoScale } from './auto_scale'; -import { render } from 'enzyme'; - -const mockElement = (clientWidth = 100, clientHeight = 200) => ({ - clientHeight, - clientWidth, -}); - -describe('AutoScale', () => { - describe('computeScale', () => { - it('is 1 if any element is null', () => { - expect(computeScale(null, null)).toBe(1); - expect(computeScale(mockElement(), null)).toBe(1); - expect(computeScale(null, mockElement())).toBe(1); - }); - - it('is never over 1', () => { - expect(computeScale(mockElement(2000, 2000), mockElement(1000, 1000))).toBe(1); - }); - - it('is never under 0.3 in default case', () => { - expect(computeScale(mockElement(2000, 1000), mockElement(1000, 10000))).toBe(0.3); - }); - - it('is never under specified min scale if specified', () => { - expect(computeScale(mockElement(2000, 1000), mockElement(1000, 10000), 0.1)).toBe(0.1); - }); - - it('is the lesser of the x or y scale', () => { - expect(computeScale(mockElement(2000, 2000), mockElement(3000, 5000))).toBe(0.4); - expect(computeScale(mockElement(2000, 3000), mockElement(4000, 3200))).toBe(0.5); - }); - }); - - describe('AutoScale', () => { - it('renders', () => { - expect( - render( - -

    Hoi!

    -
    - ) - ).toMatchInlineSnapshot(` -
    -
    -

    - Hoi! -

    -
    -
    - `); - }); - }); -}); diff --git a/x-pack/plugins/lens/public/metric_visualization/auto_scale.tsx b/x-pack/plugins/lens/public/metric_visualization/auto_scale.tsx deleted file mode 100644 index 7e47405f9258..000000000000 --- a/x-pack/plugins/lens/public/metric_visualization/auto_scale.tsx +++ /dev/null @@ -1,134 +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 from 'react'; -import { throttle } from 'lodash'; -import classNames from 'classnames'; -import { EuiResizeObserver } from '@elastic/eui'; -import { MetricState } from '../../common/expressions'; - -interface Props extends React.HTMLAttributes { - children: React.ReactNode | React.ReactNode[]; - minScale?: number; - size?: MetricState['size']; - titlePosition?: MetricState['titlePosition']; - textAlign?: MetricState['textAlign']; -} - -interface State { - scale: number; -} - -export class AutoScale extends React.Component { - private child: Element | null = null; - private parent: Element | null = null; - private scale: () => void; - - constructor(props: Props) { - super(props); - - this.scale = throttle(() => { - const scale = computeScale(this.parent, this.child, this.props.minScale); - - // Prevent an infinite render loop - if (this.state.scale !== scale) { - this.setState({ scale }); - } - }); - - // An initial scale of 0 means we always redraw - // at least once, which is sub-optimal, but it - // prevents an annoying flicker. - this.state = { scale: 0 }; - } - - setParent = (el: Element | null) => { - if (el && this.parent !== el) { - this.parent = el; - setTimeout(() => this.scale()); - } - }; - - setChild = (el: Element | null) => { - if (el && this.child !== el) { - this.child = el; - setTimeout(() => this.scale()); - } - }; - - render() { - const { children, minScale, size, textAlign, titlePosition, ...rest } = this.props; - const { scale } = this.state; - const style = this.props.style || {}; - - return ( - - {(resizeRef) => ( -
    { - this.setParent(el); - resizeRef(el); - }} - style={{ - ...style, - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - maxWidth: '100%', - maxHeight: '100%', - overflow: 'hidden', - lineHeight: 1.5, - }} - > -
    - {children} -
    -
    - )} -
    - ); - } -} - -interface ClientDimensionable { - clientWidth: number; - clientHeight: number; -} - -const MAX_SCALE = 1; -const MIN_SCALE = 0.3; - -/** - * computeScale computes the ratio by which the child needs to shrink in order - * to fit into the parent. This function is only exported for testing purposes. - */ -export function computeScale( - parent: ClientDimensionable | null, - child: ClientDimensionable | null, - minScale: number = MIN_SCALE -) { - if (!parent || !child) { - return 1; - } - - const scaleX = parent.clientWidth / child.clientWidth; - const scaleY = parent.clientHeight / child.clientHeight; - - return Math.max(Math.min(MAX_SCALE, Math.min(scaleX, scaleY)), minScale); -} diff --git a/x-pack/plugins/lens/public/metric_visualization/dimension_editor.test.tsx b/x-pack/plugins/lens/public/metric_visualization/dimension_editor.test.tsx index b296313086d7..478c0fff4020 100644 --- a/x-pack/plugins/lens/public/metric_visualization/dimension_editor.test.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/dimension_editor.test.tsx @@ -16,7 +16,7 @@ import { ColorMode, PaletteOutput, PaletteRegistry } from 'src/plugins/charts/pu import { act } from 'react-dom/test-utils'; import { CustomizablePalette, PalettePanelContainer } from '../shared_components'; import { CustomPaletteParams, layerTypes } from '../../common'; -import { MetricState } from '../../common/expressions'; +import type { MetricState } from '../../common/types'; // mocking random id generator function jest.mock('@elastic/eui', () => { diff --git a/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx b/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx index 77c6e909bc67..83faac5cc802 100644 --- a/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx @@ -17,7 +17,8 @@ import { i18n } from '@kbn/i18n'; import React, { useCallback, useState } from 'react'; import { ColorMode } from '../../../../../src/plugins/charts/common'; import type { PaletteRegistry } from '../../../../../src/plugins/charts/public'; -import { isNumericFieldForDatatable, MetricState } from '../../common/expressions'; +import type { MetricState } from '../../common/types'; +import { isNumericFieldForDatatable } from '../../common/expressions'; import { applyPaletteParams, CustomizablePalette, diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.scss b/x-pack/plugins/lens/public/metric_visualization/expression.scss deleted file mode 100644 index fdd22690207f..000000000000 --- a/x-pack/plugins/lens/public/metric_visualization/expression.scss +++ /dev/null @@ -1,87 +0,0 @@ -.lnsMetricExpression__container { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - height: 100%; - text-align: center; - - .lnsMetricExpression__value { - font-size: $euiFontSizeXXL * 2; - font-weight: $euiFontWeightSemiBold; - border-radius: $euiBorderRadius; - } - - .lnsMetricExpression__title { - font-size: $euiFontSizeXXL; - color: $euiTextColor; - &.reverseOrder { - order: 1; - } - } - - .lnsMetricExpression__containerScale { - display: flex; - align-items: center; - flex-direction: column; - &.alignLeft { - align-items: start; - } - &.alignRight { - align-items: end; - } - &.alignCenter { - align-items: center; - } - &.titleSizeXS { - .lnsMetricExpression__title { - font-size: $euiFontSizeXS; - } - .lnsMetricExpression__value { - font-size: $euiFontSizeXS * 2; - } - } - &.titleSizeS { - .lnsMetricExpression__title { - font-size: $euiFontSizeS; - } - .lnsMetricExpression__value { - font-size: $euiFontSizeM * 2.25; - } - } - &.titleSizeM { - .lnsMetricExpression__title { - font-size: $euiFontSizeM; - } - .lnsMetricExpression__value { - font-size: $euiFontSizeL * 2; - } - } - &.titleSizeL { - .lnsMetricExpression__title { - font-size: $euiFontSizeL; - } - .lnsMetricExpression__value { - font-size: $euiFontSizeXL * 2; - } - } - &.titleSizeXL { - .lnsMetricExpression__title { - font-size: $euiFontSizeXL; - } - .lnsMetricExpression__value { - font-size: $euiFontSizeXXL * 2; - } - } - - &.titleSizeXXL { - .lnsMetricExpression__title { - font-size: $euiFontSizeXXL; - } - .lnsMetricExpression__value { - font-size: $euiFontSizeXXL * 3; - } - } - } -} diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx deleted file mode 100644 index c37e0c6c660c..000000000000 --- a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx +++ /dev/null @@ -1,552 +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 { MetricChart } from './expression'; -import { MetricConfig, metricChart } from '../../common/expressions'; -import React from 'react'; -import { shallow, mount } from 'enzyme'; -import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; -import type { IFieldFormat } from '../../../../../src/plugins/field_formats/common'; -import { layerTypes } from '../../common'; -import type { LensMultiTable } from '../../common'; -import { IUiSettingsClient } from 'kibana/public'; -import { ColorMode } from 'src/plugins/charts/common'; - -function sampleArgs() { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - l1: { - type: 'datatable', - columns: [ - // Simulating a calculated column like a formula - { id: 'a', name: 'a', meta: { type: 'string', params: { id: 'string' } } }, - { id: 'b', name: 'b', meta: { type: 'string' } }, - { - id: 'c', - name: 'c', - meta: { type: 'number', params: { id: 'percent', params: { format: '0.000%' } } }, - }, - ], - rows: [{ a: 'last', b: 'last', c: 3 }], - }, - }, - }; - - const args: MetricConfig = { - accessor: 'c', - layerId: 'l1', - layerType: layerTypes.DATA, - title: 'My fanci metric chart', - description: 'Fancy chart description', - metricTitle: 'My fanci metric chart', - mode: 'full', - colorMode: ColorMode.None, - palette: { type: 'palette', name: 'status' }, - }; - - const noAttributesArgs: MetricConfig = { - accessor: 'c', - layerId: 'l1', - layerType: layerTypes.DATA, - title: '', - description: '', - metricTitle: 'My fanci metric chart', - mode: 'full', - colorMode: ColorMode.None, - palette: { type: 'palette', name: 'status' }, - }; - - return { data, args, noAttributesArgs }; -} - -describe('metric_expression', () => { - describe('metricChart', () => { - test('it renders with the specified data and args', () => { - const { data, args } = sampleArgs(); - const result = metricChart.fn(data, args, createMockExecutionContext()); - - expect(result).toEqual({ - type: 'render', - as: 'lens_metric_chart_renderer', - value: { data, args }, - }); - }); - }); - - describe('MetricChart component', () => { - test('it renders all attributes when passed (title, description, metricTitle, value)', () => { - const { data, args } = sampleArgs(); - - expect( - shallow( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ) - ).toMatchInlineSnapshot(` - - -
    - My fanci metric chart -
    -
    - 3 -
    -
    -
    - `); - }); - - test('it renders strings', () => { - const { data, args } = sampleArgs(); - args.accessor = 'a'; - - expect( - shallow( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ) - ).toMatchInlineSnapshot(` - - -
    - My fanci metric chart -
    -
    - last -
    -
    -
    - `); - }); - - test('it renders only chart content when title and description are empty strings', () => { - const { data, noAttributesArgs } = sampleArgs(); - - expect( - shallow( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ) - ).toMatchInlineSnapshot(` - - -
    - My fanci metric chart -
    -
    - 3 -
    -
    -
    - `); - }); - - test('it does not render metricTitle in reduced mode', () => { - const { data, noAttributesArgs } = sampleArgs(); - - expect( - shallow( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ) - ).toMatchInlineSnapshot(` - - -
    - 3 -
    -
    -
    - `); - }); - - test('it renders an EmptyPlaceholder when no tables is passed as data', () => { - const { data, noAttributesArgs } = sampleArgs(); - - expect( - shallow( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ) - ).toMatchInlineSnapshot(` - - - - `); - }); - - test('it renders an EmptyPlaceholder when null value is passed as data', () => { - const { data, noAttributesArgs } = sampleArgs(); - - data.tables.l1.rows[0].c = null; - - expect( - shallow( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ) - ).toMatchInlineSnapshot(` - - - - `); - }); - - test('it renders 0 value', () => { - const { data, noAttributesArgs } = sampleArgs(); - - data.tables.l1.rows[0].c = 0; - - expect( - shallow( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ) - ).toMatchInlineSnapshot(` - - -
    - My fanci metric chart -
    -
    - 0 -
    -
    -
    - `); - }); - - test('it finds the right column to format', () => { - const { data, args } = sampleArgs(); - const factory = jest.fn(() => ({ convert: (x) => x } as IFieldFormat)); - - shallow( - - ); - expect(factory).toHaveBeenCalledWith({ id: 'percent', params: { format: '0.000%' } }); - }); - - test('it renders the correct color styling for numeric value if coloring config is passed', () => { - const { data, args } = sampleArgs(); - - args.colorMode = ColorMode.Labels; - args.palette.params = { - rangeMin: 0, - rangeMax: 400, - stops: [100, 200, 400], - gradient: false, - range: 'number', - colors: ['red', 'yellow', 'green'], - }; - - const instance = mount( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ); - - expect( - instance.find('[data-test-subj="lnsVisualizationContainer"]').first().prop('style') - ).toEqual( - expect.objectContaining({ - color: 'red', - }) - ); - }); - - test('it renders no color styling for numeric value if value is lower then rangeMin and continuity is "above"', () => { - const { data, args } = sampleArgs(); - - data.tables.l1.rows[0].c = -1; - args.colorMode = ColorMode.Labels; - args.palette.params = { - rangeMin: 0, - rangeMax: 400, - stops: [100, 200, 400], - gradient: false, - range: 'number', - colors: ['red', 'yellow', 'green'], - continuity: 'above', - }; - - const instance = mount( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ); - - expect( - instance.find('[data-test-subj="lnsVisualizationContainer"]').first().prop('style') - ).not.toEqual( - expect.objectContaining({ - color: expect.any(String), - }) - ); - }); - test('it renders no color styling for numeric value if value is higher than rangeMax and continuity is "below"', () => { - const { data, args } = sampleArgs(); - - data.tables.l1.rows[0].c = 500; - args.colorMode = ColorMode.Labels; - args.palette.params = { - rangeMin: 0, - rangeMax: 400, - stops: [100, 200, 400], - gradient: false, - range: 'number', - colors: ['red', 'yellow', 'green'], - continuity: 'below', - }; - - const instance = mount( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ); - - expect( - instance.find('[data-test-subj="lnsVisualizationContainer"]').first().prop('style') - ).not.toEqual( - expect.objectContaining({ - color: expect.any(String), - }) - ); - }); - - test('it renders no color styling for numeric value if value is higher than rangeMax', () => { - const { data, args } = sampleArgs(); - - data.tables.l1.rows[0].c = 500; - args.colorMode = ColorMode.Labels; - args.palette.params = { - rangeMin: 0, - rangeMax: 400, - stops: [100, 200, 400], - gradient: false, - range: 'number', - colors: ['red', 'yellow', 'green'], - }; - - const instance = mount( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ); - - expect( - instance.find('[data-test-subj="lnsVisualizationContainer"]').first().prop('style') - ).not.toEqual( - expect.objectContaining({ - color: expect.any(String), - }) - ); - }); - - test('it renders no color styling for numeric value if value is lower than rangeMin', () => { - const { data, args } = sampleArgs(); - - data.tables.l1.rows[0].c = -1; - args.colorMode = ColorMode.Labels; - args.palette.params = { - rangeMin: 0, - rangeMax: 400, - stops: [100, 200, 400], - gradient: false, - range: 'number', - colors: ['red', 'yellow', 'green'], - }; - - const instance = mount( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ); - - expect( - instance.find('[data-test-subj="lnsVisualizationContainer"]').first().prop('style') - ).not.toEqual( - expect.objectContaining({ - color: expect.any(String), - }) - ); - }); - - test('it renders the correct color styling for numeric value if user select auto detect max value', () => { - const { data, args } = sampleArgs(); - - data.tables.l1.rows[0].c = 500; - args.colorMode = ColorMode.Labels; - args.palette.params = { - rangeMin: 20, - rangeMax: Infinity, - stops: [100, 200, 400], - gradient: false, - range: 'number', - colors: ['red', 'yellow', 'green'], - }; - - const instance = mount( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ); - - expect( - instance.find('[data-test-subj="lnsVisualizationContainer"]').first().prop('style') - ).toEqual( - expect.objectContaining({ - color: 'green', - }) - ); - }); - - test('it renders the correct color styling for numeric value if user select auto detect min value', () => { - const { data, args } = sampleArgs(); - - data.tables.l1.rows[0].c = -1; - args.colorMode = ColorMode.Labels; - args.palette.params = { - rangeMin: -Infinity, - rangeMax: 400, - stops: [-Infinity, 200, 400], - gradient: false, - range: 'number', - colors: ['red', 'yellow', 'green'], - }; - - const instance = mount( - ({ convert: (x) => x } as IFieldFormat)} - uiSettings={{ get: jest.fn() } as unknown as IUiSettingsClient} - /> - ); - - expect( - instance.find('[data-test-subj="lnsVisualizationContainer"]').first().prop('style') - ).toEqual( - expect.objectContaining({ - color: 'red', - }) - ); - }); - }); -}); diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.tsx deleted file mode 100644 index 8a4228f0d502..000000000000 --- a/x-pack/plugins/lens/public/metric_visualization/expression.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import './expression.scss'; -import { I18nProvider } from '@kbn/i18n-react'; -import React from 'react'; -import ReactDOM from 'react-dom'; -import classNames from 'classnames'; -import { IUiSettingsClient, ThemeServiceStart } from 'kibana/public'; -import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; -import type { - ExpressionRenderDefinition, - IInterpreterRenderHandlers, -} from '../../../../../src/plugins/expressions/public'; -import { - ColorMode, - CustomPaletteState, - PaletteOutput, -} from '../../../../../src/plugins/charts/public'; -import { AutoScale } from './auto_scale'; -import { VisualizationContainer } from '../visualization_container'; -import { getContrastColor } from '../shared_components'; -import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; -import { LensIconChartMetric } from '../assets/chart_metric'; -import type { FormatFactory } from '../../common'; -import type { MetricChartProps } from '../../common/expressions'; -export type { MetricChartProps, MetricState, MetricConfig } from '../../common/expressions'; - -export const getMetricChartRenderer = ( - formatFactory: FormatFactory, - uiSettings: IUiSettingsClient, - theme: ThemeServiceStart -): ExpressionRenderDefinition => ({ - name: 'lens_metric_chart_renderer', - displayName: 'Metric chart', - help: 'Metric chart renderer', - validate: () => undefined, - reuseDomNode: true, - render: (domNode: Element, config: MetricChartProps, handlers: IInterpreterRenderHandlers) => { - ReactDOM.render( - - - - - , - domNode, - () => { - handlers.done(); - } - ); - handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); - }, -}); - -function getColorStyling( - value: number, - colorMode: ColorMode, - palette: PaletteOutput | undefined, - isDarkTheme: boolean -) { - if ( - colorMode === ColorMode.None || - !palette?.params || - !palette?.params.colors?.length || - isNaN(value) - ) { - return {}; - } - - const { rangeMin, rangeMax, stops, colors } = palette.params; - - if (value > rangeMax) { - return {}; - } - if (value < rangeMin) { - return {}; - } - const cssProp = colorMode === ColorMode.Background ? 'backgroundColor' : 'color'; - let rawIndex = stops.findIndex((v) => v > value); - - if (!isFinite(rangeMax) && value > stops[stops.length - 1]) { - rawIndex = stops.length - 1; - } - - // in this case first stop is -Infinity - if (!isFinite(rangeMin) && value < (isFinite(stops[0]) ? stops[0] : stops[1])) { - rawIndex = 0; - } - - const colorIndex = rawIndex; - - const color = colors[colorIndex]; - const styling = { - [cssProp]: color, - }; - if (colorMode === ColorMode.Background && color) { - // set to "euiTextColor" for both light and dark color, depending on the theme - styling.color = getContrastColor(color, isDarkTheme, 'euiTextColor', 'euiTextColor'); - } - return styling; -} - -export function MetricChart({ - data, - args, - formatFactory, - uiSettings, -}: MetricChartProps & { formatFactory: FormatFactory; uiSettings: IUiSettingsClient }) { - const { metricTitle, accessor, mode, colorMode, palette, titlePosition, textAlign, size } = args; - const firstTable = Object.values(data.tables)[0]; - - const getEmptyState = () => ( - - - - ); - - if (!accessor || !firstTable) { - return getEmptyState(); - } - - const column = firstTable.columns.find(({ id }) => id === accessor); - const row = firstTable.rows[0]; - if (!column || !row) { - return getEmptyState(); - } - const rawValue = row[accessor]; - - // NOTE: Cardinality and Sum never receives "null" as value, but always 0, even for empty dataset. - // Mind falsy values here as 0! - if (!['number', 'string'].includes(typeof rawValue)) { - return getEmptyState(); - } - - const value = - column && column.meta?.params - ? formatFactory(column.meta?.params).convert(rawValue) - : Number(Number(rawValue).toFixed(3)).toString(); - - const color = getColorStyling(rawValue, colorMode, palette, uiSettings.get('theme:darkMode')); - - return ( - - - {mode === 'full' && ( -
    - {metricTitle} -
    - )} -
    - {value} -
    -
    -
    - ); -} diff --git a/x-pack/plugins/lens/public/metric_visualization/index.ts b/x-pack/plugins/lens/public/metric_visualization/index.ts index 8740a3af5435..c96bb59151c2 100644 --- a/x-pack/plugins/lens/public/metric_visualization/index.ts +++ b/x-pack/plugins/lens/public/metric_visualization/index.ts @@ -6,30 +6,20 @@ */ import type { CoreSetup } from 'kibana/public'; -import type { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import type { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import type { EditorFrameSetup } from '../types'; -import type { FormatFactory } from '../../common'; export interface MetricVisualizationPluginSetupPlugins { - expressions: ExpressionsSetup; - formatFactory: FormatFactory; editorFrame: EditorFrameSetup; charts: ChartsPluginSetup; } export class MetricVisualization { - setup( - core: CoreSetup, - { expressions, formatFactory, editorFrame, charts }: MetricVisualizationPluginSetupPlugins - ) { + setup(core: CoreSetup, { editorFrame, charts }: MetricVisualizationPluginSetupPlugins) { editorFrame.registerVisualization(async () => { - const { getMetricVisualization, getMetricChartRenderer } = await import('../async_services'); + const { getMetricVisualization } = await import('../async_services'); const palettes = await charts.palettes.getPalettes(); - expressions.registerRenderer(() => - getMetricChartRenderer(formatFactory, core.uiSettings, core.theme) - ); return getMetricVisualization({ paletteService: palettes, theme: core.theme }); }); } diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/align_options.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/align_options.tsx index d97aa0866100..f2b97454df9d 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/align_options.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/align_options.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonGroup } from '@elastic/eui'; -import { MetricState } from '../../../common/expressions'; +import { MetricState } from '../../../common/types'; export interface TitlePositionProps { state: MetricState; diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/appearance_options_popover.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/appearance_options_popover.tsx index 973c1e0eedf3..280a036ab5da 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/appearance_options_popover.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/appearance_options_popover.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { ToolbarPopover, TooltipWrapper } from '../../shared_components'; import { TitlePositionOptions } from './title_position_option'; import { FramePublicAPI } from '../../types'; -import { MetricState } from '../../../common/expressions'; +import type { MetricState } from '../../../common/types'; import { TextFormattingOptions } from './text_formatting_options'; export interface VisualOptionsPopoverProps { diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/index.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/index.tsx index 5a6566a863a3..947115fcee5d 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/index.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/index.tsx @@ -8,9 +8,9 @@ import React, { memo } from 'react'; import { EuiFlexGroup, EuiFlexItem, htmlIdGenerator } from '@elastic/eui'; import type { VisualizationToolbarProps } from '../../types'; +import type { MetricState } from '../../../common/types'; import { AppearanceOptionsPopover } from './appearance_options_popover'; -import { MetricState } from '../../../common/expressions'; export const MetricToolbar = memo(function MetricToolbar( props: VisualizationToolbarProps diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/size_options.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/size_options.tsx index d33d72751a20..17142ab77ab3 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/size_options.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/size_options.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, EuiSuperSelect } from '@elastic/eui'; -import { MetricState } from '../../../common/expressions'; +import type { MetricState } from '../../../common/types'; export interface TitlePositionProps { state: MetricState; diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/text_formatting_options.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/text_formatting_options.tsx index 9215d27ebb87..13be9e59ec86 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/text_formatting_options.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/text_formatting_options.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { MetricState } from '../../../common/expressions'; +import type { MetricState } from '../../../common/types'; import { SizeOptions } from './size_options'; import { AlignOptions } from './align_options'; diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/title_position_option.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/title_position_option.tsx index c35567bb6953..acaa11f47722 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/title_position_option.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/title_position_option.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonGroup, EuiFormRow } from '@elastic/eui'; -import { MetricState } from '../../../common/expressions'; +import type { MetricState } from '../../../common/types'; export interface TitlePositionProps { state: MetricState; diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts index f4a97b724ae2..49346c48e9b1 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts @@ -6,7 +6,7 @@ */ import { SuggestionRequest, VisualizationSuggestion, TableSuggestion } from '../types'; -import type { MetricState } from '../../common/expressions'; +import type { MetricState } from '../../common/types'; import { layerTypes } from '../../common'; import { LensIconChartMetric } from '../assets/chart_metric'; import { supportedTypes } from './visualization'; diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_visualization.ts b/x-pack/plugins/lens/public/metric_visualization/metric_visualization.ts index 231b6bacbbe2..78f082b8c0e2 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_visualization.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_visualization.ts @@ -5,5 +5,4 @@ * 2.0. */ -export * from './expression'; export * from './visualization'; diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts index fedd58f3b080..c7e01b0c6a13 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts @@ -6,7 +6,7 @@ */ import { getMetricVisualization } from './visualization'; -import { MetricState } from '../../common/expressions'; +import type { MetricState } from '../../common/types'; import { layerTypes } from '../../common'; import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { generateId } from '../id_generator'; @@ -269,6 +269,8 @@ describe('metric_visualization', () => { dataType: 'number', isBucketed: false, label: 'shazm', + isStaticValue: false, + hasTimeShift: false, }; }, }; @@ -284,36 +286,93 @@ describe('metric_visualization', () => { "chain": Array [ Object { "arguments": Object { - "accessor": Array [ - "a", + "autoScale": Array [ + true, + ], + "colorFullBackground": Array [ + true, ], "colorMode": Array [ "None", ], - "description": Array [ - "", - ], - "metricTitle": Array [ - "shazm", + "font": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "align": Array [ + "center", + ], + "lHeight": Array [ + 127.5, + ], + "size": Array [ + 85, + ], + "sizeUnit": Array [ + "px", + ], + "weight": Array [ + "600", + ], + }, + "function": "font", + "type": "function", + }, + ], + "type": "expression", + }, ], - "mode": Array [ - "full", - ], - "palette": Array [], - "size": Array [ - "xl", + "labelFont": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "align": Array [ + "center", + ], + "lHeight": Array [ + 40.5, + ], + "size": Array [ + 27, + ], + "sizeUnit": Array [ + "px", + ], + }, + "function": "font", + "type": "function", + }, + ], + "type": "expression", + }, ], - "textAlign": Array [ - "center", + "labelPosition": Array [ + "bottom", ], - "title": Array [ - "", + "metric": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + "a", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, ], - "titlePosition": Array [ - "bottom", + "palette": Array [], + "showLabels": Array [ + true, ], }, - "function": "lens_metric_chart", + "function": "metricVis", "type": "function", }, ], diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index a3933cfa3a12..f3a5a1781841 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -8,23 +8,45 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n-react'; +import { euiThemeVars } from '@kbn/ui-theme'; import { render } from 'react-dom'; import { Ast } from '@kbn/interpreter'; import { ThemeServiceStart } from 'kibana/public'; import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; -import { ColorMode } from '../../../../../src/plugins/charts/common'; +import { + ColorMode, + CustomPaletteState, + PaletteOutput, +} from '../../../../../src/plugins/charts/common'; import { PaletteRegistry } from '../../../../../src/plugins/charts/public'; import { getSuggestions } from './metric_suggestions'; import { LensIconChartMetric } from '../assets/chart_metric'; import { Visualization, OperationMetadata, DatasourcePublicAPI } from '../types'; -import type { MetricConfig, MetricState } from '../../common/expressions'; +import type { MetricState } from '../../common/types'; import { layerTypes } from '../../common'; import { CUSTOM_PALETTE, shiftPalette } from '../shared_components'; import { MetricDimensionEditor } from './dimension_editor'; import { MetricToolbar } from './metric_config_panel'; +interface MetricConfig extends Omit { + title: string; + description: string; + metricTitle: string; + mode: 'reduced' | 'full'; + colorMode: ColorMode; + palette: PaletteOutput; +} + export const supportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']); +const getFontSizeAndUnit = (fontSize: string) => { + const [size, sizeUnit] = fontSize.split(/(\d+)/).filter(Boolean); + return { + size: Number(size), + sizeUnit, + }; +}; + const toExpression = ( paletteService: PaletteRegistry, state: MetricState, @@ -56,22 +78,87 @@ const toExpression = ( reverse: false, }; + const fontSizes: Record = { + xs: getFontSizeAndUnit(euiThemeVars.euiFontSizeXS), + s: getFontSizeAndUnit(euiThemeVars.euiFontSizeS), + m: getFontSizeAndUnit(euiThemeVars.euiFontSizeM), + l: getFontSizeAndUnit(euiThemeVars.euiFontSizeL), + xl: getFontSizeAndUnit(euiThemeVars.euiFontSizeXL), + xxl: getFontSizeAndUnit(euiThemeVars.euiFontSizeXXL), + }; + + const labelFont = fontSizes[state?.size || 'xl']; + const labelToMetricFontSizeMap: Record = { + xs: fontSizes.xs.size * 2, + s: fontSizes.m.size * 2.5, + m: fontSizes.l.size * 2.5, + l: fontSizes.xl.size * 2.5, + xl: fontSizes.xxl.size * 2.5, + xxl: fontSizes.xxl.size * 3, + }; + const metricFontSize = labelToMetricFontSizeMap[state?.size || 'xl']; + return { type: 'expression', chain: [ { type: 'function', - function: 'lens_metric_chart', + function: 'metricVis', arguments: { - title: [attributes?.title || ''], - size: [state?.size || 'xl'], - titlePosition: [state?.titlePosition || 'bottom'], - textAlign: [state?.textAlign || 'center'], - description: [attributes?.description || ''], - metricTitle: [operation?.label || ''], - accessor: [state.accessor], - mode: [attributes?.mode || 'full'], + labelPosition: [state?.titlePosition || 'bottom'], + font: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'font', + arguments: { + align: [state?.textAlign || 'center'], + size: [metricFontSize], + weight: ['600'], + lHeight: [metricFontSize * 1.5], + sizeUnit: [labelFont.sizeUnit], + }, + }, + ], + }, + ], + labelFont: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'font', + arguments: { + align: [state?.textAlign || 'center'], + size: [labelFont.size], + lHeight: [labelFont.size * 1.5], + sizeUnit: [labelFont.sizeUnit], + }, + }, + ], + }, + ], + metric: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'visdimension', + arguments: { + accessor: [state.accessor], + }, + }, + ], + }, + ], + showLabels: [!attributes?.mode || attributes?.mode === 'full'], colorMode: !canColor ? [ColorMode.None] : [state?.colorMode || ColorMode.None], + autoScale: [true], + colorFullBackground: [true], palette: state?.colorMode && state?.colorMode !== ColorMode.None ? [paletteService.get(CUSTOM_PALETTE).toExpression(paletteParams)] @@ -81,6 +168,7 @@ const toExpression = ( ], }; }; + export const getMetricVisualization = ({ paletteService, theme, diff --git a/x-pack/plugins/lens/public/mocks/datasource_mock.ts b/x-pack/plugins/lens/public/mocks/datasource_mock.ts index 67b286b2ef8a..c30b39476b1a 100644 --- a/x-pack/plugins/lens/public/mocks/datasource_mock.ts +++ b/x-pack/plugins/lens/public/mocks/datasource_mock.ts @@ -17,6 +17,8 @@ export function createMockDatasource(id: string): DatasourceMock { getTableSpec: jest.fn(() => []), getOperationForColumnId: jest.fn(), getVisualDefaults: jest.fn(), + getSourceId: jest.fn(), + getFilters: jest.fn(), }; return { diff --git a/x-pack/plugins/lens/public/mocks/services_mock.tsx b/x-pack/plugins/lens/public/mocks/services_mock.tsx index 9fa6d61370a1..6681811744da 100644 --- a/x-pack/plugins/lens/public/mocks/services_mock.tsx +++ b/x-pack/plugins/lens/public/mocks/services_mock.tsx @@ -112,6 +112,7 @@ export function makeDefaultServices( chrome: core.chrome, overlays: core.overlays, uiSettings: core.uiSettings, + executionContext: core.executionContext, navigation: navigationStartMock, notifications: core.notifications, attributeService: makeAttributeService(), diff --git a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts index 9ae9f4ac0cae..adf35785c373 100644 --- a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts @@ -144,6 +144,7 @@ const generateCommonArguments: GenerateExpressionAstArguments = ( legendDisplay: [attributes.isPreview ? LegendDisplay.HIDE : layer.legendDisplay], legendPosition: [layer.legendPosition || Position.Right], maxLegendLines: [layer.legendMaxLines ?? 1], + legendSize: layer.legendSize ? [layer.legendSize] : [], nestedLegend: [!!layer.nestedLegend], truncateLegend: [ layer.truncateLegend ?? getDefaultVisualValuesForLayer(state, datasourceLayers).truncateText, @@ -254,7 +255,10 @@ function expressionHelper( const groups = getSortedGroups(datasource, layer); const operations = groups - .map((columnId) => ({ columnId, operation: datasource.getOperationForColumnId(columnId) })) + .map((columnId) => ({ + columnId, + operation: datasource.getOperationForColumnId(columnId) as Operation | null, + })) .filter((o): o is { columnId: string; operation: Operation } => !!o.operation); if (!layer.metric || !operations.length) { diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index f188aa12069d..2c038b093799 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -123,6 +123,11 @@ export function PieToolbar(props: VisualizationToolbarProps onStateChange({ legendSize: val }), + [onStateChange] + ); + const onValueInLegendChange = useCallback(() => { onStateChange({ showValuesInLegend: !shouldShowValuesInLegend(layer, state.shape), @@ -251,6 +256,8 @@ export function PieToolbar(props: VisualizationToolbarProps ); diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 42e4a55167c8..cfd0f106fae1 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -93,6 +93,7 @@ import type { SaveModalContainerProps } from './app_plugin/save_modal_container' import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public'; import { setupExpressions } from './expressions'; import { getSearchProvider } from './search_provider'; +import type { DiscoverSetup, DiscoverStart } from '../../../../src/plugins/discover/public'; export interface LensPluginSetupDependencies { urlForwarding: UrlForwardingSetup; @@ -104,6 +105,7 @@ export interface LensPluginSetupDependencies { charts: ChartsPluginSetup; globalSearch?: GlobalSearchPluginSetup; usageCollection?: UsageCollectionSetup; + discover?: DiscoverSetup; } export interface LensPluginStartDependencies { @@ -122,6 +124,7 @@ export interface LensPluginStartDependencies { inspector: InspectorStartContract; spaces: SpacesPluginStart; usageCollection?: UsageCollectionStart; + discover?: DiscoverStart; } export interface LensPublicSetup { @@ -248,7 +251,6 @@ export class LensPlugin { fieldFormats, plugins.fieldFormats.deserialize ); - const visualizationMap = await this.editorFrameService!.loadVisualizations(); return { @@ -287,10 +289,10 @@ export class LensPlugin { const getPresentationUtilContext = () => startServices().plugins.presentationUtil.ContextProvider; - const ensureDefaultDataView = async () => { + const ensureDefaultDataView = () => { // make sure a default index pattern exists // if not, the page will be redirected to management and visualize won't be rendered - await startServices().plugins.data.indexPatterns.ensureDefaultDataView(); + startServices().plugins.data.indexPatterns.ensureDefaultDataView(); }; core.application.register({ @@ -300,22 +302,24 @@ export class LensPlugin { mount: async (params: AppMountParameters) => { const { core: coreStart, plugins: deps } = startServices(); - await this.initParts( - core, - data, - charts, - expressions, - fieldFormats, - deps.fieldFormats.deserialize - ); + await Promise.all([ + this.initParts( + core, + data, + charts, + expressions, + fieldFormats, + deps.fieldFormats.deserialize + ), + ensureDefaultDataView(), + ]); const { mountApp, stopReportManager, getLensAttributeService } = await import( './async_services' ); - const frameStart = this.editorFrameService!.start(coreStart, deps); - this.stopReportManager = stopReportManager; - await ensureDefaultDataView(); + + const frameStart = this.editorFrameService!.start(coreStart, deps); return mountApp(core, params, { createEditorFrame: frameStart.createInstance, attributeService: getLensAttributeService(coreStart, deps), diff --git a/x-pack/plugins/lens/public/settings_storage.tsx b/x-pack/plugins/lens/public/settings_storage.tsx index fa59bff166c3..ebe812915242 100644 --- a/x-pack/plugins/lens/public/settings_storage.tsx +++ b/x-pack/plugins/lens/public/settings_storage.tsx @@ -14,5 +14,5 @@ export const readFromStorage = (storage: IStorageWrapper, key: string) => { return data && data[key]; }; export const writeToStorage = (storage: IStorageWrapper, key: string, value: string) => { - storage.set(STORAGE_KEY, { [key]: value }); + storage.set(STORAGE_KEY, { ...storage.get(STORAGE_KEY), [key]: value }); }; diff --git a/x-pack/plugins/lens/public/shared_components/axis_title_settings.tsx b/x-pack/plugins/lens/public/shared_components/axis_title_settings.tsx index edecee61d770..f54b07905b94 100644 --- a/x-pack/plugins/lens/public/shared_components/axis_title_settings.tsx +++ b/x-pack/plugins/lens/public/shared_components/axis_title_settings.tsx @@ -49,10 +49,13 @@ export const AxisTitleSettings: React.FunctionComponent isAxisTitleVisible, toggleAxisTitleVisibility, }) => { - const { inputValue: title, handleInputChange: onTitleChange } = useDebouncedValue({ - value: axisTitle || '', - onChange: updateTitleState, - }); + const { inputValue: title, handleInputChange: onTitleChange } = useDebouncedValue( + { + value: axisTitle || '', + onChange: updateTitleState, + }, + { allowFalsyValue: true } + ); return ( <> diff --git a/x-pack/plugins/lens/public/shared_components/columns_number_setting.test.tsx b/x-pack/plugins/lens/public/shared_components/columns_number_setting.test.tsx new file mode 100644 index 000000000000..50f2dc2fb93d --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/columns_number_setting.test.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 { mountWithIntl as mount } from '@kbn/test-jest-helpers'; +import { ColumnsNumberSetting } from './columns_number_setting'; + +describe('Columns Number Setting', () => { + it('should have default the columns input to 1 when no value is given', () => { + const component = mount(); + expect( + component.find('[data-test-subj="lens-legend-location-columns-input"]').at(0).prop('value') + ).toEqual(1); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/columns_number_setting.tsx b/x-pack/plugins/lens/public/shared_components/columns_number_setting.tsx new file mode 100644 index 000000000000..6a1d1ec6d030 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/columns_number_setting.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 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 { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; +import { useDebouncedValue } from './debounced_value'; +import { TooltipWrapper } from './tooltip_wrapper'; + +export const DEFAULT_FLOATING_COLUMNS = 1; + +interface ColumnsNumberSettingProps { + /** + * Sets the number of columns for legend inside chart + */ + floatingColumns?: number; + /** + * Callback on horizontal alignment option change + */ + onFloatingColumnsChange?: (value: number) => void; + /** + * Flag to disable the location settings + */ + isDisabled: boolean; + /** + * Indicates if legend is located outside + */ + isLegendOutside: boolean; +} + +export const ColumnsNumberSetting = ({ + floatingColumns, + onFloatingColumnsChange = () => {}, + isDisabled, + isLegendOutside, +}: ColumnsNumberSettingProps) => { + const { inputValue, handleInputChange } = useDebouncedValue({ + value: floatingColumns ?? DEFAULT_FLOATING_COLUMNS, + onChange: onFloatingColumnsChange, + }); + + return ( + + + { + handleInputChange(Number(e.target.value)); + }} + step={1} + /> + + + ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/legend_location_settings.test.tsx b/x-pack/plugins/lens/public/shared_components/legend_location_settings.test.tsx index 49a53c1abf66..f4b5ced49066 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_location_settings.test.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_location_settings.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { Position } from '@elastic/charts'; -import { shallowWithIntl as shallow, mountWithIntl as mount } from '@kbn/test-jest-helpers'; +import { shallowWithIntl as shallow } from '@kbn/test-jest-helpers'; import { LegendLocationSettings, LegendLocationSettingsProps } from './legend_location_settings'; describe('Legend Location Settings', () => { @@ -104,17 +104,6 @@ describe('Legend Location Settings', () => { expect(newProps.onAlignmentChange).toHaveBeenCalled(); }); - it('should have default the columns input to 1 when no value is given', () => { - const newProps = { - ...props, - location: 'inside', - } as LegendLocationSettingsProps; - const component = mount(); - expect( - component.find('[data-test-subj="lens-legend-location-columns-input"]').at(0).prop('value') - ).toEqual(1); - }); - it('should disable the components when is Disabled is true', () => { const newProps = { ...props, diff --git a/x-pack/plugins/lens/public/shared_components/legend_location_settings.tsx b/x-pack/plugins/lens/public/shared_components/legend_location_settings.tsx index 6791d5586d32..f3ac54ab00a0 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_location_settings.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_location_settings.tsx @@ -7,9 +7,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiButtonGroup, EuiFieldNumber } from '@elastic/eui'; +import { EuiFormRow, EuiButtonGroup } from '@elastic/eui'; import { VerticalAlignment, HorizontalAlignment, Position } from '@elastic/charts'; -import { useDebouncedValue } from './debounced_value'; import { TooltipWrapper } from './tooltip_wrapper'; export interface LegendLocationSettingsProps { @@ -41,22 +40,12 @@ export interface LegendLocationSettingsProps { * Callback on horizontal alignment option change */ onAlignmentChange?: (id: string) => void; - /** - * Sets the number of columns for legend inside chart - */ - floatingColumns?: number; - /** - * Callback on horizontal alignment option change - */ - onFloatingColumnsChange?: (value: number) => void; /** * Flag to disable the location settings */ isDisabled?: boolean; } -const DEFAULT_FLOATING_COLUMNS = 1; - const toggleButtonsIcons = [ { id: Position.Top, @@ -149,32 +138,6 @@ const locationAlignmentButtonsIcons: Array<{ }, ]; -const FloatingColumnsInput = ({ - value, - setValue, - isDisabled, -}: { - value: number; - setValue: (value: number) => void; - isDisabled: boolean; -}) => { - const { inputValue, handleInputChange } = useDebouncedValue({ value, onChange: setValue }); - return ( - { - handleInputChange(Number(e.target.value)); - }} - step={1} - /> - ); -}; - export const LegendLocationSettings: React.FunctionComponent = ({ location, onLocationChange = () => {}, @@ -183,8 +146,6 @@ export const LegendLocationSettings: React.FunctionComponent {}, - floatingColumns, - onFloatingColumnsChange = () => {}, isDisabled = false, }) => { const alignment = `${verticalAlignment || VerticalAlignment.Top}_${ @@ -294,37 +255,6 @@ export const LegendLocationSettings: React.FunctionComponent - {location && ( - - - - - - )} ); }; diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx index 0072a6cd2dcc..e76426515548 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx @@ -38,6 +38,7 @@ describe('Legend Settings', () => { mode: 'auto', onDisplayChange: jest.fn(), onPositionChange: jest.fn(), + onLegendSizeChange: jest.fn(), }; }); diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx index 875fd2ab2631..481c38815d43 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx @@ -17,6 +17,8 @@ import { import { Position, VerticalAlignment, HorizontalAlignment } from '@elastic/charts'; import { ToolbarPopover } from '../shared_components'; import { LegendLocationSettings } from './legend_location_settings'; +import { ColumnsNumberSetting } from './columns_number_setting'; +import { LegendSizeSettings } from './legend_size_settings'; import { ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public'; import { TooltipWrapper } from './tooltip_wrapper'; import { useDebouncedValue } from './debounced_value'; @@ -118,6 +120,14 @@ export interface LegendSettingsPopoverProps { * Button group position */ groupPosition?: ToolbarButtonProps['groupPosition']; + /** + * Legend size in pixels + */ + legendSize?: number; + /** + * Callback on legend size change + */ + onLegendSizeChange: (size?: number) => void; } const DEFAULT_TRUNCATE_LINES = 1; @@ -177,6 +187,8 @@ export const LegendSettingsPopover: React.FunctionComponent {}, shouldTruncate, onTruncateLegendChange = () => {}, + legendSize, + onLegendSizeChange, }) => { return ( + + {location && ( + + )} void; + isVerticalLegend: boolean; + isDisabled: boolean; +} + +const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ + { + value: LegendSizes.AUTO, + inputDisplay: i18n.translate('xpack.lens.shared.legendSizeSetting.legendSizeOptions.auto', { + defaultMessage: 'Auto', + }), + }, + { + value: LegendSizes.SMALL, + inputDisplay: i18n.translate('xpack.lens.shared.legendSizeSetting.legendSizeOptions.small', { + defaultMessage: 'Small', + }), + }, + { + value: LegendSizes.MEDIUM, + inputDisplay: i18n.translate('xpack.lens.shared.legendSizeSetting.legendSizeOptions.medium', { + defaultMessage: 'Medium', + }), + }, + { + value: LegendSizes.LARGE, + inputDisplay: i18n.translate('xpack.lens.shared.legendSizeSetting.legendSizeOptions.large', { + defaultMessage: 'Large', + }), + }, + { + value: LegendSizes.EXTRA_LARGE, + inputDisplay: i18n.translate( + 'xpack.lens.shared.legendSizeSetting.legendSizeOptions.extraLarge', + { + defaultMessage: 'Extra large', + } + ), + }, +]; + +export const LegendSizeSettings = ({ + legendSize, + onLegendSizeChange, + isVerticalLegend, + isDisabled, +}: LegendSizeSettingsProps) => { + useEffect(() => { + if (legendSize && !isVerticalLegend) { + onLegendSizeChange(undefined); + } + }, [isVerticalLegend, legendSize, onLegendSizeChange]); + + const onLegendSizeOptionChange = useCallback( + (option) => onLegendSizeChange(Number(option) || undefined), + [onLegendSizeChange] + ); + + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/toolbar_popover.scss b/x-pack/plugins/lens/public/shared_components/toolbar_popover.scss index a11e3373df46..c06f13dfc2eb 100644 --- a/x-pack/plugins/lens/public/shared_components/toolbar_popover.scss +++ b/x-pack/plugins/lens/public/shared_components/toolbar_popover.scss @@ -1,3 +1,3 @@ .lnsVisToolbar__popover { - width: 365px; + width: 404px; } diff --git a/x-pack/plugins/lens/public/state_management/context_middleware/index.test.ts b/x-pack/plugins/lens/public/state_management/context_middleware/index.test.ts index f115cb59e612..d256fcf9b11e 100644 --- a/x-pack/plugins/lens/public/state_management/context_middleware/index.test.ts +++ b/x-pack/plugins/lens/public/state_management/context_middleware/index.test.ts @@ -10,12 +10,12 @@ import moment from 'moment'; import { contextMiddleware } from '.'; import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; -import { initialState } from '../lens_slice'; +import { applyChanges, initialState } from '../lens_slice'; import { LensAppState } from '../types'; import { mockDataPlugin, mockStoreDeps } from '../../mocks'; const storeDeps = mockStoreDeps(); -const createMiddleware = (data: DataPublicPluginStart) => { +const createMiddleware = (data: DataPublicPluginStart, state?: Partial) => { const middleware = contextMiddleware({ ...storeDeps, lensServices: { @@ -24,12 +24,13 @@ const createMiddleware = (data: DataPublicPluginStart) => { }, }); const store = { - getState: jest.fn(() => ({ lens: initialState })), + getState: jest.fn(() => ({ lens: state || initialState })), dispatch: jest.fn(), }; const next = jest.fn(); - const invoke = (action: PayloadAction>) => middleware(store)(next)(action); + const invoke = (action: PayloadAction | void>) => + middleware(store)(next)(action); return { store, next, invoke }; }; @@ -70,6 +71,47 @@ describe('contextMiddleware', () => { }); expect(next).toHaveBeenCalledWith(action); }); + describe('when auto-apply is disabled', () => { + it('only updates searchSessionId when user applies changes', () => { + // setup + const data = mockDataPlugin(); + (data.nowProvider.get as jest.Mock).mockReturnValue(new Date(Date.now() - 30000)); + (data.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue({ + from: 'now-2m', + to: 'now', + }); + (data.query.timefilter.timefilter.getBounds as jest.Mock).mockReturnValue({ + min: moment(Date.now() - 100000), + max: moment(Date.now() - 30000), + }); + const { invoke, store } = createMiddleware(data, { + ...initialState, + autoApplyDisabled: true, + }); + + // setState shouldn't trigger + const setStateAction = { + type: 'lens/setState', + payload: { + visualization: { + state: {}, + activeId: 'id2', + }, + }, + }; + invoke(setStateAction); + expect(store.dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'lens/setState' }) + ); + + // applyChanges should trigger + const applyChangesAction = applyChanges(); + invoke(applyChangesAction); + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'lens/setState' }) + ); + }); + }); it('does not update the searchSessionId when the state changes and too little time has passed', () => { const data = mockDataPlugin(); // time range is 100,000ms ago to 300ms ago (that's a lag of .3 percent, not enough to trigger a session update) diff --git a/x-pack/plugins/lens/public/state_management/context_middleware/index.ts b/x-pack/plugins/lens/public/state_management/context_middleware/index.ts index 25dea5527d06..3ca806d17dcb 100644 --- a/x-pack/plugins/lens/public/state_management/context_middleware/index.ts +++ b/x-pack/plugins/lens/public/state_management/context_middleware/index.ts @@ -8,7 +8,14 @@ import { Dispatch, MiddlewareAPI, PayloadAction } from '@reduxjs/toolkit'; import moment from 'moment'; import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; -import { setState, LensDispatch, LensStoreDeps, navigateAway } from '..'; +import { + setState, + LensDispatch, + LensStoreDeps, + navigateAway, + applyChanges, + selectAutoApplyEnabled, +} from '..'; import { LensAppState } from '../types'; import { getResolvedDateRange, containsDynamicMath } from '../../utils'; import { subscribeToExternalContext } from './subscribe_to_external_context'; @@ -20,8 +27,12 @@ export const contextMiddleware = (storeDeps: LensStoreDeps) => (store: Middlewar store.getState, store.dispatch ); - return (next: Dispatch) => (action: PayloadAction>) => { - if (!action.payload?.searchSessionId && !onActiveDataChange.match(action)) { + return (next: Dispatch) => (action: PayloadAction) => { + if ( + !(action.payload as Partial)?.searchSessionId && + !onActiveDataChange.match(action) && + (selectAutoApplyEnabled(store.getState()) || applyChanges.match(action)) + ) { updateTimeRange(storeDeps.lensServices.data, store.dispatch); } if (navigateAway.match(action)) { diff --git a/x-pack/plugins/lens/public/state_management/index.ts b/x-pack/plugins/lens/public/state_management/index.ts index bdd1bd8f39cc..7b9c345ff89f 100644 --- a/x-pack/plugins/lens/public/state_management/index.ts +++ b/x-pack/plugins/lens/public/state_management/index.ts @@ -20,6 +20,9 @@ export const { loadInitial, navigateAway, setState, + enableAutoApply, + disableAutoApply, + applyChanges, setSaveable, onActiveDataChange, updateState, diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/index.ts b/x-pack/plugins/lens/public/state_management/init_middleware/index.ts index 88a045ed0b50..164941d5d5f8 100644 --- a/x-pack/plugins/lens/public/state_management/init_middleware/index.ts +++ b/x-pack/plugins/lens/public/state_management/init_middleware/index.ts @@ -9,11 +9,18 @@ import { Dispatch, MiddlewareAPI, PayloadAction } from '@reduxjs/toolkit'; import { LensStoreDeps } from '..'; import { loadInitial as loadInitialAction } from '..'; import { loadInitial } from './load_initial'; +import { readFromStorage } from '../../settings_storage'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { AUTO_APPLY_DISABLED_STORAGE_KEY } from '../../editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper'; + +const autoApplyDisabled = () => { + return readFromStorage(new Storage(localStorage), AUTO_APPLY_DISABLED_STORAGE_KEY) === 'true'; +}; export const initMiddleware = (storeDeps: LensStoreDeps) => (store: MiddlewareAPI) => { return (next: Dispatch) => (action: PayloadAction) => { if (loadInitialAction.match(action)) { - return loadInitial(store, storeDeps, action.payload); + return loadInitial(store, storeDeps, action.payload, autoApplyDisabled()); } next(action); }; diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts index 372d08017ee2..709577594cea 100644 --- a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts +++ b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts @@ -9,7 +9,7 @@ import { MiddlewareAPI } from '@reduxjs/toolkit'; import { i18n } from '@kbn/i18n'; import { History } from 'history'; import { setState, initEmpty, LensStoreDeps } from '..'; -import { getPreloadedState } from '../lens_slice'; +import { disableAutoApply, getPreloadedState } from '../lens_slice'; import { SharingSavedObjectProps } from '../../types'; import { LensEmbeddableInput, LensByReferenceInput } from '../../embeddable/embeddable'; import { getInitialDatasourceId } from '../../utils'; @@ -93,7 +93,8 @@ export function loadInitial( redirectCallback: (savedObjectId?: string) => void; initialInput?: LensEmbeddableInput; history?: History; - } + }, + autoApplyDisabled: boolean ) { const { lensServices, datasourceMap, embeddableEditorIncomingState, initialContext } = storeDeps; const { resolvedDateRange, searchSessionId, isLinkedToOriginatingApp, ...emptyState } = @@ -129,6 +130,9 @@ export function loadInitial( initialContext, }) ); + if (autoApplyDisabled) { + store.dispatch(disableAutoApply()); + } }) .catch((e: { message: string }) => { notifications.toasts.addDanger({ @@ -209,6 +213,10 @@ export function loadInitial( isLoading: false, }) ); + + if (autoApplyDisabled) { + store.dispatch(disableAutoApply()); + } }) .catch((e: { message: string }) => notifications.toasts.addDanger({ diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.test.ts b/x-pack/plugins/lens/public/state_management/lens_slice.test.ts index 85061f36ce35..4a183c11d896 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.test.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { EnhancedStore } from '@reduxjs/toolkit'; import { Query } from 'src/plugins/data/public'; import { switchDatasource, @@ -16,13 +17,20 @@ import { removeOrClearLayer, addLayer, LensRootStore, + selectTriggerApplyChanges, + selectChangesApplied, } from '.'; import { layerTypes } from '../../common'; import { makeLensStore, defaultState, mockStoreDeps } from '../mocks'; import { DatasourceMap, VisualizationMap } from '../types'; +import { applyChanges, disableAutoApply, enableAutoApply, setChangesApplied } from './lens_slice'; +import { LensAppState } from './types'; describe('lensSlice', () => { - const { store } = makeLensStore({}); + let store: EnhancedStore<{ lens: LensAppState }>; + beforeEach(() => { + store = makeLensStore({}).store; + }); const customQuery = { query: 'custom' } as Query; describe('state update', () => { @@ -34,6 +42,56 @@ describe('lensSlice', () => { expect(changedState).toEqual({ ...defaultState, query: customQuery }); }); + describe('auto-apply-related actions', () => { + it('should disable auto apply', () => { + expect(store.getState().lens.autoApplyDisabled).toBeUndefined(); + expect(store.getState().lens.changesApplied).toBeUndefined(); + + store.dispatch(disableAutoApply()); + + expect(store.getState().lens.autoApplyDisabled).toBe(true); + expect(store.getState().lens.changesApplied).toBe(true); + }); + + it('should enable auto-apply', () => { + store.dispatch(disableAutoApply()); + + expect(store.getState().lens.autoApplyDisabled).toBe(true); + + store.dispatch(enableAutoApply()); + + expect(store.getState().lens.autoApplyDisabled).toBe(false); + }); + + it('applies changes when auto-apply disabled', () => { + store.dispatch(disableAutoApply()); + + store.dispatch(applyChanges()); + + expect(selectTriggerApplyChanges(store.getState())).toBe(true); + }); + + it('does not apply changes if auto-apply enabled', () => { + expect(store.getState().lens.autoApplyDisabled).toBeUndefined(); + + store.dispatch(applyChanges()); + + expect(selectTriggerApplyChanges(store.getState())).toBe(false); + }); + + it('sets changes-applied flag', () => { + expect(store.getState().lens.changesApplied).toBeUndefined(); + + store.dispatch(setChangesApplied(true)); + + expect(selectChangesApplied(store.getState())).toBe(true); + + store.dispatch(setChangesApplied(false)); + + expect(selectChangesApplied(store.getState())).toBe(true); + }); + }); + it('updateState: updates state with updater', () => { const customUpdater = jest.fn((state) => ({ ...state, query: customQuery })); store.dispatch(updateState({ updater: customUpdater })); diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index 099929cdf479..56ff89f506c8 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -83,6 +83,10 @@ export const getPreloadedState = ({ export const setState = createAction>('lens/setState'); export const onActiveDataChange = createAction('lens/onActiveDataChange'); export const setSaveable = createAction('lens/setSaveable'); +export const enableAutoApply = createAction('lens/enableAutoApply'); +export const disableAutoApply = createAction('lens/disableAutoApply'); +export const applyChanges = createAction('lens/applyChanges'); +export const setChangesApplied = createAction('lens/setChangesApplied'); export const updateState = createAction<{ updater: (prevState: LensAppState) => LensAppState; }>('lens/updateState'); @@ -162,6 +166,10 @@ export const lensActions = { setState, onActiveDataChange, setSaveable, + enableAutoApply, + disableAutoApply, + applyChanges, + setChangesApplied, updateState, updateDatasourceState, updateVisualizationState, @@ -202,6 +210,22 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { isSaveable: payload, }; }, + [enableAutoApply.type]: (state) => { + state.autoApplyDisabled = false; + }, + [disableAutoApply.type]: (state) => { + state.autoApplyDisabled = true; + state.changesApplied = true; + }, + [applyChanges.type]: (state) => { + if (typeof state.applyChangesCounter === 'undefined') { + state.applyChangesCounter = 0; + } + state.applyChangesCounter!++; + }, + [setChangesApplied.type]: (state, { payload: applied }) => { + state.changesApplied = applied; + }, [updateState.type]: ( state, { diff --git a/x-pack/plugins/lens/public/state_management/selectors.test.ts b/x-pack/plugins/lens/public/state_management/selectors.test.ts new file mode 100644 index 000000000000..2313d341b7e0 --- /dev/null +++ b/x-pack/plugins/lens/public/state_management/selectors.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAppState, selectTriggerApplyChanges, selectChangesApplied } from '.'; + +describe('lens selectors', () => { + describe('selecting changes applied', () => { + it('should be true when auto-apply disabled and flag is set', () => { + const lensState = { + changesApplied: true, + autoApplyDisabled: true, + } as Partial; + + expect(selectChangesApplied({ lens: lensState as LensAppState })).toBeTruthy(); + }); + + it('should be false when auto-apply disabled and flag is false', () => { + const lensState = { + changesApplied: false, + autoApplyDisabled: true, + } as Partial; + + expect(selectChangesApplied({ lens: lensState as LensAppState })).toBeFalsy(); + }); + + it('should be true when auto-apply enabled no matter what', () => { + const lensState = { + changesApplied: false, + autoApplyDisabled: false, + } as Partial; + + expect(selectChangesApplied({ lens: lensState as LensAppState })).toBeTruthy(); + }); + }); + it('should select apply changes trigger', () => { + selectTriggerApplyChanges({ lens: { applyChangesCounter: 1 } as LensAppState }); // get the counters in sync + + expect( + selectTriggerApplyChanges({ lens: { applyChangesCounter: 2 } as LensAppState }) + ).toBeTruthy(); + expect( + selectTriggerApplyChanges({ lens: { applyChangesCounter: 2 } as LensAppState }) + ).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/lens/public/state_management/selectors.ts b/x-pack/plugins/lens/public/state_management/selectors.ts index 250e9dde3137..26a0d70d068f 100644 --- a/x-pack/plugins/lens/public/state_management/selectors.ts +++ b/x-pack/plugins/lens/public/state_management/selectors.ts @@ -19,12 +19,22 @@ export const selectFilters = (state: LensState) => state.lens.filters; export const selectResolvedDateRange = (state: LensState) => state.lens.resolvedDateRange; export const selectVisualization = (state: LensState) => state.lens.visualization; export const selectStagedPreview = (state: LensState) => state.lens.stagedPreview; +export const selectAutoApplyEnabled = (state: LensState) => !state.lens.autoApplyDisabled; +export const selectChangesApplied = (state: LensState) => + !state.lens.autoApplyDisabled || Boolean(state.lens.changesApplied); export const selectDatasourceStates = (state: LensState) => state.lens.datasourceStates; export const selectActiveDatasourceId = (state: LensState) => state.lens.activeDatasourceId; export const selectActiveData = (state: LensState) => state.lens.activeData; export const selectIsFullscreenDatasource = (state: LensState) => Boolean(state.lens.isFullscreenDatasource); +let applyChangesCounter: number | undefined; +export const selectTriggerApplyChanges = (state: LensState) => { + const shouldApply = state.lens.applyChangesCounter !== applyChangesCounter; + applyChangesCounter = state.lens.applyChangesCounter; + return shouldApply; +}; + export const selectExecutionContext = createSelector( [selectQuery, selectFilters, selectResolvedDateRange], (query, filters, dateRange) => ({ diff --git a/x-pack/plugins/lens/public/state_management/types.ts b/x-pack/plugins/lens/public/state_management/types.ts index b0ff49862d9b..0c902f944072 100644 --- a/x-pack/plugins/lens/public/state_management/types.ts +++ b/x-pack/plugins/lens/public/state_management/types.ts @@ -33,6 +33,9 @@ export interface PreviewState { export interface EditorFrameState extends PreviewState { activeDatasourceId: string | null; stagedPreview?: PreviewState; + autoApplyDisabled?: boolean; + applyChangesCounter?: number; + changesApplied?: boolean; isFullscreenDatasource?: boolean; } export interface LensAppState extends EditorFrameState { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 276c31328bb0..107884a84921 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -160,7 +160,12 @@ export interface DatasourceSuggestion { keptLayerIds: string[]; } -export type StateSetter = (newState: T | ((prevState: T) => T)) => void; +type StateSetterArg = T | ((prevState: T) => T); + +export type StateSetter = ( + newState: StateSetterArg, + options?: OptionsShape +) => void; export interface InitializationOptions { isFullEditor?: boolean; @@ -350,18 +355,29 @@ export interface DatasourceFixAction { */ export interface DatasourcePublicAPI { datasourceId: string; - getTableSpec: () => Array<{ columnId: string }>; - getOperationForColumnId: (columnId: string) => Operation | null; + getTableSpec: () => Array<{ columnId: string; fields: string[] }>; + getOperationForColumnId: (columnId: string) => OperationDescriptor | null; /** * Collect all default visual values given the current state */ getVisualDefaults: () => Record>; + /** + * Retrieve the specific source id for the current state + */ + getSourceId: () => string | undefined; + /** + * Collect all defined filters from all the operations in the layer + */ + getFilters: (activeData?: FramePublicAPI['activeData']) => { + kuery: Query[][]; + lucene: Query[][]; + }; } export interface DatasourceDataPanelProps { state: T; dragDropContext: DragContextState; - setState: StateSetter; + setState: StateSetter; showNoDataPopover: () => void; core: Pick; query: Query; @@ -400,13 +416,13 @@ export type ParamEditorCustomProps = Record & { label?: string // The only way a visualization has to restrict the query building export type DatasourceDimensionEditorProps = DatasourceDimensionProps & { // Not a StateSetter because we have this unique use case of determining valid columns - setState: ( - newState: Parameters>[0], - publishToVisualization?: { + setState: StateSetter< + T, + { isDimensionComplete?: boolean; forceRender?: boolean; } - ) => void; + >; core: Pick; dateRange: DateRange; dimensionGroups: VisualizationDimensionGroupConfig[]; @@ -449,7 +465,13 @@ export type DatasourceDimensionDropProps = SharedDimensionProps & { groupId: string; columnId: string; state: T; - setState: StateSetter; + setState: StateSetter< + T, + { + isDimensionComplete?: boolean; + forceRender?: boolean; + } + >; dimensionGroups: VisualizationDimensionGroupConfig[]; }; @@ -487,10 +509,17 @@ export interface OperationMetadata { // TODO currently it's not possible to differentiate between a field from a raw // document and an aggregated metric which might be handy in some cases. Once we // introduce a raw document datasource, this should be considered here. - isStaticValue?: boolean; } +/** + * Specific type used to store some meta information on top of the Operation type + * Rather than populate the Operation type with optional types, it can leverage a super type + */ +export interface OperationDescriptor extends Operation { + hasTimeShift: boolean; +} + export interface VisualizationConfigProps { layerId: string; frame: Pick; @@ -605,6 +634,7 @@ export interface SuggestionRequest { */ state?: T; mainPalette?: PaletteOutput; + isFromContext?: boolean; /** * The visualization needs to know which table is being suggested */ @@ -651,6 +681,7 @@ export interface VisualizationSuggestion { export interface FramePublicAPI { datasourceLayers: Record; + appliedDatasourceLayers?: Record; // this is only set when auto-apply is turned off /** * Data of the chart currently rendered in the preview. * This data might be not available (e.g. if the chart can't be rendered) or outdated and belonging to another chart. diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.test.ts b/x-pack/plugins/lens/public/visualizations/gauge/visualization.test.ts index 7c17c8ee140c..d11c2a4aa6f6 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/visualization.test.ts +++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.test.ts @@ -8,7 +8,7 @@ import { getGaugeVisualization, isNumericDynamicMetric, isNumericMetric } from './visualization'; import { createMockDatasource, createMockFramePublicAPI } from '../../mocks'; import { GROUP_ID } from './constants'; -import type { DatasourcePublicAPI, Operation } from '../../types'; +import type { DatasourcePublicAPI, OperationDescriptor } from '../../types'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; import { CustomPaletteParams, layerTypes } from '../../../common'; import type { GaugeVisualizationState } from './constants'; @@ -58,7 +58,7 @@ describe('gauge', () => { mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ dataType: 'string', label: 'MyOperation', - } as Operation); + } as OperationDescriptor); frame.datasourceLayers = { first: mockDatasource.publicAPIMock, @@ -461,7 +461,7 @@ describe('gauge', () => { mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ dataType: 'string', label: 'MyOperation', - } as Operation); + } as OperationDescriptor); datasourceLayers = { first: mockDatasource.publicAPIMock, }; @@ -532,7 +532,7 @@ describe('gauge', () => { mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ dataType: 'string', label: 'MyOperation', - } as Operation); + } as OperationDescriptor); frame.datasourceLayers = { first: mockDatasource.publicAPIMock, }; diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index 2af871d58103..5992d0bdb726 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -127,7 +127,7 @@ Object { "linear", ], }, - "function": "lens_xy_layer", + "function": "lens_xy_data_layer", "type": "function", }, ], @@ -145,6 +145,7 @@ Object { "isVisible": Array [ true, ], + "legendSize": Array [], "maxLines": Array [], "position": Array [ "bottom", diff --git a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts index 355374165c78..ac3e224663ce 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { LayerArgs } from '../../common/expressions'; +import { DataLayerArgs } from '../../common/expressions'; import { layerTypes } from '../../common'; import { Datatable } from '../../../../../src/plugins/expressions/public'; import { getAxesConfiguration } from './axes_configuration'; @@ -219,7 +219,7 @@ describe('axes_configuration', () => { }, }; - const sampleLayer: LayerArgs = { + const sampleLayer: DataLayerArgs = { layerId: 'first', layerType: layerTypes.DATA, seriesType: 'line', diff --git a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts index 9ac0171a5108..7adc803f31e9 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts +++ b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts @@ -6,7 +6,7 @@ */ import { FormatFactory } from '../../common'; -import { AxisExtentConfig, XYLayerConfig } from '../../common/expressions'; +import { AxisExtentConfig, XYDataLayerConfig } from '../../common/expressions'; import { Datatable } from '../../../../../src/plugins/expressions/public'; import type { IFieldFormat, @@ -33,7 +33,7 @@ export function isFormatterCompatible( return formatter1.id === formatter2.id; } -export function groupAxesByType(layers: XYLayerConfig[], tables?: Record) { +export function groupAxesByType(layers: XYDataLayerConfig[], tables?: Record) { const series: { auto: FormattedMetric[]; left: FormattedMetric[]; @@ -97,7 +97,7 @@ export function groupAxesByType(layers: XYLayerConfig[], tables?: Record, formatFactory?: FormatFactory diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts index 4157eabfad82..9b29401d72a9 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts @@ -7,11 +7,11 @@ import { getColorAssignments } from './color_assignment'; import type { FormatFactory, LensMultiTable } from '../../common'; -import type { LayerArgs } from '../../common/expressions'; +import type { DataLayerArgs } from '../../common/expressions'; import { layerTypes } from '../../common'; describe('color_assignment', () => { - const layers: LayerArgs[] = [ + const layers: DataLayerArgs[] = [ { yScaleType: 'linear', xScaleType: 'linear', diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts index e1e2ba75b50c..82c1106e72a0 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -59,6 +59,7 @@ export function getColorAssignments( } const splitAccessor = layer.splitAccessor; const column = data.tables[layer.layerId]?.columns.find(({ id }) => id === splitAccessor); + const columnFormatter = column && formatFactory(column.meta.params); const splits = !column || !data.tables[layer.layerId] ? [] @@ -66,7 +67,7 @@ export function getColorAssignments( data.tables[layer.layerId].rows.map((row) => { let value = row[splitAccessor]; if (value && !isPrimitive(value)) { - value = formatFactory(column.meta.params).convert(value); + value = columnFormatter?.convert(value) ?? value; } else { value = String(value); } @@ -121,6 +122,7 @@ export function getAccessorColorConfig( if (isReferenceLayer(layer)) { return getReferenceLineAccessorColorConfig(layer); } + const layerContainsSplits = Boolean(layer.splitAccessor); const currentPalette: PaletteOutput = layer.palette || { type: 'palette', name: 'default' }; const totalSeriesCount = colorAssignments[currentPalette.name]?.totalSeriesCount; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 6bee021b36de..654a0f1b94a1 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -27,13 +27,13 @@ import type { LensMultiTable } from '../../common'; import { layerTypes } from '../../common'; import { xyChart } from '../../common/expressions'; import { - layerConfig, + dataLayerConfig, legendConfig, tickLabelsConfig, gridlinesConfig, XYArgs, LegendConfig, - LayerArgs, + DataLayerArgs, AxesSettingsConfig, XYChartProps, labelsOrientationConfig, @@ -212,7 +212,7 @@ const dateHistogramData: LensMultiTable = { }, }; -const dateHistogramLayer: LayerArgs = { +const dateHistogramLayer: DataLayerArgs = { layerId: 'timeLayer', layerType: layerTypes.DATA, hide: false, @@ -254,7 +254,7 @@ const createSampleDatatableWithRows = (rows: DatatableRow[]): Datatable => ({ rows, }); -const sampleLayer: LayerArgs = { +const sampleLayer: DataLayerArgs = { layerId: 'first', layerType: layerTypes.DATA, seriesType: 'line', @@ -268,7 +268,7 @@ const sampleLayer: LayerArgs = { palette: mockPaletteOutput, }; -const createArgsWithLayers = (layers: LayerArgs[] = [sampleLayer]): XYArgs => ({ +const createArgsWithLayers = (layers: DataLayerArgs[] = [sampleLayer]): XYArgs => ({ xTitle: '', yTitle: '', yRightTitle: '', @@ -392,8 +392,8 @@ describe('xy_expression', () => { }); }); - test('layerConfig produces the correct arguments', () => { - const args: LayerArgs = { + test('dataLayerConfig produces the correct arguments', () => { + const args: DataLayerArgs = { layerId: 'first', layerType: layerTypes.DATA, seriesType: 'line', @@ -406,10 +406,10 @@ describe('xy_expression', () => { palette: mockPaletteOutput, }; - const result = layerConfig.fn(null, args, createMockExecutionContext()); + const result = dataLayerConfig.fn(null, args, createMockExecutionContext()); expect(result).toEqual({ - type: 'lens_xy_layer', + type: 'lens_xy_data_layer', ...args, }); }); @@ -556,7 +556,7 @@ describe('xy_expression', () => { }); describe('date range', () => { - const timeSampleLayer: LayerArgs = { + const timeSampleLayer: DataLayerArgs = { layerId: 'first', layerType: layerTypes.DATA, seriesType: 'line', @@ -649,7 +649,7 @@ describe('xy_expression', () => { }); describe('axis time', () => { - const defaultTimeLayer: LayerArgs = { + const defaultTimeLayer: DataLayerArgs = { layerId: 'first', layerType: layerTypes.DATA, seriesType: 'line', @@ -707,7 +707,7 @@ describe('xy_expression', () => { }); test('it should disable the new time axis for a vertical bar with break down dimension', () => { const { data } = sampleArgs(); - const timeLayer: LayerArgs = { + const timeLayer: DataLayerArgs = { ...defaultTimeLayer, seriesType: 'bar', }; @@ -734,7 +734,7 @@ describe('xy_expression', () => { test('it should enable the new time axis for a stacked vertical bar with break down dimension', () => { const { data } = sampleArgs(); - const timeLayer: LayerArgs = { + const timeLayer: DataLayerArgs = { ...defaultTimeLayer, seriesType: 'bar_stacked', }; @@ -1227,7 +1227,7 @@ describe('xy_expression', () => { test('onBrushEnd returns correct context data for number histogram data', () => { const { args } = sampleArgs(); - const numberLayer: LayerArgs = { + const numberLayer: DataLayerArgs = { layerId: 'numberLayer', layerType: layerTypes.DATA, hide: false, @@ -1436,7 +1436,7 @@ describe('xy_expression', () => { test('onElementClick returns correct context data for numeric histogram', () => { const { args } = sampleArgs(); - const numberLayer: LayerArgs = { + const numberLayer: DataLayerArgs = { layerId: 'numberLayer', layerType: layerTypes.DATA, hide: false, @@ -1757,7 +1757,7 @@ describe('xy_expression', () => { test('it applies histogram mode to the series for single series', () => { const { data, args } = sampleArgs(); - const firstLayer: LayerArgs = { + const firstLayer: DataLayerArgs = { ...args.layers[0], accessors: ['b'], seriesType: 'bar', @@ -1772,7 +1772,7 @@ describe('xy_expression', () => { test('it does not apply histogram mode to more than one bar series for unstacked bar chart', () => { const { data, args } = sampleArgs(); - const firstLayer: LayerArgs = { ...args.layers[0], seriesType: 'bar', isHistogram: true }; + const firstLayer: DataLayerArgs = { ...args.layers[0], seriesType: 'bar', isHistogram: true }; delete firstLayer.splitAccessor; const component = shallow( @@ -1783,9 +1783,17 @@ describe('xy_expression', () => { test('it applies histogram mode to more than one the series for unstacked line/area chart', () => { const { data, args } = sampleArgs(); - const firstLayer: LayerArgs = { ...args.layers[0], seriesType: 'line', isHistogram: true }; + const firstLayer: DataLayerArgs = { + ...args.layers[0], + seriesType: 'line', + isHistogram: true, + }; delete firstLayer.splitAccessor; - const secondLayer: LayerArgs = { ...args.layers[0], seriesType: 'line', isHistogram: true }; + const secondLayer: DataLayerArgs = { + ...args.layers[0], + seriesType: 'line', + isHistogram: true, + }; delete secondLayer.splitAccessor; const component = shallow( { toDate: new Date('2019-01-03T05:00:00.000Z'), }, }; - const timeSampleLayer: LayerArgs = { + const timeSampleLayer: DataLayerArgs = { layerId: 'first', layerType: layerTypes.DATA, seriesType: 'line', diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index ea0e336ff2f0..68dc8e26f320 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -42,16 +42,18 @@ import type { ExpressionRenderDefinition, Datatable, DatatableRow, + DatatableColumn, } from 'src/plugins/expressions/public'; import { IconType } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { RenderMode } from 'src/plugins/expressions'; import { ThemeServiceStart } from 'kibana/public'; +import { FieldFormat } from 'src/plugins/field_formats/common'; import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; import type { ILensInterpreterRenderHandlers, LensFilterEvent, LensBrushEvent } from '../types'; import type { LensMultiTable, FormatFactory } from '../../common'; -import type { LayerArgs, SeriesType, XYChartProps } from '../../common/expressions'; +import type { DataLayerArgs, SeriesType, XYChartProps } from '../../common/expressions'; import { visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart, getSeriesColor } from './state_helpers'; @@ -75,7 +77,7 @@ import { ReferenceLineAnnotations, } from './expression_reference_lines'; import { computeOverallDataDomain } from './reference_line_helpers'; -import { isDataLayer, isReferenceLayer } from './visualization_helpers'; +import { getReferenceLayers, isDataLayer } from './visualization_helpers'; declare global { interface Window { @@ -253,7 +255,7 @@ export function XYChart({ const layersById = filteredLayers.reduce((memo, layer) => { memo[layer.layerId] = layer; return memo; - }, {} as Record); + }, {} as Record); const handleCursorUpdate = useActiveCursor(chartsActiveCursorService, chartRef, { datatables: Object.values(data.tables), @@ -347,7 +349,7 @@ export function XYChart({ ); }; - const referenceLineLayers = layers.filter((layer) => isReferenceLayer(layer)); + const referenceLineLayers = getReferenceLayers(layers); const referenceLinePaddings = getReferenceLineRequiredPaddings(referenceLineLayers, yAxesMap); const getYAxesStyle = (groupId: 'left' | 'right') => { @@ -599,6 +601,7 @@ export function XYChart({ : undefined, }, }; + return ( (); + for (const column of table.columns) { + formatterPerColumn.set(column, formatFactory(column.meta.params)); + } + // what if row values are not primitive? That is the case of, for instance, Ranges // remaps them to their serialized version with the formatHint metadata // In order to do it we need to make a copy of the table as the raw one is required for more features (filters, etc...) later on @@ -744,7 +753,7 @@ export function XYChart({ // pre-format values for ordinal x axes because there can only be a single x axis formatter on chart level (!isPrimitive(record) || (column.id === xAccessor && xScaleType === 'ordinal')) ) { - newRow[column.id] = formatFactory(column.meta.params).convert(record); + newRow[column.id] = formatterPerColumn.get(column)!.convert(record); } } return newRow; @@ -798,6 +807,8 @@ export function XYChart({ ); const formatter = table?.columns.find((column) => column.id === accessor)?.meta?.params; + const splitHint = table.columns.find((col) => col.id === splitAccessor)?.meta?.params; + const splitFormatter = formatFactory(splitHint); const seriesProps: SeriesSpec = { splitSeriesAccessors: splitAccessor ? [splitAccessor] : [], @@ -857,8 +868,6 @@ export function XYChart({ }, }, name(d) { - const splitHint = table.columns.find((col) => col.id === splitAccessor)?.meta?.params; - // For multiple y series, the name of the operation is used on each, either: // * Key - Y name // * Formatted value - Y name @@ -871,7 +880,7 @@ export function XYChart({ splitAccessor && !layersAlreadyFormatted[splitAccessor] ) { - return formatFactory(splitHint).convert(key); + return splitFormatter.convert(key); } return splitAccessor && i === 0 ? key : columnToLabelMap[key] ?? ''; }) @@ -885,7 +894,7 @@ export function XYChart({ if (splitAccessor && layersAlreadyFormatted[splitAccessor]) { return d.seriesKeys[0]; } - return formatFactory(splitHint).convert(d.seriesKeys[0]); + return splitFormatter.convert(d.seriesKeys[0]); } // This handles both split and single-y cases: // * If split series without formatting, show the value literally @@ -977,7 +986,7 @@ export function XYChart({ ); } -function getFilteredLayers(layers: LayerArgs[], data: LensMultiTable) { +function getFilteredLayers(layers: DataLayerArgs[], data: LensMultiTable) { return layers.filter((layer) => { const { layerId, xAccessor, accessors, splitAccessor } = layer; return ( diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.test.tsx index 85d5dd362a43..4ec38f31b85a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.test.tsx @@ -8,11 +8,10 @@ import { LineAnnotation, RectAnnotation } from '@elastic/charts'; import { shallow } from 'enzyme'; import React from 'react'; -import { PaletteOutput } from 'src/plugins/charts/common'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; import { FieldFormat } from 'src/plugins/field_formats/common'; -import { layerTypes, LensMultiTable } from '../../common'; -import { LayerArgs, YConfig } from '../../common/expressions'; +import { LensMultiTable } from '../../common'; +import { ReferenceLineLayerArgs, YConfig } from '../../common/expressions'; import { ReferenceLineAnnotations, ReferenceLineAnnotationsProps, @@ -20,12 +19,6 @@ import { const paletteService = chartPluginMock.createPaletteRegistry(); -const mockPaletteOutput: PaletteOutput = { - type: 'palette', - name: 'mock', - params: {}, -}; - const row: Record = { xAccessorFirstId: 1, xAccessorSecondId: 2, @@ -57,18 +50,12 @@ const histogramData: LensMultiTable = { }, }; -function createLayers(yConfigs: LayerArgs['yConfig']): LayerArgs[] { +function createLayers(yConfigs: ReferenceLineLayerArgs['yConfig']): ReferenceLineLayerArgs[] { return [ { layerId: 'firstLayer', - layerType: layerTypes.REFERENCE_LINE, - hide: false, - yScaleType: 'linear', - xScaleType: 'linear', - isHistogram: false, - seriesType: 'bar_stacked', + layerType: 'referenceLine', accessors: (yConfigs || []).map(({ forAccessor }) => forAccessor), - palette: mockPaletteOutput, yConfig: yConfigs, }, ]; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx index a20f70ad6f4e..d9a6a84bb538 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx @@ -13,7 +13,7 @@ import { RectAnnotation, AnnotationDomainType, LineAnnotation, Position } from ' import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { FieldFormat } from 'src/plugins/field_formats/common'; import { euiLightVars } from '@kbn/ui-theme'; -import type { LayerArgs, YConfig } from '../../common/expressions'; +import type { ReferenceLineLayerArgs, YConfig } from '../../common/expressions'; import type { LensMultiTable } from '../../common/types'; import { hasIcon } from './xy_config_panel/shared/icon_select'; @@ -55,7 +55,7 @@ export const computeChartMargins = ( // Note: it does not take into consideration whether the reference line is in view or not export const getReferenceLineRequiredPaddings = ( - referenceLineLayers: LayerArgs[], + referenceLineLayers: ReferenceLineLayerArgs[], axesMap: Record<'left' | 'right', unknown> ) => { // collect all paddings for the 4 axis: if any text is detected double it. @@ -181,7 +181,7 @@ function getMarkerToShow( } export interface ReferenceLineAnnotationsProps { - layers: LayerArgs[]; + layers: ReferenceLineLayerArgs[]; data: LensMultiTable; formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; paletteService: PaletteRegistry; diff --git a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx index c15c0916bee0..faa3ecc976d9 100644 --- a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx @@ -12,7 +12,7 @@ import { mountWithIntl } from '@kbn/test-jest-helpers'; import { ComponentType, ReactWrapper } from 'enzyme'; import type { LensMultiTable } from '../../common'; import { layerTypes } from '../../common'; -import type { LayerArgs } from '../../common/expressions'; +import type { DataLayerArgs } from '../../common/expressions'; import { getLegendAction } from './get_legend_action'; import { LegendActionPopover } from '../shared_components'; @@ -27,7 +27,7 @@ const sampleLayer = { xScaleType: 'ordinal', yScaleType: 'linear', isHistogram: false, -} as LayerArgs; +} as DataLayerArgs; const tables = { first: { diff --git a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx index 0603328ee5bb..00532314e045 100644 --- a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx @@ -9,11 +9,11 @@ import React from 'react'; import type { LegendAction, XYChartSeriesIdentifier } from '@elastic/charts'; import type { LensFilterEvent } from '../types'; import type { LensMultiTable, FormatFactory } from '../../common'; -import type { LayerArgs } from '../../common/expressions'; +import type { DataLayerArgs } from '../../common/expressions'; import { LegendActionPopover } from '../shared_components'; export const getLegendAction = ( - filteredLayers: LayerArgs[], + filteredLayers: DataLayerArgs[], tables: LensMultiTable['tables'], onFilter: (data: LensFilterEvent['data']) => void, formatFactory: FormatFactory, diff --git a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.test.ts b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.test.ts index 9f48b8c8c36e..9def75615e6c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { XYLayerConfig } from '../../common/expressions'; +import { XYDataLayerConfig } from '../../common/expressions'; import { FramePublicAPI } from '../types'; import { computeOverallDataDomain, getStaticValue } from './reference_line_helpers'; @@ -51,7 +51,7 @@ describe('reference_line helpers', () => { // accessor id has no hit in data expect( getStaticValue( - [{ layerId: 'id-a', seriesType: 'area' } as XYLayerConfig], // missing xAccessor for groupId == x + [{ layerId: 'id-a', seriesType: 'area' } as XYDataLayerConfig], // missing xAccessor for groupId == x 'x', { activeData: getActiveData([ @@ -69,7 +69,7 @@ describe('reference_line helpers', () => { seriesType: 'area', layerType: 'data', accessors: ['d'], - } as XYLayerConfig, + } as XYDataLayerConfig, ], // missing hit of accessor "d" in data 'yLeft', { @@ -88,7 +88,7 @@ describe('reference_line helpers', () => { seriesType: 'area', layerType: 'data', accessors: ['a'], - } as XYLayerConfig, + } as XYDataLayerConfig, ], // missing yConfig fallbacks to left axis, but the requested group is yRight 'yRight', { @@ -107,7 +107,7 @@ describe('reference_line helpers', () => { seriesType: 'area', layerType: 'data', accessors: ['a'], - } as XYLayerConfig, + } as XYDataLayerConfig, ], // same as above with x groupId 'x', { @@ -130,7 +130,7 @@ describe('reference_line helpers', () => { layerType: 'data', accessors: ['a'], yConfig: [{ forAccessor: 'a', axisMode: 'right' }], - } as XYLayerConfig, + } as XYDataLayerConfig, ], 'yRight', { @@ -155,7 +155,7 @@ describe('reference_line helpers', () => { seriesType: 'area', layerType: 'data', accessors: ['a'], - } as XYLayerConfig, + } as XYDataLayerConfig, ], 'yLeft', { @@ -178,7 +178,7 @@ describe('reference_line helpers', () => { layerType: 'data', accessors: ['a'], yConfig: [{ forAccessor: 'a', axisMode: 'right' }], - } as XYLayerConfig, + } as XYDataLayerConfig, ], 'yRight', { @@ -205,7 +205,7 @@ describe('reference_line helpers', () => { seriesType: 'area', layerType: 'data', accessors: ['a', 'b'], - } as XYLayerConfig, + } as XYDataLayerConfig, ], 'yLeft', { activeData: tables }, @@ -220,7 +220,7 @@ describe('reference_line helpers', () => { seriesType: 'area', layerType: 'data', accessors: ['a', 'b'], - } as XYLayerConfig, + } as XYDataLayerConfig, ], 'yRight', { activeData: tables }, @@ -243,7 +243,7 @@ describe('reference_line helpers', () => { seriesType: 'area', layerType: 'data', accessors: ['a', 'b'], - } as XYLayerConfig, + } as XYDataLayerConfig, ], 'yLeft', { activeData: tables }, @@ -258,7 +258,7 @@ describe('reference_line helpers', () => { seriesType: 'area', layerType: 'data', accessors: ['a', 'b'], - } as XYLayerConfig, + } as XYDataLayerConfig, ], 'yRight', { activeData: tables }, @@ -277,7 +277,7 @@ describe('reference_line helpers', () => { layerType: 'data', xAccessor: 'a', accessors: [], - } as XYLayerConfig, + } as XYDataLayerConfig, ], 'x', // this is influenced by the callback { @@ -300,7 +300,7 @@ describe('reference_line helpers', () => { layerType: 'data', xAccessor: 'a', accessors: [], - } as XYLayerConfig, + } as XYDataLayerConfig, ], 'x', { @@ -324,7 +324,7 @@ describe('reference_line helpers', () => { for (const seriesType of ['bar_stacked', 'bar_horizontal_stacked', 'area_stacked']) expect( computeOverallDataDomain( - [{ layerId: 'id-a', seriesType, accessors: ['a', 'b', 'c'] } as XYLayerConfig], + [{ layerId: 'id-a', seriesType, accessors: ['a', 'b', 'c'] } as XYDataLayerConfig], ['a', 'b', 'c'], getActiveData([ { @@ -350,7 +350,7 @@ describe('reference_line helpers', () => { ]) expect( computeOverallDataDomain( - [{ layerId: 'id-a', seriesType, accessors: ['a', 'b', 'c'] } as XYLayerConfig], + [{ layerId: 'id-a', seriesType, accessors: ['a', 'b', 'c'] } as XYDataLayerConfig], ['a', 'b', 'c'], getActiveData([ { @@ -375,7 +375,7 @@ describe('reference_line helpers', () => { [ { layerId: 'id-a', seriesType, accessors: ['a', 'b', 'c'] }, { layerId: 'id-b', seriesType, accessors: ['d', 'e', 'f'] }, - ] as XYLayerConfig[], + ] as XYDataLayerConfig[], ['a', 'b', 'c', 'd', 'e', 'f'], getActiveData([ { id: 'id-a', rows: [{ a: 25, b: 100, c: 100 }] }, @@ -389,7 +389,7 @@ describe('reference_line helpers', () => { [ { layerId: 'id-a', seriesType, accessors: ['a', 'b', 'c'] }, { layerId: 'id-b', seriesType, accessors: ['d', 'e', 'f'] }, - ] as XYLayerConfig[], + ] as XYDataLayerConfig[], ['a', 'b', 'c', 'd', 'e', 'f'], getActiveData([ { @@ -425,7 +425,7 @@ describe('reference_line helpers', () => { [ { layerId: 'id-a', seriesType, accessors: ['a', 'b', 'c'] }, { layerId: 'id-b', seriesType, accessors: ['d', 'e', 'f'] }, - ] as XYLayerConfig[], + ] as XYDataLayerConfig[], ['a', 'b', 'c', 'd', 'e', 'f'], getActiveData([ { id: 'id-a', rows: Array(3).fill({ a: 100, b: 100, c: 100 }) }, @@ -443,7 +443,7 @@ describe('reference_line helpers', () => { [ { layerId: 'id-a', seriesType: nonStackedSeries, accessors: ['a', 'b', 'c'] }, { layerId: 'id-b', seriesType: stackedSeries, accessors: ['d', 'e', 'f'] }, - ] as XYLayerConfig[], + ] as XYDataLayerConfig[], ['a', 'b', 'c', 'd', 'e', 'f'], getActiveData([ { id: 'id-a', rows: [{ a: 100, b: 100, c: 100 }] }, @@ -465,7 +465,7 @@ describe('reference_line helpers', () => { [ { layerId: 'id-a', seriesType, xAccessor: 'c', accessors: ['a', 'b'] }, { layerId: 'id-b', seriesType, xAccessor: 'f', accessors: ['d', 'e'] }, - ] as XYLayerConfig[], + ] as XYDataLayerConfig[], ['a', 'b', 'd', 'e'], getActiveData([ { @@ -492,7 +492,7 @@ describe('reference_line helpers', () => { [ { layerId: 'id-a', seriesType, accessors: ['c'] }, { layerId: 'id-b', seriesType, accessors: ['f'] }, - ] as XYLayerConfig[], + ] as XYDataLayerConfig[], ['c', 'f'], getActiveData([ { @@ -520,7 +520,7 @@ describe('reference_line helpers', () => { [ { layerId: 'id-a', seriesType, xAccessor: 'c', accessors: ['a', 'b'] }, { layerId: 'id-b', seriesType, xAccessor: 'f', accessors: ['d', 'e'] }, - ] as XYLayerConfig[], + ] as XYDataLayerConfig[], ['a', 'b', 'd', 'e'], getActiveData([ { @@ -549,7 +549,7 @@ describe('reference_line helpers', () => { layerId: 'id-a', seriesType: 'area_stacked', accessors: ['a', 'b', 'c'], - } as XYLayerConfig, + } as XYDataLayerConfig, ], ['a', 'b', 'c'], getActiveData([ @@ -568,7 +568,13 @@ describe('reference_line helpers', () => { ).toEqual({ min: 0, max: 200 }); // it is stacked, so max is the sum and 0 is the fallback expect( computeOverallDataDomain( - [{ layerId: 'id-a', seriesType: 'area', accessors: ['a', 'b', 'c'] } as XYLayerConfig], + [ + { + layerId: 'id-a', + seriesType: 'area', + accessors: ['a', 'b', 'c'], + } as XYDataLayerConfig, + ], ['a', 'b', 'c'], getActiveData([ { @@ -602,7 +608,7 @@ describe('reference_line helpers', () => { [ { layerId: 'id-a', seriesType: 'area', accessors: ['a', 'b', 'c'] }, { layerId: 'id-b', seriesType: 'line', accessors: ['d', 'e', 'f'] }, - ] as XYLayerConfig[], + ] as XYDataLayerConfig[], ['a', 'b'], getActiveData([{ id: 'id-c', rows: [{ a: 100, b: 100 }] }]) // mind the layer id here ) @@ -613,7 +619,7 @@ describe('reference_line helpers', () => { [ { layerId: 'id-a', seriesType: 'bar', accessors: ['a', 'b', 'c'] }, { layerId: 'id-b', seriesType: 'bar_stacked' }, - ] as XYLayerConfig[], + ] as XYDataLayerConfig[], ['a', 'b'], getActiveData([]) ) diff --git a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx index 42e46b65f5c1..05a81b15efab 100644 --- a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx @@ -8,7 +8,7 @@ import { groupBy, partition } from 'lodash'; import { i18n } from '@kbn/i18n'; import { layerTypes } from '../../common'; -import type { XYLayerConfig, YConfig } from '../../common/expressions'; +import type { XYDataLayerConfig, XYLayerConfig, YConfig } from '../../common/expressions'; import { Datatable } from '../../../../../src/plugins/expressions/public'; import type { AccessorConfig, DatasourcePublicAPI, FramePublicAPI, Visualization } from '../types'; import { groupAxesByType } from './axes_configuration'; @@ -17,7 +17,7 @@ import type { XYState } from './types'; import { checkScaleOperation, getAxisName, - isDataLayer, + getDataLayers, isNumericMetric, } from './visualization_helpers'; import { generateId } from '../id_generator'; @@ -41,9 +41,7 @@ export function getGroupsToShow - isDataLayer({ layerType }) - ); + const dataLayers = getDataLayers(state.layers); const groupsAvailable = getGroupsAvailableInData(dataLayers, datasourceLayers, tables); return referenceLayers .filter(({ label, config }: T) => groupsAvailable[label] || config?.length) @@ -62,9 +60,7 @@ export function getGroupsRelatedToData( if (!state) { return []; } - const dataLayers = state.layers.filter(({ layerType = layerTypes.DATA }) => - isDataLayer({ layerType }) - ); + const dataLayers = getDataLayers(state.layers); const groupsAvailable = getGroupsAvailableInData(dataLayers, datasourceLayers, tables); return referenceLayers.filter(({ label }: T) => groupsAvailable[label]); } @@ -72,7 +68,7 @@ export function getGroupsRelatedToData( * Returns a dictionary with the groups filled in all the data layers */ export function getGroupsAvailableInData( - dataLayers: XYState['layers'], + dataLayers: XYDataLayerConfig[], datasourceLayers: Record, tables: Record | undefined ) { @@ -88,10 +84,10 @@ export function getGroupsAvailableInData( } export function getStaticValue( - dataLayers: XYState['layers'], + dataLayers: XYDataLayerConfig[], groupId: 'x' | 'yLeft' | 'yRight', { activeData }: Pick, - layerHasNumberHistogram: (layer: XYLayerConfig) => boolean + layerHasNumberHistogram: (layer: XYDataLayerConfig) => boolean ) { const fallbackValue = 100; if (!activeData) { @@ -124,7 +120,7 @@ export function getStaticValue( function getAccessorCriteriaForGroup( groupId: 'x' | 'yLeft' | 'yRight', - dataLayers: XYState['layers'], + dataLayers: XYDataLayerConfig[], activeData: FramePublicAPI['activeData'] ) { switch (groupId) { @@ -158,7 +154,7 @@ function getAccessorCriteriaForGroup( } export function computeOverallDataDomain( - dataLayers: Array>, + dataLayers: XYDataLayerConfig[], accessorIds: string[], activeData: NonNullable, allowStacking: boolean = true @@ -222,7 +218,7 @@ export function computeOverallDataDomain( } function computeStaticValueForGroup( - dataLayers: Array>, + dataLayers: XYDataLayerConfig[], accessorIds: string[], activeData: NonNullable, minZeroOrNegativeBase: boolean = true, @@ -275,8 +271,7 @@ export const getReferenceSupportedLayer = ( frame?.datasourceLayers || {}, frame?.activeData ); - const dataLayers = - state?.layers.filter(({ layerType = layerTypes.DATA }) => isDataLayer({ layerType })) || []; + const dataLayers = getDataLayers(state?.layers || []); const filledDataLayers = dataLayers.filter( ({ accessors, xAccessor }) => accessors.length || xAccessor ); diff --git a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts index 14a82011cb52..f37ff5460f31 100644 --- a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts +++ b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts @@ -9,6 +9,7 @@ import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; import type { FramePublicAPI, DatasourcePublicAPI } from '../types'; import type { SeriesType, XYLayerConfig, YConfig, ValidLayer } from '../../common/expressions'; import { visualizationTypes } from './types'; +import { getDataLayers, isDataLayer } from './visualization_helpers'; export function isHorizontalSeries(seriesType: SeriesType) { return ( @@ -30,8 +31,8 @@ export function isStackedChart(seriesType: SeriesType) { return seriesType.includes('stacked'); } -export function isHorizontalChart(layers: Array<{ seriesType: SeriesType }>) { - return layers.every((l) => isHorizontalSeries(l.seriesType)); +export function isHorizontalChart(layers: XYLayerConfig[]) { + return getDataLayers(layers).every((l) => isHorizontalSeries(l.seriesType)); } export function getIconForSeries(type: SeriesType): EuiIconType { @@ -45,7 +46,7 @@ export function getIconForSeries(type: SeriesType): EuiIconType { } export const getSeriesColor = (layer: XYLayerConfig, accessor: string) => { - if (layer.splitAccessor) { + if (isDataLayer(layer) && layer.splitAccessor) { return null; } return ( @@ -55,13 +56,14 @@ export const getSeriesColor = (layer: XYLayerConfig, accessor: string) => { export const getColumnToLabelMap = (layer: XYLayerConfig, datasource: DatasourcePublicAPI) => { const columnToLabel: Record = {}; - - layer.accessors.concat(layer.splitAccessor ? [layer.splitAccessor] : []).forEach((accessor) => { - const operation = datasource.getOperationForColumnId(accessor); - if (operation?.label) { - columnToLabel[accessor] = operation.label; - } - }); + layer.accessors + .concat(isDataLayer(layer) && layer.splitAccessor ? [layer.splitAccessor] : []) + .forEach((accessor) => { + const operation = datasource.getOperationForColumnId(accessor); + if (operation?.label) { + columnToLabel[accessor] = operation.label; + } + }); return columnToLabel; }; diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index c0dfbd998608..ac3fdcf30a4a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -9,7 +9,7 @@ import { Ast } from '@kbn/interpreter'; import { Position } from '@elastic/charts'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { getXyVisualization } from './xy_visualization'; -import { Operation } from '../types'; +import { OperationDescriptor } from '../types'; import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { layerTypes } from '../../common'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; @@ -31,14 +31,14 @@ describe('#toExpression', () => { mockDatasource = createMockDatasource('testDatasource'); mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ - { columnId: 'd' }, - { columnId: 'a' }, - { columnId: 'b' }, - { columnId: 'c' }, + { columnId: 'd', fields: [] }, + { columnId: 'a', fields: [] }, + { columnId: 'b', fields: [] }, + { columnId: 'c', fields: [] }, ]); mockDatasource.publicAPIMock.getOperationForColumnId.mockImplementation((col) => { - return { label: `col_${col}`, dataType: 'number' } as Operation; + return { label: `col_${col}`, dataType: 'number' } as OperationDescriptor; }); frame.datasourceLayers = { @@ -343,9 +343,6 @@ describe('#toExpression', () => { { layerId: 'referenceLine', layerType: layerTypes.REFERENCELINE, - seriesType: 'area', - splitAccessor: 'd', - xAccessor: 'a', accessors: ['b', 'c'], yConfig: [{ forAccessor: 'a' }], }, diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 8fa76d0b997d..8a79e05cb466 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -11,12 +11,17 @@ import { PaletteRegistry } from 'src/plugins/charts/public'; import { State } from './types'; import { OperationMetadata, DatasourcePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; -import type { ValidLayer, XYLayerConfig } from '../../common/expressions'; +import type { + ValidLayer, + XYLayerConfig, + XYReferenceLineLayerConfig, + YConfig, +} from '../../common/expressions'; import { layerTypes } from '../../common'; import { hasIcon } from './xy_config_panel/shared/icon_select'; import { defaultReferenceLineColor } from './color_assignment'; import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; -import { isDataLayer, isReferenceLayer } from './visualization_helpers'; +import { isDataLayer } from './visualization_helpers'; export const getSortedAccessors = (datasource: DatasourcePublicAPI, layer: XYLayerConfig) => { const originalOrder = datasource @@ -163,6 +168,7 @@ export const buildExpression = ( : [], position: [state.legend.position], isInside: state.legend.isInside ? [state.legend.isInside] : [], + legendSize: state.legend.legendSize ? [state.legend.legendSize] : [], horizontalAlignment: state.legend.horizontalAlignment ? [state.legend.horizontalAlignment] : [], @@ -299,100 +305,140 @@ export const buildExpression = ( hideEndzones: [state?.hideEndzones || false], valuesInLegend: [state?.valuesInLegend || false], layers: validLayers.map((layer) => { - const columnToLabel = getColumnToLabelMap(layer, datasourceLayers[layer.layerId]); + if (isDataLayer(layer)) { + return dataLayerToExpression( + layer, + datasourceLayers[layer.layerId], + metadata, + paletteService + ); + } + return referenceLineLayerToExpression( + layer, + datasourceLayers[(layer as XYReferenceLineLayerConfig).layerId] + ); + }), + }, + }, + ], + }; +}; - const xAxisOperation = - datasourceLayers && - datasourceLayers[layer.layerId].getOperationForColumnId(layer.xAccessor); +const referenceLineLayerToExpression = ( + layer: XYReferenceLineLayerConfig, + datasourceLayer: DatasourcePublicAPI +): Ast => { + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_referenceLine_layer', + arguments: { + layerId: [layer.layerId], + yConfig: layer.yConfig + ? layer.yConfig.map((yConfig) => + yConfigToExpression(yConfig, defaultReferenceLineColor) + ) + : [], + layerType: [layerTypes.REFERENCELINE], + accessors: layer.accessors, + columnToLabel: [JSON.stringify(getColumnToLabelMap(layer, datasourceLayer))], + }, + }, + ], + }; +}; - const isHistogramDimension = Boolean( - xAxisOperation && - xAxisOperation.isBucketed && - xAxisOperation.scale && - xAxisOperation.scale !== 'ordinal' - ); +const dataLayerToExpression = ( + layer: ValidLayer, + datasourceLayer: DatasourcePublicAPI, + metadata: Record>, + paletteService: PaletteRegistry +): Ast => { + const columnToLabel = getColumnToLabelMap(layer, datasourceLayer); - return { - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_xy_layer', - arguments: { - layerId: [layer.layerId], + const xAxisOperation = datasourceLayer?.getOperationForColumnId(layer.xAccessor); - hide: [Boolean(layer.hide)], + const isHistogramDimension = Boolean( + xAxisOperation && + xAxisOperation.isBucketed && + xAxisOperation.scale && + xAxisOperation.scale !== 'ordinal' + ); - xAccessor: layer.xAccessor ? [layer.xAccessor] : [], - yScaleType: [ - getScaleType(metadata[layer.layerId][layer.accessors[0]], ScaleType.Ordinal), - ], - xScaleType: [ - getScaleType(metadata[layer.layerId][layer.xAccessor], ScaleType.Linear), - ], - isHistogram: [isHistogramDimension], - splitAccessor: layer.splitAccessor ? [layer.splitAccessor] : [], - yConfig: layer.yConfig - ? layer.yConfig.map((yConfig) => ({ - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_xy_yConfig', - arguments: { - forAccessor: [yConfig.forAccessor], - axisMode: yConfig.axisMode ? [yConfig.axisMode] : [], - color: isReferenceLayer(layer) - ? [yConfig.color || defaultReferenceLineColor] - : yConfig.color - ? [yConfig.color] - : [], - lineStyle: [yConfig.lineStyle || 'solid'], - lineWidth: [yConfig.lineWidth || 1], - fill: [yConfig.fill || 'none'], - icon: hasIcon(yConfig.icon) ? [yConfig.icon] : [], - iconPosition: - hasIcon(yConfig.icon) || yConfig.textVisibility - ? [yConfig.iconPosition || 'auto'] - : ['auto'], - textVisibility: [yConfig.textVisibility || false], - }, - }, - ], - })) - : [], - seriesType: [layer.seriesType], - layerType: [layer.layerType || layerTypes.DATA], - accessors: layer.accessors, - columnToLabel: [JSON.stringify(columnToLabel)], - ...(layer.palette - ? { - palette: [ - { - type: 'expression', - chain: [ - { - type: 'function', - function: 'theme', - arguments: { - variable: ['palette'], - default: [ - paletteService - .get(layer.palette.name) - .toExpression(layer.palette.params), - ], - }, - }, - ], - }, + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_data_layer', + arguments: { + layerId: [layer.layerId], + hide: [Boolean(layer.hide)], + xAccessor: layer.xAccessor ? [layer.xAccessor] : [], + yScaleType: [ + getScaleType(metadata[layer.layerId][layer.accessors[0]], ScaleType.Ordinal), + ], + xScaleType: [getScaleType(metadata[layer.layerId][layer.xAccessor], ScaleType.Linear)], + isHistogram: [isHistogramDimension], + splitAccessor: layer.splitAccessor ? [layer.splitAccessor] : [], + yConfig: layer.yConfig + ? layer.yConfig.map((yConfig) => yConfigToExpression(yConfig)) + : [], + seriesType: [layer.seriesType], + layerType: [layerTypes.DATA], + accessors: layer.accessors, + columnToLabel: [JSON.stringify(columnToLabel)], + ...(layer.palette + ? { + palette: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'theme', + arguments: { + variable: ['palette'], + default: [ + paletteService + .get(layer.palette.name) + .toExpression(layer.palette.params), ], - } - : {}), + }, + }, + ], }, - }, - ], - }; - }), + ], + } + : {}), + }, + }, + ], + }; +}; + +const yConfigToExpression = (yConfig: YConfig, defaultColor?: string): Ast => { + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_yConfig', + arguments: { + forAccessor: [yConfig.forAccessor], + axisMode: yConfig.axisMode ? [yConfig.axisMode] : [], + color: yConfig.color ? [yConfig.color] : defaultColor ? [defaultColor] : [], + lineStyle: [yConfig.lineStyle || 'solid'], + lineWidth: [yConfig.lineWidth || 1], + fill: [yConfig.fill || 'none'], + icon: hasIcon(yConfig.icon) ? [yConfig.icon] : [], + iconPosition: + hasIcon(yConfig.icon) || yConfig.textVisibility + ? [yConfig.iconPosition || 'auto'] + : ['auto'], + textVisibility: [yConfig.textVisibility || false], }, }, ], diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 51cf15c29264..5b430fd7fc57 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -7,9 +7,9 @@ import { getXyVisualization } from './visualization'; import { Position } from '@elastic/charts'; -import { Operation, VisualizeEditorContext, Suggestion } from '../types'; +import { Operation, VisualizeEditorContext, Suggestion, OperationDescriptor } from '../types'; import type { State, XYSuggestion } from './types'; -import type { SeriesType, XYLayerConfig } from '../../common/expressions'; +import type { SeriesType, XYDataLayerConfig, XYLayerConfig } from '../../common/expressions'; import { layerTypes } from '../../common'; import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { LensIconChartBar } from '../assets/chart_bar'; @@ -32,7 +32,7 @@ function exampleState(): State { splitAccessor: 'd', xAccessor: 'a', accessors: ['b', 'c'], - }, + } as XYDataLayerConfig, ], }; } @@ -105,7 +105,7 @@ describe('xy_visualization', () => { return { ...state, layers: types.map((t, i) => ({ - ...state.layers[0], + ...(state.layers[0] as XYDataLayerConfig), layerId: `layer_${i}`, seriesType: t, })), @@ -143,7 +143,7 @@ describe('xy_visualization', () => { const initialState = xyVisualization.initialize(() => 'l1'); expect(initialState.layers).toHaveLength(1); - expect(initialState.layers[0].xAccessor).not.toBeDefined(); + expect((initialState.layers[0] as XYDataLayerConfig).xAccessor).not.toBeDefined(); expect(initialState.layers[0].accessors).toHaveLength(0); expect(initialState).toMatchInlineSnapshot(` @@ -246,10 +246,10 @@ describe('xy_visualization', () => { mockDatasource = createMockDatasource('testDatasource'); mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ - { columnId: 'd' }, - { columnId: 'a' }, - { columnId: 'b' }, - { columnId: 'c' }, + { columnId: 'd', fields: [] }, + { columnId: 'a', fields: [] }, + { columnId: 'b', fields: [] }, + { columnId: 'c', fields: [] }, ]); frame.datasourceLayers = { @@ -333,7 +333,6 @@ describe('xy_visualization', () => { { layerId: 'referenceLine', layerType: layerTypes.REFERENCELINE, - seriesType: 'line', accessors: [], }, ], @@ -345,7 +344,6 @@ describe('xy_visualization', () => { ).toEqual({ layerId: 'referenceLine', layerType: layerTypes.REFERENCELINE, - seriesType: 'line', accessors: ['newCol'], yConfig: [ { @@ -367,10 +365,10 @@ describe('xy_visualization', () => { mockDatasource = createMockDatasource('testDatasource'); mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ - { columnId: 'd' }, - { columnId: 'a' }, - { columnId: 'b' }, - { columnId: 'c' }, + { columnId: 'd', fields: [] }, + { columnId: 'a', fields: [] }, + { columnId: 'b', fields: [] }, + { columnId: 'c', fields: [] }, ]); frame.datasourceLayers = { @@ -432,11 +430,54 @@ describe('xy_visualization', () => { }, ]); - expect(state?.layers[0].palette).toStrictEqual({ + expect((state?.layers[0] as XYDataLayerConfig).palette).toStrictEqual({ name: 'temperature', type: 'palette', }); }); + + it('sets the context configuration correctly for reference lines', () => { + const newContext = { + ...context, + metrics: [ + { + agg: 'static_value', + fieldName: 'document', + isFullReference: true, + color: '#68BC00', + params: { + value: '10', + }, + }, + ], + }; + const state = xyVisualization?.updateLayersConfigurationFromContext?.({ + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'line', + xAccessor: undefined, + accessors: ['a'], + }, + ], + }, + layerId: 'first', + context: newContext, + }); + expect(state?.layers[0]).toHaveProperty('seriesType', 'area'); + expect(state?.layers[0]).toHaveProperty('layerType', 'referenceLine'); + expect(state?.layers[0].yConfig).toStrictEqual([ + { + axisMode: 'right', + color: '#68BC00', + forAccessor: 'a', + fill: 'below', + }, + ]); + }); }); describe('#getVisualizationSuggestionFromContext', () => { @@ -603,10 +644,10 @@ describe('xy_visualization', () => { mockDatasource = createMockDatasource('testDatasource'); mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ - { columnId: 'd' }, - { columnId: 'a' }, - { columnId: 'b' }, - { columnId: 'c' }, + { columnId: 'd', fields: [] }, + { columnId: 'a', fields: [] }, + { columnId: 'b', fields: [] }, + { columnId: 'c', fields: [] }, ]); frame.datasourceLayers = { @@ -660,10 +701,10 @@ describe('xy_visualization', () => { mockDatasource = createMockDatasource('testDatasource'); mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ - { columnId: 'd' }, - { columnId: 'a' }, - { columnId: 'b' }, - { columnId: 'c' }, + { columnId: 'd', fields: [] }, + { columnId: 'a', fields: [] }, + { columnId: 'b', fields: [] }, + { columnId: 'c', fields: [] }, ]); frame.datasourceLayers = { @@ -787,7 +828,11 @@ describe('xy_visualization', () => { state: { ...baseState, layers: [ - { ...baseState.layers[0], accessors: ['a'], seriesType: 'bar_percentage_stacked' }, + { + ...baseState.layers[0], + accessors: ['a'], + seriesType: 'bar_percentage_stacked', + } as XYDataLayerConfig, ], }, frame, @@ -1010,7 +1055,6 @@ describe('xy_visualization', () => { { layerId: 'referenceLine', layerType: layerTypes.REFERENCELINE, - seriesType: 'line', accessors: [], yConfig: [{ axisMode: 'left', forAccessor: 'a' }], }, @@ -1073,8 +1117,8 @@ describe('xy_visualization', () => { it('should compute no groups for referenceLines when the only data accessor available is a date histogram', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].xAccessor = 'b'; - state.layers[0].accessors = []; + (state.layers[0] as XYDataLayerConfig).xAccessor = 'b'; + (state.layers[0] as XYDataLayerConfig).accessors = []; state.layers[1].yConfig = []; // empty the configuration // set the xAccessor as date_histogram frame.datasourceLayers.referenceLine.getOperationForColumnId = jest.fn((accessor) => { @@ -1084,6 +1128,8 @@ describe('xy_visualization', () => { isBucketed: true, scale: 'interval', label: 'date_histogram', + isStaticValue: false, + hasTimeShift: false, }; } return null; @@ -1100,8 +1146,8 @@ describe('xy_visualization', () => { it('should mark horizontal group is invalid when xAccessor is changed to a date histogram', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].xAccessor = 'b'; - state.layers[0].accessors = []; + (state.layers[0] as XYDataLayerConfig).xAccessor = 'b'; + (state.layers[0] as XYDataLayerConfig).accessors = []; state.layers[1].yConfig![0].axisMode = 'bottom'; // set the xAccessor as date_histogram frame.datasourceLayers.referenceLine.getOperationForColumnId = jest.fn((accessor) => { @@ -1111,6 +1157,8 @@ describe('xy_visualization', () => { isBucketed: true, scale: 'interval', label: 'date_histogram', + isStaticValue: false, + hasTimeShift: false, }; } return null; @@ -1132,10 +1180,10 @@ describe('xy_visualization', () => { it('should return groups in a specific order (left, right, bottom)', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].xAccessor = 'c'; - state.layers[0].accessors = ['a', 'b']; + (state.layers[0] as XYDataLayerConfig).xAccessor = 'c'; + (state.layers[0] as XYDataLayerConfig).accessors = ['a', 'b']; // invert them on purpose - state.layers[0].yConfig = [ + (state.layers[0] as XYDataLayerConfig).yConfig = [ { axisMode: 'right', forAccessor: 'b' }, { axisMode: 'left', forAccessor: 'a' }, ]; @@ -1152,6 +1200,8 @@ describe('xy_visualization', () => { isBucketed: true, scale: 'interval', label: 'histogram', + isStaticValue: false, + hasTimeShift: false, }; } return null; @@ -1170,8 +1220,8 @@ describe('xy_visualization', () => { it('should ignore terms operation for xAccessor', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].xAccessor = 'b'; - state.layers[0].accessors = []; + (state.layers[0] as XYDataLayerConfig).xAccessor = 'b'; + (state.layers[0] as XYDataLayerConfig).accessors = []; state.layers[1].yConfig = []; // empty the configuration // set the xAccessor as top values frame.datasourceLayers.referenceLine.getOperationForColumnId = jest.fn((accessor) => { @@ -1181,6 +1231,8 @@ describe('xy_visualization', () => { isBucketed: true, scale: 'ordinal', label: 'top values', + isStaticValue: false, + hasTimeShift: false, }; } return null; @@ -1197,8 +1249,8 @@ describe('xy_visualization', () => { it('should mark horizontal group is invalid when accessor is changed to a terms operation', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].xAccessor = 'b'; - state.layers[0].accessors = []; + (state.layers[0] as XYDataLayerConfig).xAccessor = 'b'; + (state.layers[0] as XYDataLayerConfig).accessors = []; state.layers[1].yConfig![0].axisMode = 'bottom'; // set the xAccessor as date_histogram frame.datasourceLayers.referenceLine.getOperationForColumnId = jest.fn((accessor) => { @@ -1208,6 +1260,8 @@ describe('xy_visualization', () => { isBucketed: true, scale: 'ordinal', label: 'top values', + isStaticValue: false, + hasTimeShift: false, }; } return null; @@ -1262,7 +1316,7 @@ describe('xy_visualization', () => { }; const state = getStateWithBaseReferenceLine(); - state.layers[0].accessors = ['yAccessorId', 'yAccessorId2']; + (state.layers[0] as XYDataLayerConfig).accessors = ['yAccessorId', 'yAccessorId2']; state.layers[1].yConfig = []; // empty the configuration const options = xyVisualization.getConfiguration({ @@ -1282,8 +1336,12 @@ describe('xy_visualization', () => { it('should be excluded and not crash when a custom palette is used for data layer', () => { const state = getStateWithBaseReferenceLine(); // now add a breakdown on the data layer with a custom palette - state.layers[0].palette = { type: 'palette', name: 'custom', params: {} }; - state.layers[0].splitAccessor = 'd'; + (state.layers[0] as XYDataLayerConfig).palette = { + type: 'palette', + name: 'custom', + params: {}, + }; + (state.layers[0] as XYDataLayerConfig).splitAccessor = 'd'; const options = xyVisualization.getConfiguration({ state, @@ -1306,7 +1364,7 @@ describe('xy_visualization', () => { ...baseState.layers[0], splitAccessor: undefined, ...layerConfigOverride, - }, + } as XYDataLayerConfig, ], }, frame, @@ -1435,8 +1493,8 @@ describe('xy_visualization', () => { it('should respect the order of accessors coming from datasource', () => { mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ - { columnId: 'c' }, - { columnId: 'b' }, + { columnId: 'c', fields: [] }, + { columnId: 'b', fields: [] }, ]); const paletteGetter = jest.spyOn(paletteServiceMock, 'get'); // overrite palette with a palette returning first blue, then green as color @@ -1470,7 +1528,7 @@ describe('xy_visualization', () => { mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ dataType: 'string', label: 'MyOperation', - } as Operation); + } as OperationDescriptor); frame.datasourceLayers = { first: mockDatasource.publicAPIMock, @@ -1720,7 +1778,7 @@ describe('xy_visualization', () => { ? ({ dataType: 'date', scale: 'interval', - } as unknown as Operation) + } as unknown as OperationDescriptor) : null ); datasourceLayers.second.getOperationForColumnId = jest.fn((id: string) => @@ -1728,7 +1786,7 @@ describe('xy_visualization', () => { ? ({ dataType: 'number', scale: 'interval', - } as unknown as Operation) + } as unknown as OperationDescriptor) : null ); expect( @@ -1776,7 +1834,7 @@ describe('xy_visualization', () => { ? ({ dataType: 'date', scale: 'interval', - } as unknown as Operation) + } as unknown as OperationDescriptor) : null ); datasourceLayers.second.getOperationForColumnId = jest.fn((id: string) => @@ -1784,7 +1842,7 @@ describe('xy_visualization', () => { ? ({ dataType: 'string', scale: 'ordinal', - } as unknown as Operation) + } as unknown as OperationDescriptor) : null ); expect( @@ -1830,10 +1888,10 @@ describe('xy_visualization', () => { mockDatasource = createMockDatasource('testDatasource'); mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ - { columnId: 'd' }, - { columnId: 'a' }, - { columnId: 'b' }, - { columnId: 'c' }, + { columnId: 'd', fields: [] }, + { columnId: 'a', fields: [] }, + { columnId: 'b', fields: [] }, + { columnId: 'c', fields: [] }, ]); frame.datasourceLayers = { diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 54fc4e0594a7..69349b1de344 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; -import { groupBy, uniq } from 'lodash'; import { render } from 'react-dom'; import { Position } from '@elastic/charts'; import { FormattedMessage, I18nProvider } from '@kbn/i18n-react'; @@ -16,13 +15,14 @@ import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { ThemeServiceStart } from 'kibana/public'; import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public'; +import type { FillStyle } from '../../common/expressions/xy_chart'; import { getSuggestions } from './xy_suggestions'; import { XyToolbar } from './xy_config_panel'; import { DimensionEditor } from './xy_config_panel/dimension_editor'; import { LayerHeader } from './xy_config_panel/layer_header'; import type { Visualization, AccessorConfig, FramePublicAPI } from '../types'; import { State, visualizationTypes, XYSuggestion } from './types'; -import { SeriesType, XYLayerConfig, YAxisMode } from '../../common/expressions'; +import { SeriesType, XYDataLayerConfig, XYLayerConfig, YAxisMode } from '../../common/expressions'; import { layerTypes } from '../../common'; import { isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; @@ -38,7 +38,9 @@ import { checkXAccessorCompatibility, defaultSeriesType, getAxisName, + getDataLayers, getDescription, + getFirstDataLayer, getLayersByType, getVisualizationType, isBucketed, @@ -87,16 +89,12 @@ export const getXyVisualization = ({ }, appendLayer(state, layerId, layerType) { - const usedSeriesTypes = uniq(state.layers.map((layer) => layer.seriesType)); + const firstUsedSeriesType = getDataLayers(state.layers)?.[0]?.seriesType; return { ...state, layers: [ ...state.layers, - newLayerState( - usedSeriesTypes.length === 1 ? usedSeriesTypes[0] : state.preferredSeriesType, - layerId, - layerType - ), + newLayerState(firstUsedSeriesType || state.preferredSeriesType, layerId, layerType), ], }; }, @@ -175,6 +173,7 @@ export const getXyVisualization = ({ if (isReferenceLayer(layer)) { return getReferenceConfiguration({ state, frame, layer, sortedAccessors, mappedAccessors }); } + const dataLayers = getDataLayers(state.layers); const isHorizontal = isHorizontalChart(state.layers); const { left, right } = groupAxesByType([layer], frame.activeData); @@ -185,24 +184,21 @@ export const getXyVisualization = ({ (right.length && right.length < 2) ); // Check also for multiple layers that can stack for percentage charts - // Make sure that if multiple dimensions are defined for a single layer, they should belong to the same axis + // Make sure that if multiple dimensions are defined for a single dataLayer, they should belong to the same axis const hasOnlyOneAccessor = layerHasOnlyOneAccessor && - getLayersByType(state, layerTypes.DATA).filter( + dataLayers.filter( // check that the other layers are compatible with this one - (dataLayer) => { + (l) => { if ( - dataLayer.seriesType === layer.seriesType && - Boolean(dataLayer.xAccessor) === Boolean(layer.xAccessor) && - Boolean(dataLayer.splitAccessor) === Boolean(layer.splitAccessor) + l.seriesType === layer.seriesType && + Boolean(l.xAccessor) === Boolean(layer.xAccessor) && + Boolean(l.splitAccessor) === Boolean(layer.splitAccessor) ) { - const { left: localLeft, right: localRight } = groupAxesByType( - [dataLayer], - frame.activeData - ); + const { left: localLeft, right: localRight } = groupAxesByType([l], frame.activeData); // return true only if matching axis are found return ( - dataLayer.accessors.length && + l.accessors.length && (Boolean(localLeft.length) === Boolean(left.length) || Boolean(localRight.length) === Boolean(right.length)) ); @@ -259,7 +255,7 @@ export const getXyVisualization = ({ getMainPalette: (state) => { if (!state || state.layers.length === 0) return; - return state.layers[0].palette; + return getFirstDataLayer(state.layers)?.palette; }, setDimension(props) { @@ -295,12 +291,14 @@ export const getXyVisualization = ({ if (!foundLayer) { return prevState; } + const isReferenceLine = metrics.some((metric) => metric.agg === 'static_value'); const axisMode = axisPosition as YAxisMode; const yConfig = metrics.map((metric, idx) => { return { color: metric.color, forAccessor: metric.accessor ?? foundLayer.accessors[idx], ...(axisMode && { axisMode }), + ...(isReferenceLine && { fill: chartType === 'area' ? 'below' : ('none' as FillStyle) }), }; }); const newLayer = { @@ -308,7 +306,8 @@ export const getXyVisualization = ({ ...(chartType && { seriesType: chartType as SeriesType }), ...(palette && { palette }), yConfig, - }; + layerType: isReferenceLine ? layerTypes.REFERENCELINE : layerTypes.DATA, + } as XYLayerConfig; const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); @@ -371,14 +370,18 @@ export const getXyVisualization = ({ if (!foundLayer) { return prevState; } + const dataLayers = getDataLayers(prevState.layers); const newLayer = { ...foundLayer }; - if (newLayer.xAccessor === columnId) { - delete newLayer.xAccessor; - } else if (newLayer.splitAccessor === columnId) { - delete newLayer.splitAccessor; - // as the palette is associated with the break down by dimension, remove it together with the dimension - delete newLayer.palette; - } else if (newLayer.accessors.includes(columnId)) { + if (isDataLayer(newLayer)) { + if (newLayer.xAccessor === columnId) { + delete newLayer.xAccessor; + } else if (newLayer.splitAccessor === columnId) { + delete newLayer.splitAccessor; + // as the palette is associated with the break down by dimension, remove it together with the dimension + delete newLayer.palette; + } + } + if (newLayer.accessors.includes(columnId)) { newLayer.accessors = newLayer.accessors.filter((a) => a !== columnId); } @@ -388,10 +391,9 @@ export const getXyVisualization = ({ let newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); // check if there's any reference layer and pull it off if all data layers have no dimensions set - const layersByType = groupBy(newLayers, ({ layerType }) => layerType); // check for data layers if they all still have xAccessors const groupsAvailable = getGroupsAvailableInData( - layersByType[layerTypes.DATA], + dataLayers, frame.datasourceLayers, frame?.activeData ); @@ -453,9 +455,11 @@ export const getXyVisualization = ({ getErrorMessages(state, datasourceLayers) { // Data error handling below here - const hasNoAccessors = ({ accessors }: XYLayerConfig) => + const hasNoAccessors = ({ accessors }: XYDataLayerConfig) => accessors == null || accessors.length === 0; - const hasNoSplitAccessor = ({ splitAccessor, seriesType }: XYLayerConfig) => + + const dataLayers = getDataLayers(state.layers); + const hasNoSplitAccessor = ({ splitAccessor, seriesType }: XYDataLayerConfig) => seriesType.includes('percentage') && splitAccessor == null; const errors: Array<{ @@ -466,16 +470,15 @@ export const getXyVisualization = ({ // check if the layers in the state are compatible with this type of chart if (state && state.layers.length > 1) { // Order is important here: Y Axis is fundamental to exist to make it valid - const checks: Array<[string, (layer: XYLayerConfig) => boolean]> = [ + const checks: Array<[string, (layer: XYDataLayerConfig) => boolean]> = [ ['Y', hasNoAccessors], ['Break down', hasNoSplitAccessor], ]; // filter out those layers with no accessors at all - const filteredLayers = state.layers.filter( - ({ accessors, xAccessor, splitAccessor, layerType }: XYLayerConfig) => - isDataLayer({ layerType }) && - (accessors.length > 0 || xAccessor != null || splitAccessor != null) + const filteredLayers = dataLayers.filter( + ({ accessors, xAccessor, splitAccessor, layerType }) => + accessors.length > 0 || xAccessor != null || splitAccessor != null ); for (const [dimension, criteria] of checks) { const result = validateLayersForDimension(dimension, filteredLayers, criteria); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx index 6031ed722303..69df0d80300b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx @@ -10,7 +10,12 @@ import { uniq } from 'lodash'; import { DatasourcePublicAPI, OperationMetadata, VisualizationType } from '../types'; import { State, visualizationTypes, XYState } from './types'; import { isHorizontalChart } from './state_helpers'; -import { SeriesType, XYLayerConfig } from '../../common/expressions'; +import { + SeriesType, + XYDataLayerConfig, + XYLayerConfig, + XYReferenceLineLayerConfig, +} from '../../common/expressions'; import { layerTypes } from '..'; import { LensIconChartBarHorizontal } from '../assets/chart_bar_horizontal'; import { LensIconChartMixedXy } from '../assets/chart_mixed_xy'; @@ -59,14 +64,15 @@ export function checkXAccessorCompatibility( state: XYState, datasourceLayers: Record ) { + const dataLayers = getDataLayers(state.layers); const errors = []; - const hasDateHistogramSet = state.layers.some( + const hasDateHistogramSet = dataLayers.some( checkScaleOperation('interval', 'date', datasourceLayers) ); - const hasNumberHistogram = state.layers.some( + const hasNumberHistogram = dataLayers.some( checkScaleOperation('interval', 'number', datasourceLayers) ); - const hasOrdinalAxis = state.layers.some( + const hasOrdinalAxis = dataLayers.some( checkScaleOperation('ordinal', undefined, datasourceLayers) ); if (state.layers.length > 1 && hasDateHistogramSet && hasNumberHistogram) { @@ -109,7 +115,7 @@ export function checkScaleOperation( dataType: 'date' | 'number' | 'string' | undefined, datasourceLayers: Record ) { - return (layer: XYLayerConfig) => { + return (layer: XYDataLayerConfig) => { const datasourceAPI = datasourceLayers[layer.layerId]; if (!layer.xAccessor) { return false; @@ -121,11 +127,20 @@ export function checkScaleOperation( }; } -export const isDataLayer = (layer: Pick) => - layer.layerType === layerTypes.DATA; +export const isDataLayer = (layer: Pick): layer is XYDataLayerConfig => + layer.layerType === layerTypes.DATA || !layer.layerType; -export const isReferenceLayer = (layer: Pick) => - layer?.layerType === layerTypes.REFERENCELINE; +export const getDataLayers = (layers: XYLayerConfig[]) => + (layers || []).filter((layer): layer is XYDataLayerConfig => isDataLayer(layer)); + +export const getFirstDataLayer = (layers: XYLayerConfig[]) => + (layers || []).find((layer): layer is XYDataLayerConfig => isDataLayer(layer)); + +export const isReferenceLayer = (layer: XYLayerConfig): layer is XYReferenceLineLayerConfig => + layer.layerType === layerTypes.REFERENCELINE; + +export const getReferenceLayers = (layers: XYLayerConfig[]) => + (layers || []).filter((layer): layer is XYReferenceLineLayerConfig => isReferenceLayer(layer)); export function getVisualizationType(state: State): VisualizationType | 'mixed' { if (!state.layers.length) { @@ -133,8 +148,9 @@ export function getVisualizationType(state: State): VisualizationType | 'mixed' visualizationTypes.find((t) => t.id === state.preferredSeriesType) ?? visualizationTypes[0] ); } - const visualizationType = visualizationTypes.find((t) => t.id === state.layers[0].seriesType); - const seriesTypes = uniq(state.layers.map((l) => l.seriesType)); + const dataLayers = getDataLayers(state?.layers); + const visualizationType = visualizationTypes.find((t) => t.id === dataLayers?.[0].seriesType); + const seriesTypes = uniq(dataLayers.map((l) => l.seriesType)); return visualizationType && seriesTypes.length === 1 ? visualizationType : 'mixed'; } @@ -241,8 +257,8 @@ export function getLayersByType(state: State, byType?: string) { export function validateLayersForDimension( dimension: string, - layers: XYLayerConfig[], - missingCriteria: (layer: XYLayerConfig) => boolean + layers: XYDataLayerConfig[], + missingCriteria: (layer: XYDataLayerConfig) => boolean ): | { valid: true } | { diff --git a/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx b/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx index 81037418a814..eb9de9a2993b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx @@ -10,7 +10,7 @@ import React from 'react'; import moment from 'moment'; import { Endzones } from '../../../../../src/plugins/charts/public'; import type { LensMultiTable } from '../../common'; -import type { LayerArgs } from '../../common/expressions'; +import type { DataLayerArgs } from '../../common/expressions'; import { search } from '../../../../../src/plugins/data/public'; export interface XDomain { @@ -19,7 +19,7 @@ export interface XDomain { minInterval?: number; } -export const getAppliedTimeRange = (layers: LayerArgs[], data: LensMultiTable) => { +export const getAppliedTimeRange = (layers: DataLayerArgs[], data: LensMultiTable) => { return Object.entries(data.tables) .map(([tableId, table]) => { const layer = layers.find((l) => l.layerId === tableId); @@ -37,7 +37,7 @@ export const getAppliedTimeRange = (layers: LayerArgs[], data: LensMultiTable) = }; export const getXDomain = ( - layers: LayerArgs[], + layers: DataLayerArgs[], data: LensMultiTable, minInterval: number | undefined, isTimeViz: boolean, diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx index 20d2bd31c7c6..3766e1f022c8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx @@ -18,6 +18,7 @@ import { EuiFieldNumber, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { isEqual } from 'lodash'; import { XYLayerConfig, AxesSettingsConfig, AxisExtentConfig } from '../../../common/expressions'; import { ToolbarPopover, @@ -241,7 +242,7 @@ export const AxisSettingsPopover: React.FunctionComponent { const index = state.layers.findIndex((l) => l.layerId === layerId); const layer = state.layers[index]; - const disabled = Boolean(layer.splitAccessor); const overwriteColor = getSeriesColor(layer, accessor); const currentColor = useMemo(() => { @@ -87,6 +86,7 @@ export const ColorPicker = ({ const [color, setColor] = useState(currentColor); + const disabled = Boolean(isDataLayer(layer) && layer.splitAccessor); const handleColor: EuiColorPickerProps['onChange'] = (text, output) => { setColor(text); if (output.isValid || text === '') { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx index 94df921ba1e5..1684d822b557 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx @@ -20,6 +20,7 @@ import { VisualOptionsPopover } from './visual_options_popover'; import { getScaleType } from '../to_expression'; import { TooltipWrapper } from '../../shared_components'; import { getDefaultVisualValuesForLayer } from '../../shared_components/datasource_default_values'; +import { getDataLayers } from '../visualization_helpers'; type UnwrapArray = T extends Array ? P : T; type AxesSettingsConfigKeys = keyof AxesSettingsConfig; @@ -103,7 +104,7 @@ function hasPercentageAxis(axisGroups: GroupsConfiguration, groupId: string, sta axisGroups .find((group) => group.groupId === groupId) ?.series.some(({ layer: layerId }) => - state?.layers.find( + getDataLayers(state?.layers).find( (layer) => layer.layerId === layerId && layer.seriesType.includes('percentage') ) ) @@ -115,8 +116,9 @@ export const XyToolbar = memo(function XyToolbar( ) { const { state, setState, frame, useLegacyTimeAxis } = props; + const dataLayers = getDataLayers(state?.layers); const shouldRotate = state?.layers.length ? isHorizontalChart(state.layers) : false; - const axisGroups = getAxesConfiguration(state?.layers, shouldRotate, frame.activeData); + const axisGroups = getAxesConfiguration(dataLayers, shouldRotate, frame.activeData); const dataBounds = getDataBounds(frame.activeData, axisGroups); const tickLabelsVisibilitySettings = { @@ -196,7 +198,7 @@ export const XyToolbar = memo(function XyToolbar( }); }; - const nonOrdinalXAxis = state?.layers.every( + const nonOrdinalXAxis = dataLayers.every( (layer) => !layer.xAccessor || getScaleType( @@ -206,7 +208,7 @@ export const XyToolbar = memo(function XyToolbar( ); // only allow changing endzone visibility if it could show up theoretically (if it's a time viz) - const onChangeEndzoneVisiblity = state?.layers.every( + const onChangeEndzoneVisiblity = dataLayers.every( (layer) => layer.xAccessor && getScaleType( @@ -232,7 +234,7 @@ export const XyToolbar = memo(function XyToolbar( axisGroups .find((group) => group.groupId === 'left') ?.series?.some((series) => { - const seriesType = state.layers.find((l) => l.layerId === series.layer)?.seriesType; + const seriesType = dataLayers.find((l) => l.layerId === series.layer)?.seriesType; return seriesType?.includes('bar') || seriesType?.includes('area'); }) ); @@ -249,7 +251,7 @@ export const XyToolbar = memo(function XyToolbar( axisGroups .find((group) => group.groupId === 'right') ?.series?.some((series) => { - const seriesType = state.layers.find((l) => l.layerId === series.layer)?.seriesType; + const seriesType = dataLayers.find((l) => l.layerId === series.layer)?.seriesType; return seriesType?.includes('bar') || seriesType?.includes('area'); }) ); @@ -263,12 +265,12 @@ export const XyToolbar = memo(function XyToolbar( [setState, state] ); - const filteredBarLayers = state?.layers.filter((layer) => layer.seriesType.includes('bar')); + const filteredBarLayers = dataLayers.filter((layer) => layer.seriesType.includes('bar')); const chartHasMoreThanOneBarSeries = filteredBarLayers.length > 1 || filteredBarLayers.some((layer) => layer.accessors.length > 1 || layer.splitAccessor); - const isTimeHistogramModeEnabled = state?.layers.some( + const isTimeHistogramModeEnabled = dataLayers.some( ({ xAccessor, layerId, seriesType, splitAccessor }) => { if (!xAccessor) { return false; @@ -392,6 +394,16 @@ export const XyToolbar = memo(function XyToolbar( valuesInLegend: !state.valuesInLegend, }); }} + legendSize={state.legend.legendSize} + onLegendSizeChange={(legendSize) => { + setState({ + ...state, + legend: { + ...state.legend, + legendSize, + }, + }); + }} /> diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx index d2f54af8cf21..465a627fa33b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiIcon, EuiPopover, EuiSelectable, EuiText, EuiPopoverTitle } from '@elastic/eui'; import type { VisualizationLayerWidgetProps, VisualizationType } from '../../types'; import { State, visualizationTypes } from '../types'; -import { SeriesType } from '../../../common/expressions'; +import { SeriesType, XYDataLayerConfig } from '../../../common/expressions'; import { isHorizontalChart, isHorizontalSeries } from '../state_helpers'; import { trackUiEvent } from '../../lens_ui_telemetry'; import { StaticHeader } from '../../shared_components'; @@ -45,7 +45,7 @@ function DataLayerHeader(props: VisualizationLayerWidgetProps) { const [isPopoverOpen, setPopoverIsOpen] = useState(false); const { state, layerId } = props; const index = state.layers.findIndex((l) => l.layerId === layerId); - const layer = state.layers[index]; + const layer = state.layers[index] as XYDataLayerConfig; const currentVisType = visualizationTypes.find(({ id }) => id === layer.seriesType)!; const horizontalOnly = isHorizontalChart(state.layers); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx index 8ea6f9ace632..0436a93be94e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx @@ -15,6 +15,7 @@ import { XYState } from '../../types'; import { hasHistogramSeries } from '../../state_helpers'; import { ValidLayer } from '../../../../common/expressions'; import type { FramePublicAPI } from '../../../types'; +import { getDataLayers } from '../../visualization_helpers'; function getValueLabelDisableReason({ isAreaPercentage, @@ -49,24 +50,25 @@ export const VisualOptionsPopover: React.FC = ({ setState, datasourceLayers, }) => { - const isAreaPercentage = state?.layers.some( + const dataLayers = getDataLayers(state.layers); + const isAreaPercentage = dataLayers.some( ({ seriesType }) => seriesType === 'area_percentage_stacked' ); - const hasNonBarSeries = state?.layers.some(({ seriesType }) => + const hasNonBarSeries = dataLayers.some(({ seriesType }) => ['area_stacked', 'area', 'line'].includes(seriesType) ); - const hasBarNotStacked = state?.layers.some(({ seriesType }) => + const hasBarNotStacked = dataLayers.some(({ seriesType }) => ['bar', 'bar_horizontal'].includes(seriesType) ); - const hasAreaSeries = state?.layers.some(({ seriesType }) => + const hasAreaSeries = dataLayers.some(({ seriesType }) => ['area_stacked', 'area', 'area_percentage_stacked'].includes(seriesType) ); const isHistogramSeries = Boolean( - hasHistogramSeries(state?.layers as ValidLayer[], datasourceLayers) + hasHistogramSeries(dataLayers as ValidLayer[], datasourceLayers) ); const isValueLabelsEnabled = !hasNonBarSeries && hasBarNotStacked && !isHistogramSeries; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx index fe8a71ad9f32..4e2be2f0e474 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx @@ -16,6 +16,7 @@ import { ToolbarPopover, ValueLabelsSettings } from '../../../shared_components' import { MissingValuesOptions } from './missing_values_option'; import { FillOpacityOption } from './fill_opacity_option'; import { layerTypes } from '../../../../common'; +import { XYDataLayerConfig } from '../../../../common/expressions'; describe('Visual options popover', () => { let frame: FramePublicAPI; @@ -52,7 +53,7 @@ describe('Visual options popover', () => { setState={jest.fn()} state={{ ...state, - layers: [{ ...state.layers[0], seriesType: 'bar_stacked' }], + layers: [{ ...state.layers[0], seriesType: 'bar_stacked' } as XYDataLayerConfig], }} /> ); @@ -68,7 +69,9 @@ describe('Visual options popover', () => { setState={jest.fn()} state={{ ...state, - layers: [{ ...state.layers[0], seriesType: 'area_percentage_stacked' }], + layers: [ + { ...state.layers[0], seriesType: 'area_percentage_stacked' } as XYDataLayerConfig, + ], }} /> ); @@ -85,7 +88,9 @@ describe('Visual options popover', () => { setState={jest.fn()} state={{ ...state, - layers: [{ ...state.layers[0], seriesType: 'area_percentage_stacked' }], + layers: [ + { ...state.layers[0], seriesType: 'area_percentage_stacked' } as XYDataLayerConfig, + ], }} /> ); @@ -101,7 +106,9 @@ describe('Visual options popover', () => { setState={jest.fn()} state={{ ...state, - layers: [{ ...state.layers[0], seriesType: 'area_percentage_stacked' }], + layers: [ + { ...state.layers[0], seriesType: 'area_percentage_stacked' } as XYDataLayerConfig, + ], }} /> ); @@ -138,7 +145,7 @@ describe('Visual options popover', () => { setState={jest.fn()} state={{ ...state, - layers: [{ ...state.layers[0], seriesType: 'bar_horizontal' }], + layers: [{ ...state.layers[0], seriesType: 'bar_horizontal' } as XYDataLayerConfig], fittingFunction: 'Carry', }} /> @@ -155,7 +162,7 @@ describe('Visual options popover', () => { setState={jest.fn()} state={{ ...state, - layers: [{ ...state.layers[0], seriesType: 'bar_horizontal' }], + layers: [{ ...state.layers[0], seriesType: 'bar_horizontal' } as XYDataLayerConfig], fittingFunction: 'Carry', }} /> @@ -172,7 +179,7 @@ describe('Visual options popover', () => { setState={jest.fn()} state={{ ...state, - layers: [{ ...state.layers[0], seriesType: 'line' }], + layers: [{ ...state.layers[0], seriesType: 'line' } as XYDataLayerConfig], fittingFunction: 'Carry', }} /> @@ -190,7 +197,7 @@ describe('Visual options popover', () => { setState={jest.fn()} state={{ ...state, - layers: [{ ...state.layers[0], seriesType: 'bar_horizontal' }], + layers: [{ ...state.layers[0], seriesType: 'bar_horizontal' } as XYDataLayerConfig], fittingFunction: 'Carry', }} /> @@ -207,7 +214,7 @@ describe('Visual options popover', () => { setState={jest.fn()} state={{ ...state, - layers: [{ ...state.layers[0], seriesType: 'area' }], + layers: [{ ...state.layers[0], seriesType: 'area' } as XYDataLayerConfig], fittingFunction: 'Carry', }} /> @@ -230,7 +237,7 @@ describe('Visual options popover', () => { state={{ ...state, layers: [ - { ...state.layers[0], seriesType: 'bar' }, + { ...state.layers[0], seriesType: 'bar' } as XYDataLayerConfig, { seriesType: 'bar', layerType: layerTypes.DATA, diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx index 5dea4481a60e..1e80c6e843ba 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx @@ -18,6 +18,7 @@ import { createMockFramePublicAPI, createMockDatasource } from '../../mocks'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; import { EuiColorPicker } from '@elastic/eui'; import { layerTypes } from '../../../common'; +import { XYDataLayerConfig } from '../../../common/expressions'; describe('XY Config panels', () => { let frame: FramePublicAPI; @@ -205,7 +206,10 @@ describe('XY Config panels', () => { setState={jest.fn()} accessor="bar" groupId="left" - state={{ ...state, layers: [{ ...state.layers[0], seriesType: 'bar_horizontal' }] }} + state={{ + ...state, + layers: [{ ...state.layers[0], seriesType: 'bar_horizontal' } as XYDataLayerConfig], + }} formatFactory={jest.fn()} paletteService={chartPluginMock.createPaletteRegistry()} panelRef={React.createRef()} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts index 89276d00b5ed..679f3537fdd6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts @@ -15,6 +15,7 @@ import { PaletteOutput } from 'src/plugins/charts/public'; import { layerTypes } from '../../common'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; import { themeServiceMock } from '../../../../../src/core/public/mocks'; +import { XYDataLayerConfig } from '../../common/expressions'; jest.mock('../id_generator'); @@ -89,12 +90,14 @@ describe('xy_suggestions', () => { // Helper that plucks out the important part of a suggestion for // most test assertions function suggestionSubset(suggestion: VisualizationSuggestion) { - return suggestion.state.layers.map(({ seriesType, splitAccessor, xAccessor, accessors }) => ({ - seriesType, - splitAccessor, - x: xAccessor, - y: accessors, - })); + return (suggestion.state.layers as XYDataLayerConfig[]).map( + ({ seriesType, splitAccessor, xAccessor, accessors }) => ({ + seriesType, + splitAccessor, + x: xAccessor, + y: accessors, + }) + ); } beforeEach(() => { @@ -543,7 +546,7 @@ describe('xy_suggestions', () => { mainPalette, }); - expect(suggestion.state.layers[0].palette).toEqual(mainPalette); + expect((suggestion.state.layers as XYDataLayerConfig[])[0].palette).toEqual(mainPalette); }); test('ignores passed in palette for non splitted charts', () => { @@ -559,7 +562,7 @@ describe('xy_suggestions', () => { mainPalette, }); - expect(suggestion.state.layers[0].palette).toEqual(undefined); + expect((suggestion.state.layers as XYDataLayerConfig[])[0].palette).toEqual(undefined); }); test('hides reduced suggestions if there is a current state', () => { @@ -655,7 +658,7 @@ describe('xy_suggestions', () => { expect(suggestions[0].hide).toEqual(false); expect(suggestions[0].state.preferredSeriesType).toEqual('line'); - expect(suggestions[0].state.layers[0].seriesType).toEqual('line'); + expect((suggestions[0].state.layers[0] as XYDataLayerConfig).seriesType).toEqual('line'); }); test('makes a visible seriesType suggestion for unchanged table without split', () => { @@ -779,7 +782,11 @@ describe('xy_suggestions', () => { expect(rest).toHaveLength(visualizationTypes.length - 1); expect(suggestion.state.preferredSeriesType).toEqual('bar_horizontal'); - expect(suggestion.state.layers.every((l) => l.seriesType === 'bar_horizontal')).toBeTruthy(); + expect( + (suggestion.state.layers as XYDataLayerConfig[]).every( + (l) => l.seriesType === 'bar_horizontal' + ) + ).toBeTruthy(); expect(suggestion.title).toEqual('Flip'); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 090c8ccba798..1578442b5281 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -17,9 +17,10 @@ import { TableChangeType, } from '../types'; import { State, XYState, visualizationTypes } from './types'; -import type { SeriesType, XYLayerConfig } from '../../common/expressions'; +import type { SeriesType, XYDataLayerConfig } from '../../common/expressions'; import { layerTypes } from '../../common'; import { getIconForSeries } from './state_helpers'; +import { getDataLayers, isDataLayer } from './visualization_helpers'; const columnSortOrder = { document: 0, @@ -44,6 +45,7 @@ export function getSuggestions({ keptLayerIds, subVisualizationId, mainPalette, + isFromContext, }: SuggestionRequest): Array> { const incompleteTable = !table.isMultiRow || @@ -71,7 +73,7 @@ export function getSuggestions({ if ( (incompleteTable && state && !subVisualizationId) || - table.columns.some((col) => col.operation.isStaticValue) || + table.columns.some((col) => col.operation.isStaticValue && !isFromContext) || // do not use suggestions with non-numeric metrics table.columns.some((col) => !col.operation.isBucketed && col.operation.dataType !== 'number') ) { @@ -158,7 +160,8 @@ function flipSeriesType(seriesType: SeriesType) { function getBucketMappings(table: TableSuggestion, currentState?: State) { const currentLayer = - currentState && currentState.layers.find(({ layerId }) => layerId === table.layerId); + currentState && + getDataLayers(currentState.layers).find(({ layerId }) => layerId === table.layerId); const buckets = table.columns.filter((col) => col.operation.isBucketed); // reverse the buckets before prioritization to always use the most inner @@ -416,7 +419,7 @@ function getSeriesType( const defaultType = 'bar_stacked'; const oldLayer = getExistingLayer(currentState, layerId); - const oldLayerSeriesType = oldLayer ? oldLayer.seriesType : false; + const oldLayerSeriesType = oldLayer && isDataLayer(oldLayer) ? oldLayer.seriesType : false; const closestSeriesType = oldLayerSeriesType || (currentState && currentState.preferredSeriesType) || defaultType; @@ -496,7 +499,8 @@ function buildSuggestion({ splitBy = xValue; xValue = undefined; } - const existingLayer: XYLayerConfig | {} = getExistingLayer(currentState, layerId) || {}; + const existingLayer: XYDataLayerConfig | {} = + getExistingLayer(currentState, layerId) || ({} as XYDataLayerConfig); const accessors = yValues.map((col) => col.columnId); const newLayer = { ...existingLayer, diff --git a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts index 39d36a9f306a..506a5e7688f9 100644 --- a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts @@ -13,6 +13,7 @@ import { } from '../../../../../src/plugins/kibana_utils/common'; import { DOC_TYPE } from '../../common'; import { + commonEnhanceTableRowHeight, commonMakeReversePaletteAsCustom, commonRemoveTimezoneDateHistogramParam, commonRenameFilterReferences, @@ -26,8 +27,10 @@ import { CustomVisualizationMigrations, LensDocShape713, LensDocShape715, + LensDocShape810, LensDocShapePre712, VisState716, + VisState810, VisStatePre715, } from '../migrations/types'; import { extract, inject } from '../../common/embeddable_factory'; @@ -88,6 +91,14 @@ export const makeLensEmbeddableFactory = attributes: migratedLensState, } as unknown as SerializableRecord; }, + '8.2.0': (state) => { + const lensState = state as unknown as { attributes: LensDocShape810 }; + const migratedLensState = commonEnhanceTableRowHeight(lensState.attributes); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, }), getLensCustomVisualizationMigrations(customVisualizationMigrations) ), diff --git a/x-pack/plugins/lens/server/expressions/expressions.ts b/x-pack/plugins/lens/server/expressions/expressions.ts index f258db7f9aed..84e238b3eb15 100644 --- a/x-pack/plugins/lens/server/expressions/expressions.ts +++ b/x-pack/plugins/lens/server/expressions/expressions.ts @@ -9,9 +9,9 @@ import type { CoreSetup } from 'kibana/server'; import { xyChart, counterRate, - metricChart, yAxisConfig, - layerConfig, + dataLayerConfig, + referenceLineLayerConfig, formatColumn, legendConfig, renameColumns, @@ -37,9 +37,9 @@ export const setupExpressions = ( [ xyChart, counterRate, - metricChart, yAxisConfig, - layerConfig, + dataLayerConfig, + referenceLineLayerConfig, formatColumn, legendConfig, renameColumns, diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.ts b/x-pack/plugins/lens/server/migrations/common_migrations.ts index 39eed3cbc2a3..12ceff5b1e84 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -23,6 +23,8 @@ import { VisStatePost715, VisStatePre715, VisState716, + VisState810, + VisState820, CustomVisualizationMigrations, LensDocShape810, } from './types'; @@ -192,6 +194,20 @@ export const commonRenameFilterReferences = (attributes: LensDocShape715): LensD return newAttributes as LensDocShape810; }; +export const commonEnhanceTableRowHeight = ( + attributes: LensDocShape810 +): LensDocShape810 => { + if (attributes.visualizationType !== 'lnsDatatable') { + return attributes; + } + const visState810 = attributes.state.visualization as VisState810; + const newAttributes = cloneDeep(attributes); + const vizState = newAttributes.state.visualization as VisState820; + vizState.rowHeight = visState810.fitRowToContent ? 'auto' : 'single'; + vizState.rowHeightLines = visState810.fitRowToContent ? 2 : 1; + return newAttributes; +}; + const getApplyCustomVisualizationMigrationToLens = (id: string, migration: MigrateFunction) => { return (savedObject: { attributes: LensDocShape }) => { if (savedObject.attributes.visualizationType !== id) return savedObject; diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index 5cd63b2786fe..a051c2474269 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -18,6 +18,8 @@ import { VisState716, VisStatePost715, VisStatePre715, + VisState810, + VisState820, } from './types'; import { CustomPaletteParams, layerTypes } from '../../common'; import { PaletteOutput } from 'src/plugins/charts/common'; @@ -1779,4 +1781,122 @@ describe('Lens migrations', () => { ); }); }); + + describe('8.2.0 rename fitRowToContent to new detailed rowHeight and rowHeightLines', () => { + const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + function getExample(fitToContent: boolean) { + return { + type: 'lens', + id: 'mocked-saved-object-id', + attributes: { + visualizationType: 'lnsDatatable', + title: 'Lens visualization', + references: [ + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: 'indexpattern-datasource-layer-cddd8f79-fb20-4191-a3e7-92484780cc62', + type: 'index-pattern', + }, + ], + state: { + datasourceStates: { + indexpattern: { + layers: { + 'cddd8f79-fb20-4191-a3e7-92484780cc62': { + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + columns: { + '221f0abf-6e54-4c61-9316-4107ad6fa500': { + label: 'Top values of category.keyword', + dataType: 'string', + operationType: 'terms', + scale: 'ordinal', + sourceField: 'category.keyword', + isBucketed: true, + params: { + size: 5, + orderBy: { + type: 'column', + columnId: 'c6f07a26-64eb-4871-ad62-c7d937230e33', + }, + orderDirection: 'desc', + otherBucket: true, + missingBucket: false, + parentFormat: { + id: 'terms', + }, + }, + }, + 'c6f07a26-64eb-4871-ad62-c7d937230e33': { + label: 'Count of records', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + }, + }, + columnOrder: [ + '221f0abf-6e54-4c61-9316-4107ad6fa500', + 'c6f07a26-64eb-4871-ad62-c7d937230e33', + ], + incompleteColumns: {}, + }, + }, + }, + }, + visualization: { + columns: [ + { + isTransposed: false, + columnId: '221f0abf-6e54-4c61-9316-4107ad6fa500', + }, + { + isTransposed: false, + columnId: 'c6f07a26-64eb-4871-ad62-c7d937230e33', + }, + ], + layerId: 'cddd8f79-fb20-4191-a3e7-92484780cc62', + layerType: 'data', + fitRowToContent: fitToContent, + }, + filters: [], + query: { + query: '', + language: 'kuery', + }, + }, + }, + } as unknown as SavedObjectUnsanitizedDoc; + } + + it('should migrate enabled fitRowToContent to new rowHeight: "auto"', () => { + const result = migrations['8.2.0'](getExample(true), context) as ReturnType< + SavedObjectMigrationFn, LensDocShape810> + >; + + expect(result.attributes.state.visualization as VisState820).toEqual( + expect.objectContaining({ + rowHeight: 'auto', + }) + ); + }); + + it('should migrate disabled fitRowToContent to new rowHeight: "single"', () => { + const result = migrations['8.2.0'](getExample(false), context) as ReturnType< + SavedObjectMigrationFn, LensDocShape810> + >; + + expect(result.attributes.state.visualization as VisState820).toEqual( + expect.objectContaining({ + rowHeight: 'single', + rowHeightLines: 1, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index 2617fb42bce0..00c490b4509f 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -40,6 +40,7 @@ import { getLensCustomVisualizationMigrations, commonRenameRecordsField, fixLensTopValuesCustomFormatting, + commonEnhanceTableRowHeight, } from './common_migrations'; interface LensDocShapePre710 { @@ -464,6 +465,11 @@ const addParentFormatter: SavedObjectMigrationFn = (doc) => { + const newDoc = cloneDeep(doc); + return { ...newDoc, attributes: commonEnhanceTableRowHeight(newDoc.attributes) }; +}; + const lensMigrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -478,6 +484,7 @@ const lensMigrations: SavedObjectMigrationMap = { '7.15.0': addLayerTypeToVisualization, '7.16.0': moveDefaultReversedPaletteToCustom, '8.1.0': flow(renameFilterReferences, renameRecordsField, addParentFormatter), + '8.2.0': enhanceTableRowHeight, }; export const getAllMigrations = ( diff --git a/x-pack/plugins/lens/server/migrations/types.ts b/x-pack/plugins/lens/server/migrations/types.ts index 7cbb2052dbff..b3f76d05acfd 100644 --- a/x-pack/plugins/lens/server/migrations/types.ts +++ b/x-pack/plugins/lens/server/migrations/types.ts @@ -246,3 +246,14 @@ export type VisState716 = | { palette?: PaletteOutput; }; + +// Datatable only +export interface VisState810 { + fitRowToContent?: boolean; +} + +// Datatable only +export interface VisState820 { + rowHeight: 'auto' | 'single' | 'custom'; + rowHeightLines: number; +} diff --git a/x-pack/plugins/lens/server/usage/schema.ts b/x-pack/plugins/lens/server/usage/schema.ts index 3c391e3ac895..6d580ba8f6c2 100644 --- a/x-pack/plugins/lens/server/usage/schema.ts +++ b/x-pack/plugins/lens/server/usage/schema.ts @@ -25,6 +25,12 @@ const eventsSchema: MakeSchemaFrom = { type: 'long', _meta: { description: 'Number of times the user opened the in-product formula help popover.' }, }, + toggle_autoapply: { + type: 'long', + _meta: { + description: 'Number of times the user toggled auto-apply.', + }, + }, toggle_fullscreen_formula: { type: 'long', _meta: { diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index db9647d03f8e..18bc323b7685 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -799,11 +799,13 @@ exports[`UploadLicense should display a modal when license requires acknowledgem className="euiFlexItem euiFlexItem--flexGrowZero" > - + { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={true} /> ); @@ -104,6 +108,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -138,6 +143,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -174,6 +180,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -210,6 +217,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -247,6 +255,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={true} /> ); @@ -284,6 +293,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={true} /> ); @@ -320,6 +330,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -357,6 +368,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -414,6 +426,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -456,6 +469,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={mockOnChange} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -496,6 +510,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={mockOnChange} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -536,6 +551,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={mockOnChange} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -576,6 +592,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={mockOnChange} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -616,6 +633,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={mockOnChange} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -662,6 +680,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={mockSetErrorExists} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -701,6 +720,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={mockSetErrorExists} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -723,6 +743,104 @@ describe('BuilderEntryItem', () => { expect(mockSetErrorExists).toHaveBeenCalledWith(true); }); + test('it invokes "setWarningsExist" when invalid value in field value input', async () => { + const mockSetWarningsExists = jest.fn(); + + (validateFilePathInput as jest.Mock).mockReturnValue('some warning message'); + wrapper = mount( + + ); + + await waitFor(() => { + ( + wrapper.find(EuiComboBox).at(2).props() as unknown as { + onBlur: () => void; + } + ).onBlur(); + + // Invalid input because field is just a string and not a path + ( + wrapper.find(EuiComboBox).at(2).props() as unknown as { + onSearchChange: (arg: string) => void; + } + ).onSearchChange('i243kjhfew'); + }); + + expect(mockSetWarningsExists).toHaveBeenCalledWith(true); + }); + + test('it does not invoke "setWarningsExist" when valid value in field value input', async () => { + const mockSetWarningsExists = jest.fn(); + + (validateFilePathInput as jest.Mock).mockReturnValue(undefined); + wrapper = mount( + + ); + + await waitFor(() => { + ( + wrapper.find(EuiComboBox).at(2).props() as unknown as { + onBlur: () => void; + } + ).onBlur(); + + // valid input as it is a path + ( + wrapper.find(EuiComboBox).at(2).props() as unknown as { + onSearchChange: (arg: string) => void; + } + ).onSearchChange('c:\\path.exe'); + }); + + expect(mockSetWarningsExists).toHaveBeenCalledWith(false); + }); + test('it disabled field inputs correctly when passed "isDisabled=true"', () => { wrapper = mount( { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} osTypes={['windows']} showLabel={false} isDisabled={true} diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx index 206b1a5dd6f8..aa24ec6611b9 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx @@ -24,6 +24,7 @@ import { getEntryOnMatchAnyChange, getEntryOnMatchChange, getEntryOnOperatorChange, + getEntryOnWildcardChange, getFilteredIndexPatterns, getOperatorOptions, } from '@kbn/securitysolution-list-utils'; @@ -32,9 +33,11 @@ import { AutocompleteFieldListsComponent, AutocompleteFieldMatchAnyComponent, AutocompleteFieldMatchComponent, + AutocompleteFieldWildcardComponent, FieldComponent, OperatorComponent, } from '@kbn/securitysolution-autocomplete'; +import { OperatingSystem, validateFilePathInput } from '@kbn/securitysolution-utils'; import { DataViewBase, DataViewFieldBase } from '@kbn/es-query'; import type { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; @@ -64,6 +67,7 @@ export interface EntryItemProps { onChange: (arg: BuilderEntry, i: number) => void; onlyShowListOperators?: boolean; setErrorsExist: (arg: boolean) => void; + setWarningsExist: (arg: boolean) => void; isDisabled?: boolean; operatorsList?: OperatorOption[]; } @@ -80,6 +84,7 @@ export const BuilderEntryItem: React.FC = ({ onChange, onlyShowListOperators = false, setErrorsExist, + setWarningsExist, showLabel, isDisabled = false, operatorsList, @@ -90,6 +95,12 @@ export const BuilderEntryItem: React.FC = ({ }, [setErrorsExist] ); + const handleWarning = useCallback( + (warn: boolean): void => { + setWarningsExist(warn); + }, + [setWarningsExist] + ); const handleFieldChange = useCallback( ([newField]: DataViewFieldBase[]): void => { @@ -126,6 +137,15 @@ export const BuilderEntryItem: React.FC = ({ [onChange, entry] ); + const handleFieldWildcardValueChange = useCallback( + (newField: string): void => { + const { updatedEntry, index } = getEntryOnWildcardChange(entry, newField); + + onChange(updatedEntry, index); + }, + [onChange, entry] + ); + const handleFieldListValueChange = useCallback( (newField: ListSchema): void => { const { updatedEntry, index } = getEntryOnListChange(entry, newField); @@ -199,8 +219,17 @@ export const BuilderEntryItem: React.FC = ({ ); const renderOperatorInput = (isFirst: boolean): JSX.Element => { - const operatorOptions = operatorsList - ? operatorsList + // for event filters forms + // show extra operators for wildcards when field is `file.path.text` + const isFilePathTextField = entry.field !== undefined && entry.field.name === 'file.path.text'; + const isEventFilterList = listType === 'endpoint_events'; + const augmentedOperatorsList = + operatorsList && isFilePathTextField && isEventFilterList + ? operatorsList + : operatorsList?.filter((operator) => operator.type !== OperatorTypeEnum.WILDCARD); + + const operatorOptions = augmentedOperatorsList + ? augmentedOperatorsList : onlyShowListOperators ? EXCEPTION_OPERATORS_ONLY_LISTS : getOperatorOptions( @@ -209,6 +238,7 @@ export const BuilderEntryItem: React.FC = ({ entry.field != null && entry.field.type === 'boolean', isFirst && allowLargeValueLists ); + const comboBox = ( = ({ data-test-subj="exceptionBuilderEntryFieldMatchAny" /> ); + case OperatorTypeEnum.WILDCARD: + const wildcardValue = typeof entry.value === 'string' ? entry.value : undefined; + let os: OperatingSystem = OperatingSystem.WINDOWS; + if (osTypes) { + [os] = osTypes as OperatingSystem[]; + } + const warning = validateFilePathInput({ os, value: wildcardValue }); + return ( + + ); case OperatorTypeEnum.LIST: const id = typeof entry.value === 'string' ? entry.value : undefined; return ( diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx index ccda52e28058..fed24ba428e6 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx @@ -53,6 +53,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -84,6 +85,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -113,6 +115,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -144,6 +147,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -182,6 +186,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -212,6 +217,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -243,6 +249,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -272,6 +279,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -303,6 +311,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={mockOnDeleteExceptionItem} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx index 931a8356e93b..febfa54a482b 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx @@ -59,6 +59,7 @@ interface BuilderExceptionListItemProps { onDeleteExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; onChangeExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; setErrorsExist: (arg: boolean) => void; + setWarningsExist: (arg: boolean) => void; onlyShowListOperators?: boolean; isDisabled?: boolean; operatorsList?: OperatorOption[]; @@ -80,6 +81,7 @@ export const BuilderExceptionListItemComponent = React.memo - indexPattern != null && exceptionItem.entries.length > 0 - ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) - : [], - [exceptionItem.entries, indexPattern] - ); + const entries = useMemo((): FormattedBuilderEntry[] => { + const hasIndexPatternAndEntries = indexPattern != null && exceptionItem.entries.length > 0; + return hasIndexPatternAndEntries + ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) + : []; + }, [exceptionItem.entries, indexPattern]); return ( @@ -150,6 +150,7 @@ export const BuilderExceptionListItemComponent = React.memo; exceptionsToDelete: ExceptionListItemSchema[]; + warningExists: boolean; } export interface ExceptionBuilderProps { @@ -123,6 +125,7 @@ export const ExceptionBuilderComponent = ({ disableNested, disableOr, errorExists, + warningExists, exceptions, exceptionsToDelete, }, @@ -144,6 +147,16 @@ export const ExceptionBuilderComponent = ({ [dispatch] ); + const setWarningsExist = useCallback( + (hasWarnings: boolean): void => { + dispatch({ + type: 'setWarningsExist', + warningExists: hasWarnings, + }); + }, + [dispatch] + ); + const setUpdateExceptions = useCallback( (items: ExceptionsBuilderExceptionItem[]): void => { dispatch({ @@ -350,8 +363,9 @@ export const ExceptionBuilderComponent = ({ errorExists: errorExists > 0, exceptionItems: filterExceptionItems(exceptions), exceptionsToDelete, + warningExists: warningExists > 0, }); - }, [onChange, exceptionsToDelete, exceptions, errorExists]); + }, [onChange, exceptionsToDelete, exceptions, errorExists, warningExists]); useEffect(() => { setUpdateExceptions([]); @@ -416,6 +430,7 @@ export const ExceptionBuilderComponent = ({ onDeleteExceptionItem={handleDeleteExceptionItem} onlyShowListOperators={containsValueListEntry(exceptions)} setErrorsExist={setErrorsExist} + setWarningsExist={setWarningsExist} osTypes={osTypes} isDisabled={isDisabled} operatorsList={operatorsList} diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/reducer.ts b/x-pack/plugins/lists/public/exceptions/components/builder/reducer.ts index 4ace0c7d31ef..ba3b77fb24ed 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/reducer.ts +++ b/x-pack/plugins/lists/public/exceptions/components/builder/reducer.ts @@ -25,6 +25,7 @@ export interface State { exceptions: ExceptionsBuilderExceptionItem[]; exceptionsToDelete: ExceptionListItemSchema[]; errorExists: number; + warningExists: number; } export type Action = @@ -56,6 +57,10 @@ export type Action = | { type: 'setErrorsExist'; errorExists: boolean; + } + | { + type: 'setWarningsExist'; + warningExists: boolean; }; export const exceptionsBuilderReducer = @@ -128,6 +133,15 @@ export const exceptionsBuilderReducer = errorExists: errTotal < 0 ? 0 : errTotal, }; } + case 'setWarningsExist': { + const { warningExists } = state; + const warnTotal = action.warningExists ? warningExists + 1 : warningExists - 1; + + return { + ...state, + warningExists: warnTotal < 0 ? 0 : warnTotal, + }; + } default: return state; } diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 6b9f32b92b9c..435b4e55b4ce 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -30,6 +30,7 @@ export const CHECK_IS_DRAWING_INDEX = `/${GIS_API_PATH}/checkIsDrawingIndex`; export const MVT_GETTILE_API_PATH = 'mvt/getTile'; export const MVT_GETGRIDTILE_API_PATH = 'mvt/getGridTile'; +export const OPEN_LAYER_WIZARD = 'openLayerWizard'; // Identifies centroid feature. // Centroids are a single point for representing lines, multiLines, polygons, and multiPolygons @@ -144,6 +145,7 @@ export enum DRAW_SHAPE { LINE = 'LINE', SIMPLE_SELECT = 'SIMPLE_SELECT', DELETE = 'DELETE', + WAIT = 'WAIT', } export const AGG_DELIMITER = '_of_'; @@ -285,3 +287,23 @@ export const MAPS_NEW_VECTOR_LAYER_META_CREATED_BY = 'maps-new-vector-layer'; export const MAX_DRAWING_SIZE_BYTES = 10485760; // 10MB export const emsWorldLayerId = 'world_countries'; + +export enum WIZARD_ID { + CHOROPLETH = 'choropleth', + GEO_FILE = 'uploadGeoFile', + NEW_VECTOR = 'newVectorLayer', + OBSERVABILITY = 'observabilityLayer', + SECURITY = 'securityLayer', + EMS_BOUNDARIES = 'emsBoundaries', + EMS_BASEMAP = 'emsBaseMap', + CLUSTERS = 'clusters', + HEATMAP = 'heatmap', + GEO_LINE = 'geoLine', + POINT_2_POINT = 'point2Point', + ES_DOCUMENT = 'esDocument', + ES_TOP_HITS = 'esTopHits', + KIBANA_BASEMAP = 'kibanaBasemap', + MVT_VECTOR = 'mvtVector', + WMS_LAYER = 'wmsLayer', + TMS_LAYER = 'tmsLayer', +} diff --git a/x-pack/plugins/maps/common/elasticsearch_util/es_agg_utils.test.ts b/x-pack/plugins/maps/common/elasticsearch_util/es_agg_utils.test.ts index a56074ec7b58..106edb5d5605 100644 --- a/x-pack/plugins/maps/common/elasticsearch_util/es_agg_utils.test.ts +++ b/x-pack/plugins/maps/common/elasticsearch_util/es_agg_utils.test.ts @@ -35,7 +35,6 @@ describe('extractPropertiesFromBucket', () => { expect(properties).toEqual({ doc_count: 3, 'terms_of_machine.os.keyword': 'win xp', - // eslint-disable-next-line @typescript-eslint/naming-convention 'terms_of_machine.os.keyword__percentage': 33, }); }); diff --git a/x-pack/plugins/maps/common/execution_context.test.ts b/x-pack/plugins/maps/common/execution_context.test.ts new file mode 100644 index 000000000000..25e3813a7e8f --- /dev/null +++ b/x-pack/plugins/maps/common/execution_context.test.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 { makeExecutionContext } from './execution_context'; + +describe('makeExecutionContext', () => { + test('returns basic fields if nothing is provided', () => { + const context = makeExecutionContext({}); + expect(context).toStrictEqual({ + name: 'maps', + type: 'application', + }); + }); + + test('merges in context', () => { + const context = makeExecutionContext({ id: '123' }); + expect(context).toStrictEqual({ + name: 'maps', + type: 'application', + id: '123', + }); + }); + + test('omits undefined values', () => { + const context = makeExecutionContext({ id: '123', description: undefined }); + expect(context).toStrictEqual({ + name: 'maps', + type: 'application', + id: '123', + }); + }); +}); diff --git a/x-pack/plugins/maps/common/execution_context.ts b/x-pack/plugins/maps/common/execution_context.ts index 23de29cfa8cd..4a11eb5d8902 100644 --- a/x-pack/plugins/maps/common/execution_context.ts +++ b/x-pack/plugins/maps/common/execution_context.ts @@ -5,14 +5,16 @@ * 2.0. */ +import { isUndefined, omitBy } from 'lodash'; import { APP_ID } from './constants'; -export function makeExecutionContext(id: string, url: string, description?: string) { - return { - name: APP_ID, - type: 'application', - id, - description: description || '', - url, - }; +export function makeExecutionContext(context: { id?: string; url?: string; description?: string }) { + return omitBy( + { + name: APP_ID, + type: 'application', + ...context, + }, + isUndefined + ); } diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 2abbfc0d076a..cccb49f36062 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -14,6 +14,7 @@ import turfBooleanContains from '@turf/boolean-contains'; import { Filter } from '@kbn/es-query'; import { Query, TimeRange } from 'src/plugins/data/public'; import { Geometry, Position } from 'geojson'; +import { asyncForEach } from '@kbn/std'; import { DRAW_MODE, DRAW_SHAPE } from '../../common/constants'; import type { MapExtentState, MapViewContext } from '../reducers/map/types'; import { MapStoreState } from '../reducers/store'; @@ -63,9 +64,10 @@ import { DrawState, MapCenterAndZoom, MapExtent, Timeslice } from '../../common/ import { INITIAL_LOCATION } from '../../common/constants'; import { updateTooltipStateForLayer } from './tooltip_actions'; import { isVectorLayer, IVectorLayer } from '../classes/layers/vector_layer'; -import { SET_DRAW_MODE } from './ui_actions'; +import { SET_DRAW_MODE, pushDeletedFeatureId, clearDeletedFeatureIds } from './ui_actions'; import { expandToTileBoundaries } from '../classes/util/geo_tile_utils'; import { getToasts } from '../kibana_services'; +import { getDeletedFeatureIds } from '../selectors/ui_selectors'; export function setMapInitError(errorMessage: string) { return { @@ -321,6 +323,10 @@ export function updateEditShape(shapeToDraw: DRAW_SHAPE | null) { drawShape: shapeToDraw, }, }); + + if (shapeToDraw !== DRAW_SHAPE.DELETE) { + dispatch(clearDeletedFeatureIds()); + } }; } @@ -353,7 +359,7 @@ export function updateEditLayer(layerId: string | null) { }; } -export function addNewFeatureToIndex(geometry: Geometry | Position[]) { +export function addNewFeatureToIndex(geometries: Array) { return async ( dispatch: ThunkDispatch, getState: () => MapStoreState @@ -369,7 +375,10 @@ export function addNewFeatureToIndex(geometry: Geometry | Position[]) { } try { - await (layer as IVectorLayer).addFeature(geometry); + dispatch(updateEditShape(DRAW_SHAPE.WAIT)); + await asyncForEach(geometries, async (geometry) => { + await (layer as IVectorLayer).addFeature(geometry); + }); await dispatch(syncDataForLayerDueToDrawing(layer)); } catch (e) { getToasts().addError(e, { @@ -378,6 +387,7 @@ export function addNewFeatureToIndex(geometry: Geometry | Position[]) { }), }); } + dispatch(updateEditShape(DRAW_SHAPE.SIMPLE_SELECT)); }; } @@ -386,6 +396,12 @@ export function deleteFeatureFromIndex(featureId: string) { dispatch: ThunkDispatch, getState: () => MapStoreState ) => { + // There is a race condition where users can click on a previously deleted feature before layer has re-rendered after feature delete. + // Check ensures delete requests for previously deleted features are aborted. + if (getDeletedFeatureIds(getState()).includes(featureId)) { + return; + } + const editState = getEditState(getState()); const layerId = editState ? editState.layerId : undefined; if (!layerId) { @@ -395,8 +411,11 @@ export function deleteFeatureFromIndex(featureId: string) { if (!layer || !isVectorLayer(layer)) { return; } + try { + dispatch(updateEditShape(DRAW_SHAPE.WAIT)); await (layer as IVectorLayer).deleteFeature(featureId); + dispatch(pushDeletedFeatureId(featureId)); await dispatch(syncDataForLayerDueToDrawing(layer)); } catch (e) { getToasts().addError(e, { @@ -405,5 +424,6 @@ export function deleteFeatureFromIndex(featureId: string) { }), }); } + dispatch(updateEditShape(DRAW_SHAPE.DELETE)); }; } diff --git a/x-pack/plugins/maps/public/actions/ui_actions.ts b/x-pack/plugins/maps/public/actions/ui_actions.ts index 70e24283ef48..bdc0e9155671 100644 --- a/x-pack/plugins/maps/public/actions/ui_actions.ts +++ b/x-pack/plugins/maps/public/actions/ui_actions.ts @@ -24,6 +24,9 @@ export const SET_OPEN_TOC_DETAILS = 'SET_OPEN_TOC_DETAILS'; export const SHOW_TOC_DETAILS = 'SHOW_TOC_DETAILS'; export const HIDE_TOC_DETAILS = 'HIDE_TOC_DETAILS'; export const SET_DRAW_MODE = 'SET_DRAW_MODE'; +export const SET_AUTO_OPEN_WIZARD_ID = 'SET_AUTO_OPEN_WIZARD_ID'; +export const PUSH_DELETED_FEATURE_ID = 'PUSH_DELETED_FEATURE_ID'; +export const CLEAR_DELETED_FEATURE_IDS = 'CLEAR_DELETED_FEATURE_IDS'; export function exitFullScreen() { return { @@ -123,3 +126,28 @@ export function closeTimeslider() { dispatch(setQuery({ clearTimeslice: true })); }; } + +export function setAutoOpenLayerWizardId(autoOpenLayerWizardId: string) { + return (dispatch: ThunkDispatch) => { + dispatch(setSelectedLayer(null)); + dispatch(updateFlyout(FLYOUT_STATE.ADD_LAYER_WIZARD)); + dispatch(setDrawMode(DRAW_MODE.NONE)); + dispatch({ + type: SET_AUTO_OPEN_WIZARD_ID, + autoOpenLayerWizardId, + }); + }; +} + +export function pushDeletedFeatureId(featureId: string) { + return { + type: PUSH_DELETED_FEATURE_ID, + featureId, + }; +} + +export function clearDeletedFeatureIds() { + return { + type: CLEAR_DELETED_FEATURE_IDS, + }; +} diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/choropleth_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/choropleth_layer_wizard.tsx index 4334a3478543..9f2bbf168a08 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/choropleth_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/choropleth_layer_wizard.tsx @@ -7,12 +7,13 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { LAYER_WIZARD_CATEGORY } from '../../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../../common/constants'; import { LayerWizard, RenderWizardArguments } from '../layer_wizard_registry'; import { LayerTemplate } from './layer_template'; import { ChoroplethLayerIcon } from '../icons/cloropleth_layer_icon'; export const choroplethLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.CHOROPLETH, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.choropleth.desc', { diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/file_upload_wizard/config.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/file_upload_wizard/config.tsx index 1dc94c37437e..e0852b6f9300 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/file_upload_wizard/config.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/file_upload_wizard/config.tsx @@ -10,8 +10,10 @@ import React from 'react'; import { LayerWizard, RenderWizardArguments } from '../layer_wizard_registry'; import { ClientFileCreateSourceEditor, UPLOAD_STEPS } from './wizard'; import { getFileUpload } from '../../../../kibana_services'; +import { WIZARD_ID } from '../../../../../common/constants'; export const uploadLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.GEO_FILE, order: 10, categories: [], description: i18n.translate('xpack.maps.fileUploadWizard.description', { diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.test.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.test.tsx index aa54ea4286f1..0c94ab62d44b 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.test.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.test.tsx @@ -16,6 +16,7 @@ import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; describe('LayerWizardRegistryTest', () => { it('should enforce ordering', async () => { registerLayerWizardExternal({ + id: '', categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: '', icon: '', @@ -27,6 +28,7 @@ describe('LayerWizardRegistryTest', () => { }); registerLayerWizardInternal({ + id: '', order: 1, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: '', @@ -38,6 +40,7 @@ describe('LayerWizardRegistryTest', () => { }); registerLayerWizardInternal({ + id: '', order: 1, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: '', @@ -58,6 +61,7 @@ describe('LayerWizardRegistryTest', () => { it('external users must add order higher than 99 ', async () => { expect(() => { registerLayerWizardExternal({ + id: '', order: 99, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: '', diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.ts b/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.ts index 6ab8a3d9a2f5..977b72c4a2ab 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.ts +++ b/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.ts @@ -12,6 +12,7 @@ import type { LayerDescriptor } from '../../../../common/descriptor_types'; import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export type LayerWizard = { + id: string; title: string; categories: LAYER_WIZARD_CATEGORY[]; /* @@ -90,3 +91,7 @@ export async function getLayerWizards(): Promise { return wizard1.order - wizard2.order; }); } + +export function getWizardById(wizardId: string) { + return registry.find((wizard) => wizard.id === wizardId); +} diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/load_layer_wizards.ts b/x-pack/plugins/maps/public/classes/layers/wizards/load_layer_wizards.ts index 3bf64d08fc84..aa772d44341e 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/load_layer_wizards.ts +++ b/x-pack/plugins/maps/public/classes/layers/wizards/load_layer_wizards.ts @@ -29,6 +29,7 @@ import { choroplethLayerWizardConfig } from './choropleth_layer_wizard'; import { newVectorLayerWizardConfig } from './new_vector_layer_wizard'; let registered = false; + export function registerLayerWizards() { if (registered) { return; diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/new_vector_layer_wizard/config.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/new_vector_layer_wizard/config.tsx index b83410a4eef0..c5aa0fc7db1f 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/new_vector_layer_wizard/config.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/new_vector_layer_wizard/config.tsx @@ -11,11 +11,12 @@ import { LayerWizard, RenderWizardArguments } from '../layer_wizard_registry'; import { NewVectorLayerEditor } from './wizard'; import { DrawLayerIcon } from '../icons/draw_layer_icon'; import { getFileUpload } from '../../../../kibana_services'; -import { LAYER_WIZARD_CATEGORY } from '../../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../../common/constants'; const ADD_VECTOR_DRAWING_LAYER = 'ADD_VECTOR_DRAWING_LAYER'; export const newVectorLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.NEW_VECTOR, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.newVectorLayerWizard.description', { diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/observability/observability_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/observability/observability_layer_wizard.tsx index 2e023f7c588d..60526cfbaded 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/observability/observability_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/observability/observability_layer_wizard.tsx @@ -7,13 +7,14 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { LAYER_WIZARD_CATEGORY } from '../../../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../../../common/constants'; import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; import { ObservabilityLayerTemplate } from './observability_layer_template'; import { APM_INDEX_PATTERN_ID } from './create_layer_descriptor'; import { getIndexPatternService } from '../../../../../kibana_services'; export const ObservabilityLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.OBSERVABILITY, order: 20, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH, LAYER_WIZARD_CATEGORY.SOLUTIONS], getIsDisabled: async () => { diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/security_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/security_layer_wizard.tsx index 79575ea81512..625ead02e2f5 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/security_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/security_layer_wizard.tsx @@ -7,12 +7,13 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { LAYER_WIZARD_CATEGORY } from '../../../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../../../common/constants'; import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; import { getSecurityIndexPatterns } from './security_index_pattern_utils'; import { SecurityLayerTemplate } from './security_layer_template'; export const SecurityLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.SECURITY, order: 20, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH, LAYER_WIZARD_CATEGORY.SOLUTIONS], getIsDisabled: async () => { diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx index 8fe8f1b3a155..27a3960bfbe1 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx @@ -15,7 +15,7 @@ import { EMSFileSource, getSourceTitle } from './ems_file_source'; // @ts-ignore import { getEMSSettings } from '../../../kibana_services'; import { EMSFileSourceDescriptor } from '../../../../common/descriptor_types'; -import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../common/constants'; import { EMSBoundariesLayerIcon } from '../../layers/wizards/icons/ems_boundaries_layer_icon'; function getDescription() { @@ -29,6 +29,7 @@ function getDescription() { } export const emsBoundariesLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.EMS_BOUNDARIES, order: 10, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: async () => { diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx index 27d911cc8feb..58deab255032 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx @@ -13,7 +13,7 @@ import { EmsVectorTileLayer } from '../../layers/ems_vector_tile_layer/ems_vecto import { EmsTmsSourceConfig } from './tile_service_select'; import { CreateSourceEditor } from './create_source_editor'; import { getEMSSettings } from '../../../kibana_services'; -import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../common/constants'; import { WorldMapLayerIcon } from '../../layers/wizards/icons/world_map_layer_icon'; function getDescription() { @@ -27,6 +27,7 @@ function getDescription() { } export const emsBaseMapLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.EMS_BASEMAP, order: 10, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: async () => { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx index e075a615d586..422aa4ab80ec 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx @@ -28,11 +28,13 @@ import { RENDER_AS, VECTOR_STYLES, STYLE_TYPE, + WIZARD_ID, } from '../../../../common/constants'; import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes'; import { ClustersLayerIcon } from '../../layers/wizards/icons/clusters_layer_icon'; export const clustersLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.CLUSTERS, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esGridClustersDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/convert_to_geojson.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/convert_to_geojson.test.ts index d6de17bef710..bbbc86794cc5 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/convert_to_geojson.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/convert_to_geojson.test.ts @@ -52,7 +52,6 @@ describe('convertCompositeRespToGeoJson', () => { avg_of_bytes: 5359.2307692307695, doc_count: 65, 'terms_of_machine.os.keyword': 'win xp', - // eslint-disable-next-line @typescript-eslint/naming-convention 'terms_of_machine.os.keyword__percentage': 25, }, type: 'Feature', @@ -80,7 +79,6 @@ describe('convertCompositeRespToGeoJson', () => { avg_of_bytes: 5359.2307692307695, doc_count: 65, 'terms_of_machine.os.keyword': 'win xp', - // eslint-disable-next-line @typescript-eslint/naming-convention 'terms_of_machine.os.keyword__percentage': 25, }, type: 'Feature', @@ -128,7 +126,6 @@ describe('convertRegularRespToGeoJson', () => { avg_of_bytes: 5359.2307692307695, doc_count: 65, 'terms_of_machine.os.keyword': 'win xp', - // eslint-disable-next-line @typescript-eslint/naming-convention 'terms_of_machine.os.keyword__percentage': 25, }, type: 'Feature', @@ -156,7 +153,6 @@ describe('convertRegularRespToGeoJson', () => { avg_of_bytes: 5359.2307692307695, doc_count: 65, 'terms_of_machine.os.keyword': 'win xp', - // eslint-disable-next-line @typescript-eslint/naming-convention 'terms_of_machine.os.keyword__percentage': 25, }, type: 'Feature', diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts index e2f9959b25d3..a26bd341613b 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts @@ -5,8 +5,14 @@ * 2.0. */ +import { coreMock } from '../../../../../../../src/core/public/mocks'; import { MapExtent, VectorSourceRequestMeta } from '../../../../common/descriptor_types'; -import { getHttp, getIndexPatternService, getSearchService } from '../../../kibana_services'; +import { + getExecutionContext, + getHttp, + getIndexPatternService, + getSearchService, +} from '../../../kibana_services'; import { ESGeoGridSource } from './es_geo_grid_source'; import { ES_GEO_FIELD_TYPE, @@ -129,6 +135,13 @@ describe('ESGeoGridSource', () => { }, }, }); + + const coreStartMock = coreMock.createStart(); + coreStartMock.executionContext.get.mockReturnValue({ + name: 'some-app', + }); + // @ts-expect-error + getExecutionContext.mockReturnValue(coreStartMock.executionContext); }); afterEach(() => { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx index 5e67a8381156..53dbcfac9597 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx @@ -13,10 +13,16 @@ import { ESGeoGridSource, heatmapTitle } from './es_geo_grid_source'; import { LayerWizard, RenderWizardArguments } from '../../layers'; import { HeatmapLayer } from '../../layers/heatmap_layer'; import { ESGeoGridSourceDescriptor } from '../../../../common/descriptor_types'; -import { GRID_RESOLUTION, LAYER_WIZARD_CATEGORY, RENDER_AS } from '../../../../common/constants'; +import { + GRID_RESOLUTION, + LAYER_WIZARD_CATEGORY, + RENDER_AS, + WIZARD_ID, +} from '../../../../common/constants'; import { HeatmapLayerIcon } from '../../layers/wizards/icons/heatmap_layer_icon'; export const heatmapLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.HEATMAP, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esGridHeatmapDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx index 18d459ddbcb7..2957235602d7 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx @@ -10,13 +10,19 @@ import React from 'react'; import { CreateSourceEditor } from './create_source_editor'; import { ESGeoLineSource, geoLineTitle, REQUIRES_GOLD_LICENSE_MSG } from './es_geo_line_source'; import { LayerWizard, RenderWizardArguments } from '../../layers'; -import { LAYER_WIZARD_CATEGORY, STYLE_TYPE, VECTOR_STYLES } from '../../../../common/constants'; +import { + LAYER_WIZARD_CATEGORY, + STYLE_TYPE, + VECTOR_STYLES, + WIZARD_ID, +} from '../../../../common/constants'; import { VectorStyle } from '../../styles/vector/vector_style'; import { GeoJsonVectorLayer } from '../../layers/vector_layer'; import { getIsGoldPlus } from '../../../licensed_features'; import { TracksLayerIcon } from '../../layers/wizards/icons/tracks_layer_icon'; export const geoLineLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.GEO_LINE, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esGeoLineDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx index e3522d39e892..37ecbfdebab1 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx @@ -18,6 +18,7 @@ import { LAYER_WIZARD_CATEGORY, VECTOR_STYLES, STYLE_TYPE, + WIZARD_ID, } from '../../../../common/constants'; import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes'; // @ts-ignore @@ -27,6 +28,7 @@ import { ColorDynamicOptions, SizeDynamicOptions } from '../../../../common/desc import { Point2PointLayerIcon } from '../../layers/wizards/icons/point_2_point_layer_icon'; export const point2PointLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.POINT_2_POINT, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.pewPewDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx index 82fb1c502ef6..92580f92f027 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx @@ -12,7 +12,7 @@ import { CreateSourceEditor } from './create_source_editor'; import { LayerWizard, RenderWizardArguments } from '../../layers'; import { ESSearchSource, sourceTitle } from './es_search_source'; import { BlendedVectorLayer, GeoJsonVectorLayer, MvtVectorLayer } from '../../layers/vector_layer'; -import { LAYER_WIZARD_CATEGORY, SCALING_TYPES } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, SCALING_TYPES, WIZARD_ID } from '../../../../common/constants'; import { DocumentsLayerIcon } from '../../layers/wizards/icons/documents_layer_icon'; import { ESSearchSourceDescriptor, @@ -35,6 +35,7 @@ export function createDefaultLayerDescriptor( } export const esDocumentsLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.ES_DOCUMENT, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esSearchDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/top_hits/wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/top_hits/wizard.tsx index 7c01fed158b0..051afb41ce00 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/top_hits/wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/top_hits/wizard.tsx @@ -10,12 +10,13 @@ import React from 'react'; import { CreateSourceEditor } from './create_source_editor'; import { LayerWizard, RenderWizardArguments } from '../../../layers'; import { GeoJsonVectorLayer } from '../../../layers/vector_layer'; -import { LAYER_WIZARD_CATEGORY } from '../../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../../common/constants'; import { TopHitsLayerIcon } from '../../../layers/wizards/icons/top_hits_layer_icon'; import { ESSearchSourceDescriptor } from '../../../../../common/descriptor_types'; import { ESSearchSource } from '../es_search_source'; export const esTopHitsLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.ES_TOP_HITS, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.topHitsDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx index 0f3475eeae9e..ef2d7e05a5cb 100644 --- a/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx @@ -14,9 +14,10 @@ import { CreateSourceEditor } from './create_source_editor'; import { KibanaTilemapSource, sourceTitle } from './kibana_tilemap_source'; import { RasterTileLayer } from '../../layers/raster_tile_layer/raster_tile_layer'; import { getKibanaTileMap } from '../../../util'; -import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../common/constants'; export const kibanaBasemapLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.KIBANA_BASEMAP, order: 10, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: async () => { diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx index f123ed7c7805..329b00404c23 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx @@ -11,11 +11,12 @@ import { MVTSingleLayerVectorSourceEditor } from './mvt_single_layer_vector_sour import { MVTSingleLayerVectorSource, sourceTitle } from './mvt_single_layer_vector_source'; import { LayerWizard, RenderWizardArguments } from '../../layers'; import { MvtVectorLayer } from '../../layers/vector_layer'; -import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../common/constants'; import { TiledSingleLayerVectorSourceSettings } from '../../../../common/descriptor_types'; import { VectorTileLayerIcon } from '../../layers/wizards/icons/vector_tile_layer_icon'; export const mvtVectorSourceWizardConfig: LayerWizard = { + id: WIZARD_ID.MVT_VECTOR, order: 10, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: i18n.translate('xpack.maps.source.mvtVectorSourceWizard', { diff --git a/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx index 2f79b8d0984d..3b1f5e728eed 100644 --- a/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx @@ -13,10 +13,11 @@ import { WMSCreateSourceEditor } from './wms_create_source_editor'; import { sourceTitle, WMSSource } from './wms_source'; import { LayerWizard, RenderWizardArguments } from '../../layers'; import { RasterTileLayer } from '../../layers/raster_tile_layer/raster_tile_layer'; -import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../common/constants'; import { WebMapServiceLayerIcon } from '../../layers/wizards/icons/web_map_service_layer_icon'; export const wmsLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.WMS_LAYER, order: 10, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: i18n.translate('xpack.maps.source.wmsDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx index 7c137419f4a1..4333fbcbfff6 100644 --- a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx @@ -11,10 +11,11 @@ import { XYZTMSEditor, XYZTMSSourceConfig } from './xyz_tms_editor'; import { XYZTMSSource, sourceTitle } from './xyz_tms_source'; import { LayerWizard, RenderWizardArguments } from '../../layers'; import { RasterTileLayer } from '../../layers/raster_tile_layer/raster_tile_layer'; -import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../common/constants'; import { WorldMapLayerIcon } from '../../layers/wizards/icons/world_map_layer_icon'; export const tmsLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.TMS_LAYER, order: 10, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: i18n.translate('xpack.maps.source.ems_xyzDescription', { diff --git a/x-pack/plugins/maps/public/components/tooltip_selector/tooltip_selector.tsx b/x-pack/plugins/maps/public/components/tooltip_selector/tooltip_selector.tsx index ff00ef958acd..6998a6a629e2 100644 --- a/x-pack/plugins/maps/public/components/tooltip_selector/tooltip_selector.tsx +++ b/x-pack/plugins/maps/public/components/tooltip_selector/tooltip_selector.tsx @@ -192,9 +192,7 @@ export class TooltipSelector extends Component { {(provided, state) => (
    diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/index.ts b/x-pack/plugins/maps/public/connected_components/add_layer_panel/index.ts index ed10b135899d..b790c0c1da5b 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/index.ts +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/index.ts @@ -18,16 +18,19 @@ import { setFirstPreviewLayerToSelectedLayer, setEditLayerToSelectedLayer, updateFlyout, + setAutoOpenLayerWizardId, } from '../../actions'; import { MapStoreState } from '../../reducers/store'; import { LayerDescriptor } from '../../../common/descriptor_types'; import { hasPreviewLayers, isLoadingPreviewLayers } from '../../selectors/map_selectors'; import { DRAW_MODE } from '../../../common/constants'; +import { getAutoOpenLayerWizardId } from '../../selectors/ui_selectors'; function mapStateToProps(state: MapStoreState) { return { hasPreviewLayers: hasPreviewLayers(state), isLoadingPreviewLayers: isLoadingPreviewLayers(state), + autoOpenLayerWizardId: getAutoOpenLayerWizardId(state), }; } @@ -49,6 +52,9 @@ function mapDispatchToProps(dispatch: ThunkDispatch { + dispatch(setAutoOpenLayerWizardId('')); + }, }; } diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx index 6cb5c94c5cd8..578059b17445 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx @@ -20,6 +20,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { FlyoutBody } from './flyout_body'; import { LayerDescriptor } from '../../../common/descriptor_types'; import { LayerWizard } from '../../classes/layers'; +import { getWizardById } from '../../classes/layers/wizards/layer_wizard_registry'; export const ADD_LAYER_STEP_ID = 'ADD_LAYER_STEP_ID'; const ADD_LAYER_STEP_LABEL = i18n.translate('xpack.maps.addLayerPanel.addLayer', { @@ -34,6 +35,8 @@ export interface Props { isLoadingPreviewLayers: boolean; promotePreviewLayers: () => void; enableEditMode: () => void; + autoOpenLayerWizardId: string; + clearAutoOpenLayerWizardId: () => void; } interface State { @@ -59,6 +62,20 @@ export class AddLayerPanel extends Component { ...INITIAL_STATE, }; + componentDidMount() { + if (this.props.autoOpenLayerWizardId) { + this._openWizard(); + } + } + + _openWizard() { + const selectedWizard = getWizardById(this.props.autoOpenLayerWizardId); + if (selectedWizard) { + this._onWizardSelect(selectedWizard); + } + this.props.clearAutoOpenLayerWizardId(); + } + _previewLayers = (layerDescriptors: LayerDescriptor[]) => { this.props.addPreviewLayers(layerDescriptors); }; 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 bfc8474fec88..581460f31858 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 @@ -27,7 +27,6 @@ import { RawValue } from '../../../common/constants'; import { FLYOUT_STATE } from '../../reducers/ui'; import { MapSettings } from '../../reducers/map'; import { MapSettingsPanel } from '../map_settings_panel'; -import { registerLayerWizards } from '../../classes/layers/wizards/load_layer_wizards'; import { RenderToolTipContent } from '../../classes/tooltips/tooltip_property'; import { ILayer } from '../../classes/layers/layer'; @@ -81,7 +80,6 @@ export class MapContainer extends Component { this._isMounted = true; this._loadShowFitToBoundsButton(); this._loadShowTimesliderButton(); - registerLayerWizards(); } componentDidUpdate() { diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx index a404db91a942..8cbfcd3a41e8 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx @@ -20,15 +20,6 @@ import { DRAW_SHAPE } from '../../../../common/constants'; import { DrawCircle, DRAW_CIRCLE_RADIUS_LABEL_STYLE } from './draw_circle'; import { DrawTooltip } from './draw_tooltip'; -const mbModeEquivalencies = new Map([ - ['simple_select', DRAW_SHAPE.SIMPLE_SELECT], - ['draw_rectangle', DRAW_SHAPE.BOUNDS], - ['draw_circle', DRAW_SHAPE.DISTANCE], - ['draw_polygon', DRAW_SHAPE.POLYGON], - ['draw_line_string', DRAW_SHAPE.LINE], - ['draw_point', DRAW_SHAPE.POINT], -]); - const DRAW_RECTANGLE = 'draw_rectangle'; const DRAW_CIRCLE = 'draw_circle'; const mbDrawModes = MapboxDraw.modes; @@ -41,7 +32,6 @@ export interface Props { onClick?: (event: MapMouseEvent, drawControl?: MapboxDraw) => void; mbMap: MbMap; enable: boolean; - updateEditShape: (shapeToDraw: DRAW_SHAPE) => void; } export class DrawControl extends Component { @@ -91,12 +81,6 @@ export class DrawControl extends Component { } }, 0); - _onModeChange = ({ mode }: { mode: string }) => { - if (mbModeEquivalencies.has(mode)) { - this.props.updateEditShape(mbModeEquivalencies.get(mode)!); - } - }; - _removeDrawControl() { // Do not remove draw control after mbMap.remove is called, causes execeptions and mbMap.remove cleans up all map resources. const isMapRemoved = !this.props.mbMap.loaded(); @@ -105,7 +89,6 @@ export class DrawControl extends Component { } this.props.mbMap.getCanvas().style.cursor = ''; - this.props.mbMap.off('draw.modechange', this._onModeChange); this.props.mbMap.off('draw.create', this._onDraw); if (this.props.onClick) { this.props.mbMap.off('click', this._onClick); @@ -118,7 +101,6 @@ export class DrawControl extends Component { if (!this._mbDrawControlAdded) { this.props.mbMap.addControl(this._mbDrawControl); this._mbDrawControlAdded = true; - this.props.mbMap.on('draw.modechange', this._onModeChange); this.props.mbMap.on('draw.create', this._onDraw); if (this.props.onClick) { @@ -144,6 +126,9 @@ export class DrawControl extends Component { this._mbDrawControl.changeMode(DRAW_POINT); } else if (this.props.drawShape === DRAW_SHAPE.DELETE) { this._mbDrawControl.changeMode(SIMPLE_SELECT); + } else if (this.props.drawShape === DRAW_SHAPE.WAIT) { + this.props.mbMap.getCanvas().style.cursor = 'wait'; + this._mbDrawControl.changeMode(SIMPLE_SELECT); } else { this._mbDrawControl.changeMode(SIMPLE_SELECT); } diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_feature_control/draw_feature_control.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_feature_control/draw_feature_control.tsx index 6c7fe9f0ad21..b6ffacc49103 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_feature_control/draw_feature_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_feature_control/draw_feature_control.tsx @@ -15,7 +15,7 @@ import { i18n } from '@kbn/i18n'; import * as jsts from 'jsts'; import { MapMouseEvent } from '@kbn/mapbox-gl'; import { getToasts } from '../../../../kibana_services'; -import { DrawControl } from '../'; +import { DrawControl } from '../draw_control'; import { DRAW_MODE, DRAW_SHAPE } from '../../../../../common/constants'; import { ILayer } from '../../../../classes/layers/layer'; import { EXCLUDE_CENTROID_FEATURES } from '../../../../classes/util/mb_filter_expressions'; @@ -29,9 +29,8 @@ export interface ReduxStateProps { } export interface ReduxDispatchProps { - addNewFeatureToIndex: (geometry: Geometry | Position[]) => void; + addNewFeatureToIndex: (geometries: Array) => void; deleteFeatureFromIndex: (featureId: string) => void; - disableDrawState: () => void; } export interface OwnProps { @@ -43,6 +42,7 @@ type Props = ReduxStateProps & ReduxDispatchProps & OwnProps; export class DrawFeatureControl extends Component { _onDraw = async (e: { features: Feature[] }, mbDrawControl: MapboxDraw) => { try { + const geometries: Array = []; e.features.forEach((feature: Feature) => { const { geometry } = geoJSONReader.read(feature); if (!geometry.isSimple() || !geometry.isValid()) { @@ -58,9 +58,13 @@ export class DrawFeatureControl extends Component { this.props.drawMode === DRAW_MODE.DRAW_POINTS ? feature.geometry.coordinates : feature.geometry; - this.props.addNewFeatureToIndex(featureGeom); + geometries.push(featureGeom); } }); + + if (geometries.length) { + this.props.addNewFeatureToIndex(geometries); + } } catch (error) { getToasts().addWarning( i18n.translate('xpack.maps.drawFeatureControl.unableToCreateFeature', { @@ -71,7 +75,6 @@ export class DrawFeatureControl extends Component { }) ); } finally { - this.props.disableDrawState(); try { mbDrawControl.deleteAll(); } catch (_e) { @@ -86,6 +89,7 @@ export class DrawFeatureControl extends Component { if (!this.props.editLayer || this.props.drawShape !== DRAW_SHAPE.DELETE) { return; } + const mbEditLayerIds = this.props.editLayer .getMbLayerIds() .filter((mbLayerId) => !!this.props.mbMap.getLayer(mbLayerId)); diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_feature_control/index.ts b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_feature_control/index.ts index e1d703173fc2..d2c369b4bd50 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_feature_control/index.ts +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_feature_control/index.ts @@ -15,7 +15,7 @@ import { ReduxStateProps, OwnProps, } from './draw_feature_control'; -import { addNewFeatureToIndex, deleteFeatureFromIndex, updateEditShape } from '../../../../actions'; +import { addNewFeatureToIndex, deleteFeatureFromIndex } from '../../../../actions'; import { MapStoreState } from '../../../../reducers/store'; import { getEditState, getLayerById } from '../../../../selectors/map_selectors'; import { getDrawMode } from '../../../../selectors/ui_selectors'; @@ -34,15 +34,12 @@ function mapDispatchToProps( dispatch: ThunkDispatch ): ReduxDispatchProps { return { - addNewFeatureToIndex(geometry: Geometry | Position[]) { - dispatch(addNewFeatureToIndex(geometry)); + addNewFeatureToIndex(geometries: Array) { + dispatch(addNewFeatureToIndex(geometries)); }, deleteFeatureFromIndex(featureId: string) { dispatch(deleteFeatureFromIndex(featureId)); }, - disableDrawState() { - dispatch(updateEditShape(null)); - }, }; } diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/draw_filter_control.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/draw_filter_control.tsx index 2f652506857d..98d88d43fc65 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/draw_filter_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/draw_filter_control.tsx @@ -20,7 +20,7 @@ import { roundCoordinates, } from '../../../../../common/elasticsearch_util'; import { getToasts } from '../../../../kibana_services'; -import { DrawControl } from '../'; +import { DrawControl } from '../draw_control'; import { DrawCircleProperties } from '../draw_circle'; export interface Props { diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/index.ts b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/index.ts deleted file mode 100644 index b0f1941caec0..000000000000 --- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ThunkDispatch } from 'redux-thunk'; -import { AnyAction } from 'redux'; -import { connect } from 'react-redux'; -import { updateEditShape } from '../../../actions'; -import { MapStoreState } from '../../../reducers/store'; -import { DrawControl } from './draw_control'; -import { DRAW_SHAPE } from '../../../../common/constants'; - -function mapDispatchToProps(dispatch: ThunkDispatch) { - return { - updateEditShape(shapeToDraw: DRAW_SHAPE) { - dispatch(updateEditShape(shapeToDraw)); - }, - }; -} - -const connected = connect(null, mapDispatchToProps)(DrawControl); -export { connected as DrawControl }; diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index 54e4964b35f0..d8197902c73a 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -33,6 +33,7 @@ export const getUiSettings = () => coreStart.uiSettings; export const getIsDarkMode = () => getUiSettings().get('theme:darkMode', false); export const getIndexPatternSelectComponent = () => pluginsStart.data.ui.IndexPatternSelect; export const getHttp = () => coreStart.http; +export const getExecutionContext = () => coreStart.executionContext; export const getTimeFilter = () => pluginsStart.data.query.timefilter.timefilter; export const getToasts = () => coreStart.notifications.toasts; export const getSavedObjectsClient = () => coreStart.savedObjects.client; diff --git a/x-pack/plugins/maps/public/reducers/ui.ts b/x-pack/plugins/maps/public/reducers/ui.ts index f3f948bb9650..1ee7dc3e38e2 100644 --- a/x-pack/plugins/maps/public/reducers/ui.ts +++ b/x-pack/plugins/maps/public/reducers/ui.ts @@ -19,6 +19,9 @@ import { SHOW_TOC_DETAILS, HIDE_TOC_DETAILS, SET_DRAW_MODE, + SET_AUTO_OPEN_WIZARD_ID, + PUSH_DELETED_FEATURE_ID, + CLEAR_DELETED_FEATURE_IDS, } from '../actions'; import { DRAW_MODE } from '../../common/constants'; @@ -37,6 +40,8 @@ export type MapUiState = { isLayerTOCOpen: boolean; isTimesliderOpen: boolean; openTOCDetails: string[]; + autoOpenLayerWizardId: string; + deletedFeatureIds: string[]; }; export const DEFAULT_IS_LAYER_TOC_OPEN = true; @@ -51,6 +56,8 @@ export const DEFAULT_MAP_UI_STATE = { // storing TOC detail visibility outside of map.layerList because its UI state and not map rendering state. // This also makes for easy read/write access for embeddables. openTOCDetails: [], + autoOpenLayerWizardId: '', + deletedFeatureIds: [], }; // Reducer @@ -82,6 +89,18 @@ export function ui(state: MapUiState = DEFAULT_MAP_UI_STATE, action: any) { return layerId !== action.layerId; }), }; + case SET_AUTO_OPEN_WIZARD_ID: + return { ...state, autoOpenLayerWizardId: action.autoOpenLayerWizardId }; + case PUSH_DELETED_FEATURE_ID: + return { + ...state, + deletedFeatureIds: [...state.deletedFeatureIds, action.featureId], + }; + case CLEAR_DELETED_FEATURE_IDS: + return { + ...state, + deletedFeatureIds: [], + }; default: return state; } diff --git a/x-pack/plugins/maps/public/render_app.tsx b/x-pack/plugins/maps/public/render_app.tsx index bf7aec7f5f15..aa5e1ee29833 100644 --- a/x-pack/plugins/maps/public/render_app.tsx +++ b/x-pack/plugins/maps/public/render_app.tsx @@ -27,6 +27,7 @@ import { import { ListPage, MapPage } from './routes'; import { MapByValueInput, MapByReferenceInput } from './embeddable/types'; import { APP_ID } from '../common/constants'; +import { registerLayerWizards } from './classes/layers/wizards/load_layer_wizards'; export let goToSpecifiedPath: (path: string) => void; export let kbnUrlStateStorage: IKbnUrlStateStorage; @@ -75,6 +76,7 @@ export async function renderApp( const stateTransfer = getEmbeddableService().getStateTransfer(); + registerLayerWizards(); setAppChrome(); function renderMapApp(routeProps: RouteComponentProps<{ savedMapId?: string }>) { diff --git a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx index 571cba64a06c..dab284b0b71e 100644 --- a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx +++ b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx @@ -17,6 +17,7 @@ import { getMapsCapabilities, getToasts, getCoreChrome, + getExecutionContext, getNavigateToApp, getSavedObjectsClient, getSavedObjectsTagging, @@ -121,6 +122,12 @@ async function deleteMaps(items: object[]) { } export function MapsListView() { + getExecutionContext().set({ + type: 'application', + page: 'list', + id: '', + }); + const isReadOnly = !getMapsCapabilities().save; getCoreChrome().docTitle.change(getAppTitle()); diff --git a/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx b/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx index 9aede248e187..a341246f748f 100644 --- a/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx +++ b/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx @@ -16,6 +16,7 @@ import { type Filter, FilterStateStore } from '@kbn/es-query'; import type { Query, TimeRange, DataView } from 'src/plugins/data/common'; import { getData, + getExecutionContext, getCoreChrome, getMapsCapabilities, getNavigation, @@ -115,6 +116,12 @@ export class MapApp extends React.Component { componentDidMount() { this._isMounted = true; + getExecutionContext().set({ + type: 'application', + page: 'editor', + id: this.props.savedMap.getSavedObjectId() || 'new', + }); + this._autoRefreshSubscription = getTimeFilter() .getAutoRefreshFetch$() .pipe( diff --git a/x-pack/plugins/maps/public/routes/map_page/map_page.tsx b/x-pack/plugins/maps/public/routes/map_page/map_page.tsx index b382be1d506b..dc7030a805ce 100644 --- a/x-pack/plugins/maps/public/routes/map_page/map_page.tsx +++ b/x-pack/plugins/maps/public/routes/map_page/map_page.tsx @@ -10,7 +10,11 @@ import { Provider } from 'react-redux'; import type { AppMountParameters } from 'kibana/public'; import type { EmbeddableStateTransfer } from 'src/plugins/embeddable/public'; import { MapApp } from './map_app'; -import { SavedMap, getInitialLayersFromUrlParam } from './saved_map'; +import { + SavedMap, + getInitialLayersFromUrlParam, + getOpenLayerWizardFromUrlParam, +} from './saved_map'; import { MapEmbeddableInput } from '../../embeddable/types'; interface Props { @@ -47,6 +51,7 @@ export class MapPage extends Component { originatingPath: props.originatingPath, stateTransfer: props.stateTransfer, onSaveCallback: this.updateSaveCounter, + defaultLayerWizard: getOpenLayerWizardFromUrlParam() || '', }), saveCounter: 0, }; diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_open_layer_wizard_url_param.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_open_layer_wizard_url_param.ts new file mode 100644 index 000000000000..099a756b7052 --- /dev/null +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_open_layer_wizard_url_param.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 { OPEN_LAYER_WIZARD } from '../../../../common/constants'; + +export function getOpenLayerWizardFromUrlParam() { + const locationSplit = window.location.href.split(/[?#]+/); + + if (locationSplit.length <= 1) { + return ''; + } + + const mapAppParams = new URLSearchParams(locationSplit[1]); + if (!mapAppParams.has(OPEN_LAYER_WIZARD)) { + return ''; + } + + return mapAppParams.has(OPEN_LAYER_WIZARD) ? mapAppParams.get(OPEN_LAYER_WIZARD) : ''; +} diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/index.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/index.ts index a3e8ef96160b..c204267e0f9a 100644 --- a/x-pack/plugins/maps/public/routes/map_page/saved_map/index.ts +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/index.ts @@ -12,3 +12,4 @@ export { getInitialQuery } from './get_initial_query'; export { getInitialRefreshConfig } from './get_initial_refresh_config'; export { getInitialTimeFilters } from './get_initial_time_filters'; export { unsavedChangesTitle, unsavedChangesWarning } from './get_breadcrumbs'; +export { getOpenLayerWizardFromUrlParam } from './get_open_layer_wizard_url_param'; diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts index a9547fe90a00..781a72aabf78 100644 --- a/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts @@ -48,6 +48,7 @@ import { DEFAULT_IS_LAYER_TOC_OPEN } from '../../../reducers/ui'; import { createBasemapLayerDescriptor } from '../../../classes/layers/create_basemap_layer_descriptor'; import { whenLicenseInitialized } from '../../../licensed_features'; import { SerializedMapState, SerializedUiState } from './types'; +import { setAutoOpenLayerWizardId } from '../../../actions/ui_actions'; export class SavedMap { private _attributes: MapSavedObjectAttributes | null = null; @@ -62,6 +63,7 @@ export class SavedMap { private readonly _stateTransfer?: EmbeddableStateTransfer; private readonly _store: MapStore; private _tags: string[] = []; + private _defaultLayerWizard: string; constructor({ defaultLayers = [], @@ -71,6 +73,7 @@ export class SavedMap { originatingApp, stateTransfer, originatingPath, + defaultLayerWizard, }: { defaultLayers?: LayerDescriptor[]; mapEmbeddableInput?: MapEmbeddableInput; @@ -79,6 +82,7 @@ export class SavedMap { originatingApp?: string; stateTransfer?: EmbeddableStateTransfer; originatingPath?: string; + defaultLayerWizard?: string; }) { this._defaultLayers = defaultLayers; this._mapEmbeddableInput = mapEmbeddableInput; @@ -88,6 +92,7 @@ export class SavedMap { this._originatingPath = originatingPath; this._stateTransfer = stateTransfer; this._store = createMapStore(); + this._defaultLayerWizard = defaultLayerWizard || ''; } public getStore() { @@ -204,6 +209,10 @@ export class SavedMap { this._store.dispatch(setHiddenLayers(this._mapEmbeddableInput.hiddenLayers)); } this._initialLayerListConfig = copyPersistentState(layerList); + + if (this._defaultLayerWizard) { + this._store.dispatch(setAutoOpenLayerWizardId(this._defaultLayerWizard)); + } } hasUnsavedChanges = () => { diff --git a/x-pack/plugins/maps/public/selectors/ui_selectors.ts b/x-pack/plugins/maps/public/selectors/ui_selectors.ts index 942a5190691a..1011a736e5ce 100644 --- a/x-pack/plugins/maps/public/selectors/ui_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/ui_selectors.ts @@ -17,3 +17,5 @@ export const getIsTimesliderOpen = ({ ui }: MapStoreState): boolean => ui.isTime export const getOpenTOCDetails = ({ ui }: MapStoreState): string[] => ui.openTOCDetails; export const getIsFullScreen = ({ ui }: MapStoreState): boolean => ui.isFullScreen; export const getIsReadOnly = ({ ui }: MapStoreState): boolean => ui.isReadOnly; +export const getAutoOpenLayerWizardId = ({ ui }: MapStoreState): string => ui.autoOpenLayerWizardId; +export const getDeletedFeatureIds = ({ ui }: MapStoreState): string[] => ui.deletedFeatureIds; diff --git a/x-pack/plugins/maps/public/util.test.js b/x-pack/plugins/maps/public/util.test.js index d8861063fc63..7fc88578b378 100644 --- a/x-pack/plugins/maps/public/util.test.js +++ b/x-pack/plugins/maps/public/util.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { getGlyphUrl } from './util'; +import { getGlyphUrl, makePublicExecutionContext } from './util'; const MOCK_EMS_SETTINGS = { isEMSEnabled: () => true, @@ -62,3 +62,55 @@ describe('getGlyphUrl', () => { }); }); }); + +describe('makePublicExecutionContext', () => { + let injectedContext = {}; + beforeAll(() => { + require('./kibana_services').getExecutionContext = () => ({ + get: () => injectedContext, + }); + }); + + test('creates basic context when no top level context is provided', () => { + const context = makePublicExecutionContext('test'); + expect(context).toStrictEqual({ + description: 'test', + name: 'maps', + type: 'application', + url: '/', + }); + }); + + test('merges with top level context if its from the same app', () => { + injectedContext = { + name: 'maps', + id: '1234', + }; + const context = makePublicExecutionContext('test'); + expect(context).toStrictEqual({ + description: 'test', + name: 'maps', + type: 'application', + url: '/', + id: '1234', + }); + }); + + test('nests inside top level context if its from a different app', () => { + injectedContext = { + name: 'other-app', + id: '1234', + }; + const context = makePublicExecutionContext('test'); + expect(context).toStrictEqual({ + name: 'other-app', + id: '1234', + child: { + description: 'test', + type: 'application', + name: 'maps', + url: '/', + }, + }); + }); +}); diff --git a/x-pack/plugins/maps/public/util.ts b/x-pack/plugins/maps/public/util.ts index 4adb8b35bfce..66244ea5f676 100644 --- a/x-pack/plugins/maps/public/util.ts +++ b/x-pack/plugins/maps/public/util.ts @@ -8,7 +8,13 @@ import { EMSClient, FileLayer, TMSService } from '@elastic/ems-client'; import type { KibanaExecutionContext } from 'kibana/public'; import { FONTS_API_PATH } from '../common/constants'; -import { getHttp, getTilemap, getEMSSettings, getMapsEmsStart } from './kibana_services'; +import { + getHttp, + getTilemap, + getEMSSettings, + getMapsEmsStart, + getExecutionContext, +} from './kibana_services'; import { getLicenseId } from './licensed_features'; import { makeExecutionContext } from '../common/execution_context'; @@ -67,9 +73,21 @@ export function isRetina(): boolean { return window.devicePixelRatio === 2; } -export function makePublicExecutionContext( - id: string, - description?: string -): KibanaExecutionContext { - return makeExecutionContext(id, window.location.pathname, description); +export function makePublicExecutionContext(description: string): KibanaExecutionContext { + const topLevelContext = getExecutionContext().get(); + const context = makeExecutionContext({ + url: window.location.pathname, + description, + }); + + // Distinguish between running in maps app vs. embedded + return topLevelContext.name !== undefined && topLevelContext.name !== context.name + ? { + ...topLevelContext, + child: context, + } + : { + ...topLevelContext, + ...context, + }; } diff --git a/x-pack/plugins/maps/server/mvt/get_grid_tile.ts b/x-pack/plugins/maps/server/mvt/get_grid_tile.ts index 193a3d74e2dc..28effa5eabfb 100644 --- a/x-pack/plugins/maps/server/mvt/get_grid_tile.ts +++ b/x-pack/plugins/maps/server/mvt/get_grid_tile.ts @@ -56,7 +56,10 @@ export async function getEsGridTile({ }; const tile = await core.executionContext.withContext( - makeExecutionContext('mvt:get_grid_tile', url), + makeExecutionContext({ + description: 'mvt:get_grid_tile', + url, + }), async () => { return await context.core.elasticsearch.client.asCurrentUser.transport.request( { diff --git a/x-pack/plugins/maps/server/mvt/get_tile.ts b/x-pack/plugins/maps/server/mvt/get_tile.ts index 2c8b6dd4b113..7e9bc01c5c31 100644 --- a/x-pack/plugins/maps/server/mvt/get_tile.ts +++ b/x-pack/plugins/maps/server/mvt/get_tile.ts @@ -57,7 +57,10 @@ export async function getEsTile({ }; const tile = await core.executionContext.withContext( - makeExecutionContext('mvt:get_tile', url), + makeExecutionContext({ + description: 'mvt:get_tile', + url, + }), async () => { return await context.core.elasticsearch.client.asCurrentUser.transport.request( { diff --git a/x-pack/plugins/maps/server/register_integrations.ts b/x-pack/plugins/maps/server/register_integrations.ts index 8832c746f6db..3740ae624279 100644 --- a/x-pack/plugins/maps/server/register_integrations.ts +++ b/x-pack/plugins/maps/server/register_integrations.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup } from 'kibana/server'; import { CustomIntegrationsPluginSetup } from '../../../../src/plugins/custom_integrations/server'; -import { APP_ID } from '../common/constants'; +import { APP_ID, OPEN_LAYER_WIZARD, getFullPath, WIZARD_ID } from '../common/constants'; export function registerIntegrations( core: CoreSetup, @@ -35,4 +35,45 @@ export function registerIntegrations( shipper: 'other', isBeta: false, }); + customIntegrations.registerCustomIntegration({ + id: 'ingest_geojson', + title: i18n.translate('xpack.maps.registerIntegrations.geojson.integrationTitle', { + defaultMessage: 'GeoJSON', + }), + description: i18n.translate('xpack.maps.registerIntegrations.geojson.integrationDescription', { + defaultMessage: 'Upload GeoJSON files with Elastic Maps.', + }), + uiInternalPath: `${getFullPath('')}#?${OPEN_LAYER_WIZARD}=${WIZARD_ID.GEO_FILE}`, + icons: [ + { + type: 'eui', + src: 'logoMaps', + }, + ], + categories: ['upload_file', 'geo'], + shipper: 'other', + isBeta: false, + }); + customIntegrations.registerCustomIntegration({ + id: 'ingest_shape', + title: i18n.translate('xpack.maps.registerIntegrations.shapefile.integrationTitle', { + defaultMessage: 'Shapefile', + }), + description: i18n.translate( + 'xpack.maps.registerIntegrations.shapefile.integrationDescription', + { + defaultMessage: 'Upload Shapefiles with Elastic Maps.', + } + ), + uiInternalPath: `${getFullPath('')}#?${OPEN_LAYER_WIZARD}=${WIZARD_ID.GEO_FILE}`, + icons: [ + { + type: 'eui', + src: 'logoMaps', + }, + ], + categories: ['upload_file', 'geo'], + shipper: 'other', + isBeta: false, + }); } diff --git a/x-pack/plugins/ml/common/constants/trained_models.ts b/x-pack/plugins/ml/common/constants/trained_models.ts index 019189ea13c0..e7508af45f5b 100644 --- a/x-pack/plugins/ml/common/constants/trained_models.ts +++ b/x-pack/plugins/ml/common/constants/trained_models.ts @@ -12,3 +12,11 @@ export const DEPLOYMENT_STATE = { } as const; export type DeploymentState = typeof DEPLOYMENT_STATE[keyof typeof DEPLOYMENT_STATE]; + +export const TRAINED_MODEL_TYPE = { + PYTORCH: 'pytorch', + TREE_ENSEMBLE: 'tree_ensemble', + LANG_IDENT: 'lang_ident', +} as const; + +export type TrainedModelType = typeof TRAINED_MODEL_TYPE[keyof typeof TRAINED_MODEL_TYPE]; diff --git a/x-pack/plugins/ml/common/types/annotations.ts b/x-pack/plugins/ml/common/types/annotations.ts index dbc146c1175d..50d7c2d3fcd2 100644 --- a/x-pack/plugins/ml/common/types/annotations.ts +++ b/x-pack/plugins/ml/common/types/annotations.ts @@ -6,10 +6,10 @@ */ // The Annotation interface is based on annotation documents stored in the -// `.ml-annotations-6` index, accessed via the `.ml-annotations-[read|write]` aliases. +// `.ml-annotations-*` index, accessed via the `.ml-annotations-[read|write]` aliases. // Annotation document mapping: -// PUT .ml-annotations-6 +// PUT .ml-annotations-000001 // { // "mappings": { // "annotation": { @@ -54,8 +54,8 @@ // POST /_aliases // { // "actions" : [ -// { "add" : { "index" : ".ml-annotations-6", "alias" : ".ml-annotations-read" } }, -// { "add" : { "index" : ".ml-annotations-6", "alias" : ".ml-annotations-write" } } +// { "add" : { "index" : ".ml-annotations-000001", "alias" : ".ml-annotations-read" } }, +// { "add" : { "index" : ".ml-annotations-000001", "alias" : ".ml-annotations-write" } } // ] // } @@ -128,4 +128,5 @@ export interface GetAnnotationsResponse { export interface AnnotationsTable { annotationsData: Annotations; error?: string; + totalCount?: number; } diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index 36377aaa1ed3..f99e99df5606 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -7,7 +7,11 @@ import { KibanaRequest } from 'kibana/server'; import { PLUGIN_ID } from '../constants/app'; -import { ML_SAVED_OBJECT_TYPE } from './saved_objects'; +import { + ML_JOB_SAVED_OBJECT_TYPE, + ML_MODULE_SAVED_OBJECT_TYPE, + ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, +} from './saved_objects'; import { ML_ALERT_TYPES } from '../constants/alerts'; export const apmUserMlCapabilities = { @@ -32,6 +36,8 @@ export const userMlCapabilities = { canDeleteAnnotation: false, // Alerts canUseMlAlerts: false, + // Trained models + canGetTrainedModels: false, }; export const adminMlCapabilities = { @@ -65,6 +71,10 @@ export const adminMlCapabilities = { canUseMlAlerts: false, // Model management canViewMlNodes: false, + // Trained models + canCreateTrainedModels: false, + canDeleteTrainedModels: false, + canStartStopTrainedModels: false, }; export type UserMlCapabilities = typeof userMlCapabilities; @@ -88,13 +98,15 @@ export function getPluginPrivileges() { const userMlCapabilitiesKeys = Object.keys(userMlCapabilities); const adminMlCapabilitiesKeys = Object.keys(adminMlCapabilities); const allMlCapabilitiesKeys = [...adminMlCapabilitiesKeys, ...userMlCapabilitiesKeys]; - // TODO: include ML in base privileges for the `8.0` release: https://github.com/elastic/kibana/issues/71422 + const savedObjects = [ 'index-pattern', 'dashboard', 'search', 'visualization', - ML_SAVED_OBJECT_TYPE, + ML_JOB_SAVED_OBJECT_TYPE, + ML_MODULE_SAVED_OBJECT_TYPE, + ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, ]; const privilege = { app: [PLUGIN_ID, 'kibana'], @@ -149,7 +161,7 @@ export function getPluginPrivileges() { catalogue: [], savedObject: { all: [], - read: [ML_SAVED_OBJECT_TYPE], + read: [ML_JOB_SAVED_OBJECT_TYPE], }, api: apmUserMlCapabilitiesKeys.map((k) => `ml:${k}`), ui: apmUserMlCapabilitiesKeys, diff --git a/x-pack/plugins/ml/common/types/locator.ts b/x-pack/plugins/ml/common/types/locator.ts index e13dbf7c5b27..c6a825fc6e9d 100644 --- a/x-pack/plugins/ml/common/types/locator.ts +++ b/x-pack/plugins/ml/common/types/locator.ts @@ -72,6 +72,11 @@ export type AnomalyDetectionUrlState = MLPageState< typeof ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, AnomalyDetectionQueryState | undefined >; + +export type AnomalyExplorerSwimLaneUrlState = ExplorerAppState['mlExplorerSwimlane']; + +export type AnomalyExplorerFilterUrlState = ExplorerAppState['mlExplorerFilter']; + export interface ExplorerAppState { mlExplorerSwimlane: { selectedType?: 'overall' | 'viewBy'; diff --git a/x-pack/plugins/ml/common/types/saved_objects.ts b/x-pack/plugins/ml/common/types/saved_objects.ts index e376fddbe627..b1f46240733c 100644 --- a/x-pack/plugins/ml/common/types/saved_objects.ts +++ b/x-pack/plugins/ml/common/types/saved_objects.ts @@ -7,18 +7,31 @@ import type { ErrorType } from '../util/errors'; export type JobType = 'anomaly-detector' | 'data-frame-analytics'; -export const ML_SAVED_OBJECT_TYPE = 'ml-job'; +export type TrainedModelType = 'trained-model'; + +export const ML_JOB_SAVED_OBJECT_TYPE = 'ml-job'; +export const ML_TRAINED_MODEL_SAVED_OBJECT_TYPE = 'ml-trained-model'; export const ML_MODULE_SAVED_OBJECT_TYPE = 'ml-module'; +export type MlSavedObjectType = + | typeof ML_JOB_SAVED_OBJECT_TYPE + | typeof ML_TRAINED_MODEL_SAVED_OBJECT_TYPE + | typeof ML_MODULE_SAVED_OBJECT_TYPE; export interface SavedObjectResult { - [jobId: string]: { success: boolean; type: JobType; error?: ErrorType }; + [id: string]: { success: boolean; type: JobType | TrainedModelType; error?: ErrorType }; } +export type SyncResult = { + [jobType in JobType | TrainedModelType]?: { + [id: string]: { success: boolean; error?: ErrorType }; + }; +}; + export interface SyncSavedObjectResponse { - savedObjectsCreated: SavedObjectResult; - savedObjectsDeleted: SavedObjectResult; - datafeedsAdded: SavedObjectResult; - datafeedsRemoved: SavedObjectResult; + savedObjectsCreated: SyncResult; + savedObjectsDeleted: SyncResult; + datafeedsAdded: SyncResult; + datafeedsRemoved: SyncResult; } export interface CanDeleteJobResponse { @@ -32,9 +45,14 @@ export type JobsSpacesResponse = { [jobType in JobType]: { [jobId: string]: string[] }; }; +export interface TrainedModelsSpacesResponse { + trainedModels: { [id: string]: string[] }; +} + export interface InitializeSavedObjectResponse { jobs: Array<{ id: string; type: JobType }>; datafeeds: Array<{ id: string; type: JobType }>; + trainedModels: Array<{ id: string }>; success: boolean; error?: ErrorType; } diff --git a/x-pack/plugins/ml/common/types/trained_models.ts b/x-pack/plugins/ml/common/types/trained_models.ts index 7e4ed94c3cca..d6eda37f9946 100644 --- a/x-pack/plugins/ml/common/types/trained_models.ts +++ b/x-pack/plugins/ml/common/types/trained_models.ts @@ -8,7 +8,7 @@ import type { DataFrameAnalyticsConfig } from './data_frame_analytics'; import type { FeatureImportanceBaseline, TotalFeatureImportance } from './feature_importance'; import type { XOR } from './common'; -import type { DeploymentState } from '../constants/trained_models'; +import type { DeploymentState, TrainedModelType } from '../constants/trained_models'; export interface IngestStats { count: number; @@ -103,7 +103,7 @@ export interface TrainedModelConfigResponse { model_aliases?: string[]; } & Record; model_id: string; - model_type: 'tree_ensemble' | 'pytorch' | 'lang_ident'; + model_type: TrainedModelType; tags: string[]; version: string; inference_config?: Record; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 54ef526fb511..71a1b0b489c9 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -7,44 +7,45 @@ "ml" ], "requiredPlugins": [ + "cloud", "data", "dataViews", - "cloud", - "features", "dataVisualizer", + "discover", + "embeddable", + "features", + "fieldFormats", "licensing", "share", - "embeddable", - "uiActions", - "discover", + "taskManager", "triggersActionsUi", - "fieldFormats" + "uiActions" ], "optionalPlugins": [ "alerting", + "charts", + "dashboard", "home", - "security", - "spaces", - "management", "licenseManagement", + "management", "maps", - "usageCollection", - "dashboard", - "charts" + "security", + "spaces", + "usageCollection" ], "server": true, "ui": true, "requiredBundles": [ - "esUiShared", - "kibanaUtils", - "kibanaReact", + "charts", "dashboard", - "savedObjects", + "esUiShared", + "fieldFormats", "home", + "kibanaReact", + "kibanaUtils", "maps", - "usageCollection", - "fieldFormats", - "charts" + "savedObjects", + "usageCollection" ], "extraPublicDirs": [ "common" diff --git a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx index e24242c3ca7f..aec95f51b52c 100644 --- a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx @@ -8,23 +8,24 @@ import React, { FC, useCallback, useMemo } from 'react'; import { EuiCheckbox, htmlIdGenerator } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; - -export interface CheckboxShowChartsProps { - showCharts: boolean; - setShowCharts: (update: boolean) => void; -} +import useObservable from 'react-use/lib/useObservable'; +import { useAnomalyExplorerContext } from '../../../explorer/anomaly_explorer_context'; /* * React component for a checkbox element to toggle charts display. */ -export const CheckboxShowCharts: FC = ({ showCharts, setShowCharts }) => { - const onChange = useCallback( - (e: React.ChangeEvent) => { - setShowCharts(e.target.checked); - }, - [setShowCharts] +export const CheckboxShowCharts: FC = () => { + const { anomalyExplorerCommonStateService } = useAnomalyExplorerContext(); + + const showCharts = useObservable( + anomalyExplorerCommonStateService.getShowCharts$(), + anomalyExplorerCommonStateService.getShowCharts() ); + const onChange = useCallback((e: React.ChangeEvent) => { + anomalyExplorerCommonStateService.setShowCharts(e.target.checked); + }, []); + const id = useMemo(() => htmlIdGenerator()(), []); return ( diff --git a/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx b/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx index f1ef62ddc90d..75a51d439a73 100644 --- a/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx @@ -55,7 +55,11 @@ function optionValueToInterval(value: string) { export const TABLE_INTERVAL_DEFAULT = optionValueToInterval('auto'); export const useTableInterval = (): [TableInterval, (v: TableInterval) => void] => { - return usePageUrlState('mlSelectInterval', TABLE_INTERVAL_DEFAULT); + const [interval, updateCallback] = usePageUrlState( + 'mlSelectInterval', + TABLE_INTERVAL_DEFAULT + ); + return [interval, updateCallback]; }; /* diff --git a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx index 340964269215..5aea43a9c815 100644 --- a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx @@ -82,7 +82,8 @@ export function optionValueToThreshold(value: number) { const TABLE_SEVERITY_DEFAULT = SEVERITY_OPTIONS[0]; export const useTableSeverity = (): [TableSeverity, (v: TableSeverity) => void] => { - return usePageUrlState('mlSelectSeverity', TABLE_SEVERITY_DEFAULT); + const [severity, updateCallback] = usePageUrlState('mlSelectSeverity', TABLE_SEVERITY_DEFAULT); + return [severity, updateCallback]; }; export const getSeverityOptions = () => diff --git a/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx b/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx index 9ed7714dd805..dfd20359ca62 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx @@ -71,7 +71,6 @@ export const ColumnChart: FC = ({ )}
    = ({ spacesApi, spaceIds, jobId, jobType, refresh }) => { +const modelObjectNoun = i18n.translate('xpack.ml.management.jobsSpacesList.modelObjectNoun', { + defaultMessage: 'trained model', +}); + +export const JobSpacesList: FC = ({ spacesApi, spaceIds, id, jobType, refresh }) => { const { displayErrorToast } = useToastNotificationService(); const [showFlyout, setShowFlyout] = useState(false); @@ -45,13 +50,18 @@ export const JobSpacesList: FC = ({ spacesApi, spaceIds, jobId, jobType, const spacesToRemove = spacesToAdd.includes(ALL_SPACES_ID) ? [] : spacesToMaybeRemove; if (spacesToAdd.length || spacesToRemove.length) { - const resp = await ml.savedObjects.updateJobsSpaces( - jobType, - [jobId], - spacesToAdd, - spacesToRemove - ); - handleApplySpaces(resp); + if (jobType === 'trained-model') { + const resp = await ml.savedObjects.updateModelsSpaces([id], spacesToAdd, spacesToRemove); + handleApplySpaces(resp); + } else { + const resp = await ml.savedObjects.updateJobsSpaces( + jobType, + [id], + spacesToAdd, + spacesToRemove + ); + handleApplySpaces(resp); + } } onClose(); } @@ -62,11 +72,11 @@ export const JobSpacesList: FC = ({ spacesApi, spaceIds, jobId, jobType, } function handleApplySpaces(resp: SavedObjectResult) { - Object.entries(resp).forEach(([id, { success, error }]) => { + Object.entries(resp).forEach(([errorId, { success, error }]) => { if (success === false) { const title = i18n.translate('xpack.ml.management.jobsSpacesList.updateSpaces.error', { defaultMessage: 'Error updating {id}', - values: { id }, + values: { id: errorId }, }); displayErrorToast(error, title); } @@ -80,11 +90,11 @@ export const JobSpacesList: FC = ({ spacesApi, spaceIds, jobId, jobType, const shareToSpaceFlyoutProps: ShareToSpaceFlyoutProps = { savedObjectTarget: { - type: ML_SAVED_OBJECT_TYPE, - id: jobId, + type: ML_JOB_SAVED_OBJECT_TYPE, + id, namespaces: spaceIds, - title: jobId, - noun: objectNoun, + title: id, + noun: jobType === 'trained-model' ? modelObjectNoun : jobObjectNoun, }, behaviorContext: 'outside-space', changeSpacesHandler, diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_sync/job_spaces_sync_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_sync/job_spaces_sync_flyout.tsx index 2131219deffd..8b379dfbc7ac 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_sync/job_spaces_sync_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/job_spaces_sync/job_spaces_sync_flyout.tsx @@ -24,7 +24,7 @@ import { } from '@elastic/eui'; import { ml } from '../../services/ml_api_service'; -import { SyncSavedObjectResponse, SavedObjectResult } from '../../../../common/types/saved_objects'; +import { SyncSavedObjectResponse, SyncResult } from '../../../../common/types/saved_objects'; import { SyncList } from './sync_list'; import { useToastNotificationService } from '../../services/toast_notification_service'; @@ -74,7 +74,7 @@ export const JobSpacesSyncFlyout: FC = ({ onClose }) => { const { successCount, errorCount } = getResponseCounts(resp); if (errorCount > 0) { const title = i18n.translate('xpack.ml.management.syncSavedObjectsFlyout.sync.error', { - defaultMessage: 'Some jobs cannot be synchronized.', + defaultMessage: 'Some jobs or trained models cannot be synchronized.', }); displayErrorToast(resp as any, title); return; @@ -83,7 +83,7 @@ export const JobSpacesSyncFlyout: FC = ({ onClose }) => { displaySuccessToast( i18n.translate('xpack.ml.management.syncSavedObjectsFlyout.sync.success', { defaultMessage: - '{successCount} {successCount, plural, one {job} other {jobs}} synchronized', + '{successCount} {successCount, plural, one {item} other {items}} synchronized', values: { successCount }, }) ); @@ -153,13 +153,15 @@ export const JobSpacesSyncFlyout: FC = ({ onClose }) => { function getResponseCounts(resp: SyncSavedObjectResponse) { let successCount = 0; let errorCount = 0; - Object.values(resp).forEach((result: SavedObjectResult) => { - Object.values(result).forEach(({ success, error }) => { - if (success === true) { - successCount++; - } else if (error !== undefined) { - errorCount++; - } + Object.values(resp).forEach((result: SyncResult) => { + Object.values(result).forEach((type) => { + Object.values(type).forEach(({ success, error }) => { + if (success === true) { + successCount++; + } else if (error !== undefined) { + errorCount++; + } + }); }); }); return { successCount, errorCount }; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_sync/sync_list.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_sync/sync_list.tsx index bdf80db21364..8d724f5b6187 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_sync/sync_list.tsx +++ b/x-pack/plugins/ml/public/application/components/job_spaces_sync/sync_list.tsx @@ -5,12 +5,19 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { FC, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiText, EuiTitle, EuiAccordion, EuiTextColor, EuiHorizontalRule } from '@elastic/eui'; +import { + EuiText, + EuiTitle, + EuiAccordion, + EuiTextColor, + EuiHorizontalRule, + EuiSpacer, +} from '@elastic/eui'; -import { SyncSavedObjectResponse } from '../../../../common/types/saved_objects'; +import type { SyncSavedObjectResponse, SyncResult } from '../../../../common/types/saved_objects'; export const SyncList: FC<{ syncItems: SyncSavedObjectResponse | null }> = ({ syncItems }) => { if (syncItems === null) { @@ -39,17 +46,17 @@ export const SyncList: FC<{ syncItems: SyncSavedObjectResponse | null }> = ({ sy }; const SavedObjectsCreated: FC<{ syncItems: SyncSavedObjectResponse }> = ({ syncItems }) => { - const items = Object.keys(syncItems.savedObjectsCreated); + const count = getTotalItemsCount(syncItems.savedObjectsCreated); const title = ( <>

    - +

    @@ -66,21 +73,23 @@ const SavedObjectsCreated: FC<{ syncItems: SyncSavedObjectResponse }> = ({ syncI ); - return ; + return ( + + ); }; const SavedObjectsDeleted: FC<{ syncItems: SyncSavedObjectResponse }> = ({ syncItems }) => { - const items = Object.keys(syncItems.savedObjectsDeleted); + const count = getTotalItemsCount(syncItems.savedObjectsDeleted); const title = ( <>

    - +

    @@ -97,21 +106,23 @@ const SavedObjectsDeleted: FC<{ syncItems: SyncSavedObjectResponse }> = ({ syncI ); - return ; + return ( + + ); }; const DatafeedsAdded: FC<{ syncItems: SyncSavedObjectResponse }> = ({ syncItems }) => { - const items = Object.keys(syncItems.datafeedsAdded); + const count = getTotalItemsCount(syncItems.datafeedsAdded); const title = ( <>

    - +

    @@ -128,21 +139,21 @@ const DatafeedsAdded: FC<{ syncItems: SyncSavedObjectResponse }> = ({ syncItems ); - return ; + return ; }; const DatafeedsRemoved: FC<{ syncItems: SyncSavedObjectResponse }> = ({ syncItems }) => { - const items = Object.keys(syncItems.datafeedsRemoved); + const count = getTotalItemsCount(syncItems.datafeedsRemoved); const title = ( <>

    - +

    @@ -159,21 +170,35 @@ const DatafeedsRemoved: FC<{ syncItems: SyncSavedObjectResponse }> = ({ syncItem ); - return ; + return ; }; -const SyncItem: FC<{ id: string; title: JSX.Element; items: string[] }> = ({ +const SyncItem: FC<{ id: string; title: JSX.Element; results: SyncResult }> = ({ id, title, - items, -}) => ( - - -
      - {items.map((item) => ( -
    • {item}
    • - ))} -
    -
    -
    -); + results, +}) => { + return ( + + {Object.entries(results).map(([type, items]) => { + return ( + + +

    {type}

    +
      + {Object.keys(items).map((item) => ( +
    • {item}
    • + ))} +
    +
    + +
    + ); + })} +
    + ); +}; + +function getTotalItemsCount(result: SyncResult) { + return Object.values(result).flatMap((r) => Object.keys(r)).length; +} diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx index 498a27834d05..930592761887 100644 --- a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx @@ -230,7 +230,7 @@ export const RevertModelSnapshotFlyout: FC = ({ fadeChart={true} overlayRanges={[ { - start: currentSnapshot.latest_record_time_stamp, + start: currentSnapshot.latest_record_time_stamp!, end: job.data_counts.latest_record_timestamp!, color: '#ff0000', }, @@ -253,7 +253,7 @@ export const RevertModelSnapshotFlyout: FC = ({ @@ -333,7 +333,7 @@ export const RevertModelSnapshotFlyout: FC = ({ void; forceRefresh?: boolean; } @@ -55,14 +55,14 @@ export const SavedObjectsWarning: FC = ({ jobType, onCloseFlyout, forceRe mounted.current = false; }; }, - [forceRefresh, mounted] + [forceRefresh, mounted, checkStatus] ); const onClose = useCallback(() => { + setShowSyncFlyout(false); if (forceRefresh === undefined) { checkStatus(); } - setShowSyncFlyout(false); if (typeof onCloseFlyout === 'function') { onCloseFlyout(); } @@ -83,7 +83,7 @@ export const SavedObjectsWarning: FC = ({ jobType, onCloseFlyout, forceRe title={ } color="warning" @@ -93,7 +93,7 @@ export const SavedObjectsWarning: FC = ({ jobType, onCloseFlyout, forceRe <> {canCreateJob ? ( diff --git a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts index 63d5855d96f4..87c331be855e 100644 --- a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts @@ -10,7 +10,7 @@ import { isEqual } from 'lodash'; import useObservable from 'react-use/lib/useObservable'; import { forkJoin, of, Observable, Subject } from 'rxjs'; -import { mergeMap, switchMap, tap, map } from 'rxjs/operators'; +import { switchMap, tap, map } from 'rxjs/operators'; import { useCallback, useMemo } from 'react'; import { explorerService } from '../explorer_dashboard_service'; @@ -31,14 +31,12 @@ import { ExplorerState } from '../reducers'; import { useMlKibana, useTimefilter } from '../../contexts/kibana'; import { AnomalyTimelineService } from '../../services/anomaly_timeline_service'; import { MlResultsService, mlResultsServiceProvider } from '../../services/results_service'; -import { isViewBySwimLaneData } from '../swimlane_container'; -import { ANOMALY_SWIM_LANE_HARD_LIMIT } from '../explorer_constants'; import { TimefilterContract } from '../../../../../../../src/plugins/data/public'; import { AnomalyExplorerChartsService } from '../../services/anomaly_explorer_charts_service'; -import { CombinedJob } from '../../../../common/types/anomaly_detection_jobs'; -import { InfluencersFilterQuery } from '../../../../common/types/es_client'; +import type { CombinedJob } from '../../../../common/types/anomaly_detection_jobs'; +import type { InfluencersFilterQuery } from '../../../../common/types/es_client'; import { mlJobService } from '../../services/job_service'; -import { TimeBucketsInterval } from '../../util/time_buckets'; +import type { TimeBucketsInterval, TimeRangeBounds } from '../../util/time_buckets'; // Memoize the data fetching methods. // wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument @@ -53,18 +51,20 @@ const wrapWithLastRefreshArg = any>(func: T, context }; }; const memoize = any>(func: T, context?: any) => { - return memoizeOne(wrapWithLastRefreshArg(func, context) as any, memoizeIsEqual); + return memoizeOne(wrapWithLastRefreshArg(func, context) as any, memoizeIsEqual) as ( + lastRefresh: number, + ...args: Parameters + ) => ReturnType; }; -const memoizedLoadOverallAnnotations = - memoize(loadOverallAnnotations); +const memoizedLoadOverallAnnotations = memoize(loadOverallAnnotations); + +const memoizedLoadAnnotationsTableData = memoize(loadAnnotationsTableData); + +const memoizedLoadFilteredTopInfluencers = memoize(loadFilteredTopInfluencers); -const memoizedLoadAnnotationsTableData = - memoize(loadAnnotationsTableData); -const memoizedLoadFilteredTopInfluencers = memoize( - loadFilteredTopInfluencers -); const memoizedLoadTopInfluencers = memoize(loadTopInfluencers); + const memoizedLoadAnomaliesTableData = memoize(loadAnomaliesTableData); export interface LoadExplorerDataConfig { @@ -78,10 +78,7 @@ export interface LoadExplorerDataConfig { tableInterval: string; tableSeverity: number; viewBySwimlaneFieldName: string; - viewByFromPage: number; - viewByPerPage: number; swimlaneContainerWidth: number; - swimLaneSeverity: number; } export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfig => { @@ -102,14 +99,6 @@ const loadExplorerDataProvider = ( anomalyExplorerChartsService: AnomalyExplorerChartsService, timefilter: TimefilterContract ) => { - const memoizedLoadOverallData = memoize( - anomalyTimelineService.loadOverallData, - anomalyTimelineService - ); - const memoizedLoadViewBySwimlane = memoize( - anomalyTimelineService.loadViewBySwimlane, - anomalyTimelineService - ); const memoizedAnomalyDataChange = memoize( anomalyExplorerChartsService.getAnomalyData, anomalyExplorerChartsService @@ -127,14 +116,10 @@ const loadExplorerDataProvider = ( selectedCells, selectedJobs, swimlaneBucketInterval, - swimlaneLimit, tableInterval, tableSeverity, viewBySwimlaneFieldName, swimlaneContainerWidth, - viewByFromPage, - viewByPerPage, - swimLaneSeverity, } = config; const combinedJobRecords: Record = selectedJobs.reduce((acc, job) => { @@ -144,7 +129,7 @@ const loadExplorerDataProvider = ( const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneFieldName); const jobIds = getSelectionJobIds(selectedCells, selectedJobs); - const bounds = timefilter.getBounds(); + const bounds = timefilter.getBounds() as Required; const timerange = getSelectionTimeRange( selectedCells, @@ -155,8 +140,9 @@ const loadExplorerDataProvider = ( const dateFormatTz = getDateFormatTz(); const interval = swimlaneBucketInterval.asSeconds(); + // First get the data where we have all necessary args at hand using forkJoin: - // annotationsData, anomalyChartRecords, influencers, overallState, tableData, topFieldValues + // annotationsData, anomalyChartRecords, influencers, overallState, tableData return forkJoin({ overallAnnotations: memoizedLoadOverallAnnotations( lastRefresh, @@ -192,13 +178,6 @@ const loadExplorerDataProvider = ( influencersFilterQuery ) : Promise.resolve({}), - overallState: memoizedLoadOverallData( - lastRefresh, - selectedJobs, - swimlaneContainerWidth, - undefined, - swimLaneSeverity - ), tableData: memoizedLoadAnomaliesTableData( lastRefresh, selectedCells, @@ -211,27 +190,8 @@ const loadExplorerDataProvider = ( tableSeverity, influencersFilterQuery ), - topFieldValues: - selectedCells !== undefined && selectedCells.showTopFieldValues === true - ? anomalyTimelineService.loadViewByTopFieldValuesForSelectedTime( - timerange.earliestMs, - timerange.latestMs, - selectedJobs, - viewBySwimlaneFieldName, - swimlaneLimit, - viewByPerPage, - viewByFromPage, - swimlaneContainerWidth, - selectionInfluencers, - influencersFilterQuery - ) - : Promise.resolve([]), }).pipe( - // Trigger a side-effect action to reset view-by swimlane, - // show the view-by loading indicator - // and pass on the data we already fetched. - tap(explorerService.setViewBySwimlaneLoading), - tap(({ anomalyChartRecords, topFieldValues }) => { + tap(({ anomalyChartRecords }) => { memoizedAnomalyDataChange( lastRefresh, explorerService, @@ -246,16 +206,8 @@ const loadExplorerDataProvider = ( tableSeverity ); }), - mergeMap( - ({ - overallAnnotations, - anomalyChartRecords, - influencers, - overallState, - topFieldValues, - annotationsData, - tableData, - }) => + switchMap( + ({ overallAnnotations, anomalyChartRecords, influencers, annotationsData, tableData }) => forkJoin({ filteredTopInfluencers: (selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) && @@ -273,38 +225,15 @@ const loadExplorerDataProvider = ( influencersFilterQuery ) : Promise.resolve(influencers), - viewBySwimlaneState: memoizedLoadViewBySwimlane( - lastRefresh, - topFieldValues, - { - earliest: overallState.earliest, - latest: overallState.latest, - }, - selectedJobs, - viewBySwimlaneFieldName, - ANOMALY_SWIM_LANE_HARD_LIMIT, - viewByPerPage, - viewByFromPage, - swimlaneContainerWidth, - influencersFilterQuery, - undefined, - swimLaneSeverity - ), }).pipe( - map(({ viewBySwimlaneState, filteredTopInfluencers }) => { + map(({ filteredTopInfluencers }) => { return { overallAnnotations, annotations: annotationsData, influencers: filteredTopInfluencers as any, loading: false, - viewBySwimlaneDataLoading: false, anomalyChartsDataLoading: false, - overallSwimlaneData: overallState, - viewBySwimlaneData: viewBySwimlaneState as any, tableData, - swimlaneLimit: isViewBySwimLaneData(viewBySwimlaneState) - ? viewBySwimlaneState.cardinality - : undefined, }; }) ) @@ -312,6 +241,7 @@ const loadExplorerDataProvider = ( ); }; }; + export const useExplorerData = (): [Partial | undefined, (d: any) => void] => { const timefilter = useTimefilter(); diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts new file mode 100644 index 000000000000..66c557230753 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts @@ -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 { BehaviorSubject, Observable } from 'rxjs'; +import { distinctUntilChanged, map, skipWhile } from 'rxjs/operators'; +import { isEqual } from 'lodash'; +import type { ExplorerJob } from './explorer_utils'; +import type { InfluencersFilterQuery } from '../../../common/types/es_client'; +import type { AnomalyExplorerUrlStateService } from './hooks/use_explorer_url_state'; +import type { AnomalyExplorerFilterUrlState } from '../../../common/types/locator'; +import type { KQLFilterSettings } from './components/explorer_query_bar/explorer_query_bar'; + +export interface AnomalyExplorerState { + selectedJobs: ExplorerJob[]; +} + +export type FilterSettings = Required< + Pick +> & + Pick; + +/** + * Anomaly Explorer common state. + * Manages related values in the URL state and applies required formatting. + */ +export class AnomalyExplorerCommonStateService { + private _selectedJobs$ = new BehaviorSubject(undefined); + private _filterSettings$ = new BehaviorSubject(this._getDefaultFilterSettings()); + private _showCharts$ = new BehaviorSubject(true); + + private _getDefaultFilterSettings(): FilterSettings { + return { + filterActive: false, + filteredFields: [], + queryString: '', + influencersFilterQuery: undefined, + }; + } + + constructor(private anomalyExplorerUrlStateService: AnomalyExplorerUrlStateService) { + this._init(); + } + + private _init() { + this.anomalyExplorerUrlStateService + .getPageUrlState$() + .pipe( + map((urlState) => urlState?.mlExplorerFilter), + distinctUntilChanged(isEqual) + ) + .subscribe((v) => { + const result = { + ...this._getDefaultFilterSettings(), + ...v, + }; + this._filterSettings$.next(result); + }); + + this.anomalyExplorerUrlStateService + .getPageUrlState$() + .pipe( + map((urlState) => urlState?.mlShowCharts ?? true), + distinctUntilChanged() + ) + .subscribe(this._showCharts$); + } + + public setSelectedJobs(explorerJobs: ExplorerJob[] | undefined) { + this._selectedJobs$.next(explorerJobs); + } + + public getSelectedJobs$(): Observable { + return this._selectedJobs$.pipe( + skipWhile((v) => !v || !v.length), + distinctUntilChanged(isEqual) + ); + } + + public getSelectedJobs(): ExplorerJob[] | undefined { + return this._selectedJobs$.getValue(); + } + + public getInfluencerFilterQuery$(): Observable { + return this._filterSettings$.pipe( + map((v) => v?.influencersFilterQuery), + distinctUntilChanged(isEqual) + ); + } + + public getFilterSettings$(): Observable { + return this._filterSettings$.asObservable(); + } + + public getFilterSettings(): FilterSettings { + return this._filterSettings$.getValue(); + } + + public setFilterSettings(update: KQLFilterSettings) { + this.anomalyExplorerUrlStateService.updateUrlState({ + mlExplorerFilter: { + influencersFilterQuery: update.filterQuery, + filterActive: true, + filteredFields: update.filteredFields, + queryString: update.queryString, + }, + }); + } + + public clearFilterSettings() { + this.anomalyExplorerUrlStateService.updateUrlState({ mlExplorerFilter: {} }); + } + + public getShowCharts$(): Observable { + return this._showCharts$.asObservable(); + } + + public getShowCharts(): boolean { + return this._showCharts$.getValue(); + } + + public setShowCharts(update: boolean) { + this.anomalyExplorerUrlStateService.updateUrlState({ mlShowCharts: update }); + } +} diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx new file mode 100644 index 000000000000..f0d175e49dda --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useContext, useMemo } from 'react'; +import { AnomalyTimelineStateService } from './anomaly_timeline_state_service'; +import { AnomalyExplorerCommonStateService } from './anomaly_explorer_common_state'; +import { useMlKibana, useTimefilter } from '../contexts/kibana'; +import { mlResultsServiceProvider } from '../services/results_service'; +import { AnomalyTimelineService } from '../services/anomaly_timeline_service'; +import type { AnomalyExplorerUrlStateService } from './hooks/use_explorer_url_state'; + +export type AnomalyExplorerContextValue = + | { + anomalyExplorerCommonStateService: AnomalyExplorerCommonStateService; + anomalyTimelineStateService: AnomalyTimelineStateService; + } + | undefined; + +/** + * Context of the Anomaly Explorer page. + */ +export const AnomalyExplorerContext = React.createContext(undefined); + +/** + * Hook for consuming {@link AnomalyExplorerContext}. + */ +export function useAnomalyExplorerContext(): + | Exclude + | never { + const context = useContext(AnomalyExplorerContext); + + if (context === undefined) { + throw new Error('AnomalyExplorerContext has not been initialized.'); + } + + return context; +} + +/** + * Creates Anomaly Explorer context. + */ +export function useAnomalyExplorerContextValue( + anomalyExplorerUrlStateService: AnomalyExplorerUrlStateService +): Exclude { + const timefilter = useTimefilter(); + + const { + services: { + mlServices: { mlApiServices }, + uiSettings, + }, + } = useMlKibana(); + + const mlResultsService = useMemo(() => mlResultsServiceProvider(mlApiServices), []); + + const anomalyTimelineService = useMemo(() => { + return new AnomalyTimelineService(timefilter, uiSettings, mlResultsService); + }, []); + + return useMemo(() => { + const anomalyExplorerCommonStateService = new AnomalyExplorerCommonStateService( + anomalyExplorerUrlStateService + ); + + return { + anomalyExplorerCommonStateService, + anomalyTimelineStateService: new AnomalyTimelineStateService( + anomalyExplorerCommonStateService, + anomalyTimelineService, + timefilter + ), + }; + }, []); +} diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx index 36c86af989a4..b8deedf3bd36 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -12,24 +12,22 @@ import { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, - EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiPopover, EuiSelect, EuiSpacer, + EuiText, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import useDebounce from 'react-use/lib/useDebounce'; +import useObservable from 'react-use/lib/useObservable'; import { OVERALL_LABEL, SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from './explorer_constants'; import { AddSwimlaneToDashboardControl } from './dashboard_controls/add_swimlane_to_dashboard_controls'; import { useMlKibana } from '../contexts/kibana'; -import { TimeBuckets } from '../util/time_buckets'; -import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; -import { explorerService } from './explorer_dashboard_service'; import { ExplorerState } from './reducers/explorer_reducer'; import { ExplorerNoInfluencersFound } from './components/explorer_no_influencers_found/explorer_no_influencers_found'; import { SwimlaneContainer } from './swimlane_container'; @@ -41,6 +39,10 @@ import { isDefined } from '../../../common/types/guards'; import { MlTooltipComponent } from '../components/chart_tooltip'; import { SwimlaneAnnotationContainer } from './swimlane_annotation_container'; import { AnomalyTimelineService } from '../services/anomaly_timeline_service'; +import { useAnomalyExplorerContext } from './anomaly_explorer_context'; +import { useTimeBuckets } from '../components/custom_hooks/use_time_buckets'; +import { formatHumanReadableDateTime } from '../../../common/util/date_utils'; +import { getTimeBoundsFromSelection } from './hooks/use_selected_cells'; function mapSwimlaneOptionsToEuiOptions(options: string[]) { return options.map((option) => ({ @@ -51,58 +53,89 @@ function mapSwimlaneOptionsToEuiOptions(options: string[]) { interface AnomalyTimelineProps { explorerState: ExplorerState; - setSelectedCells: (cells?: any) => void; } export const AnomalyTimeline: FC = React.memo( - ({ explorerState, setSelectedCells }) => { + ({ explorerState }) => { const { services: { - uiSettings, application: { capabilities }, }, } = useMlKibana(); + const { anomalyExplorerCommonStateService, anomalyTimelineStateService } = + useAnomalyExplorerContext(); + + const setSelectedCells = anomalyTimelineStateService.setSelectedCells.bind( + anomalyTimelineStateService + ); + const [isMenuOpen, setIsMenuOpen] = useState(false); const [isAddDashboardsActive, setIsAddDashboardActive] = useState(false); const canEditDashboards = capabilities.dashboard?.createNew ?? false; - const timeBuckets = useMemo(() => { - return new TimeBuckets({ - 'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), - 'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), - dateFormat: uiSettings.get('dateFormat'), - 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), - }); - }, [uiSettings]); + const timeBuckets = useTimeBuckets(); - const { - filterActive, - selectedCells, - viewByLoadedForTimeFormatted, - viewBySwimlaneDataLoading, - viewBySwimlaneFieldName, - viewBySwimlaneOptions, - selectedJobs, - viewByFromPage, - viewByPerPage, - swimlaneLimit, - loading, - overallAnnotations, - swimLaneSeverity, - overallSwimlaneData, - viewBySwimlaneData, - swimlaneContainerWidth, - } = explorerState; - - const [severityUpdate, setSeverityUpdate] = useState(swimLaneSeverity); + const { overallAnnotations } = explorerState; + + const { filterActive } = useObservable( + anomalyExplorerCommonStateService.getFilterSettings$(), + anomalyExplorerCommonStateService.getFilterSettings() + ); + + const swimlaneLimit = useObservable(anomalyTimelineStateService.getSwimLaneCardinality$()); + + const selectedJobs = useObservable(anomalyExplorerCommonStateService.getSelectedJobs$()); + + const loading = useObservable(anomalyTimelineStateService.isOverallSwimLaneLoading$(), true); + + const swimlaneContainerWidth = useObservable( + anomalyTimelineStateService.getContainerWidth$(), + anomalyTimelineStateService.getContainerWidth() + ); + const viewBySwimlaneDataLoading = useObservable( + anomalyTimelineStateService.isViewBySwimLaneLoading$(), + true + ); + + const overallSwimlaneData = useObservable( + anomalyTimelineStateService.getOverallSwimLaneData$() + ); + + const viewBySwimlaneData = useObservable(anomalyTimelineStateService.getViewBySwimLaneData$()); + const selectedCells = useObservable(anomalyTimelineStateService.getSelectedCells$()); + const swimLaneSeverity = useObservable(anomalyTimelineStateService.getSwimLaneSeverity$()); + const viewBySwimlaneFieldName = useObservable( + anomalyTimelineStateService.getViewBySwimlaneFieldName$() + ); + + const viewBySwimlaneOptions = useObservable( + anomalyTimelineStateService.getViewBySwimLaneOptions$(), + anomalyTimelineStateService.getViewBySwimLaneOptions() + ); + + const { viewByPerPage, viewByFromPage } = useObservable( + anomalyTimelineStateService.getSwimLanePagination$(), + anomalyTimelineStateService.getSwimLanePagination() + ); + + const [severityUpdate, setSeverityUpdate] = useState( + anomalyTimelineStateService.getSwimLaneSeverity() + ); + + const timeRange = getTimeBoundsFromSelection(selectedCells); + + const viewByLoadedForTimeFormatted = timeRange + ? `${formatHumanReadableDateTime(timeRange.earliestMs)} - ${formatHumanReadableDateTime( + timeRange.latestMs + )}` + : null; useDebounce( () => { if (severityUpdate === swimLaneSeverity) return; - - explorerService.setSwimLaneSeverity(severityUpdate!); + anomalyTimelineStateService.setSeverity(severityUpdate!); }, 500, [severityUpdate, swimLaneSeverity] @@ -154,6 +187,10 @@ export const AnomalyTimeline: FC = React.memo( [overallSwimlaneData] ); + const onResize = useCallback((value: number) => { + anomalyTimelineStateService.setContainerWidth(value); + }, []); + return ( <> @@ -213,7 +250,9 @@ export const AnomalyTimeline: FC = React.memo( id="selectViewBy" options={mapSwimlaneOptionsToEuiOptions(viewBySwimlaneOptions)} value={viewBySwimlaneFieldName} - onChange={(e) => explorerService.setViewBySwimlaneFieldName(e.target.value)} + onChange={(e) => { + anomalyTimelineStateService.setViewBySwimLaneFieldName(e.target.value); + }} /> @@ -255,21 +294,18 @@ export const AnomalyTimeline: FC = React.memo( )}
    - - {selectedCells ? ( - - - - - - ) : null} + + + + + @@ -278,7 +314,7 @@ export const AnomalyTimeline: FC = React.memo( {(tooltipService) => ( = React.memo( swimlaneType={SWIMLANE_TYPE.OVERALL} selection={overallCellSelection} onCellsSelection={setSelectedCells} - onResize={explorerService.setSwimlaneContainerWidth} + onResize={onResize} isLoading={loading} noDataWarning={ - - - - } - /> + +
    + +
    +
    } showTimeline={false} showLegend={false} @@ -327,42 +359,42 @@ export const AnomalyTimeline: FC = React.memo( swimlaneType={SWIMLANE_TYPE.VIEW_BY} selection={selectedCells} onCellsSelection={setSelectedCells} - onResize={explorerService.setSwimlaneContainerWidth} + onResize={onResize} fromPage={viewByFromPage} perPage={viewByPerPage} swimlaneLimit={swimlaneLimit} onPaginationChange={({ perPage: perPageUpdate, fromPage: fromPageUpdate }) => { if (perPageUpdate) { - explorerService.setViewByPerPage(perPageUpdate); + anomalyTimelineStateService.setSwimLanePagination({ + viewByPerPage: perPageUpdate, + }); } if (fromPageUpdate) { - explorerService.setViewByFromPage(fromPageUpdate); + anomalyTimelineStateService.setSwimLanePagination({ + viewByFromPage: fromPageUpdate, + }); } }} isLoading={loading || viewBySwimlaneDataLoading} noDataWarning={ - - {typeof viewBySwimlaneFieldName === 'string' ? ( - viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL ? ( - - ) : ( - - ) - ) : null} - - } - /> + +
    + {typeof viewBySwimlaneFieldName === 'string' ? ( + viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL ? ( + + ) : ( + + ) + ) : null} +
    +
    } /> )} diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts new file mode 100644 index 000000000000..19dab0be1ff9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts @@ -0,0 +1,717 @@ +/* + * Copyright 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 { BehaviorSubject, combineLatest, from, Observable, of } from 'rxjs'; +import { + switchMap, + map, + skipWhile, + distinctUntilChanged, + startWith, + tap, + debounceTime, +} from 'rxjs/operators'; +import { isEqual, sortBy, uniq } from 'lodash'; +import { AnomalyTimelineService } from '../services/anomaly_timeline_service'; +import type { + AppStateSelectedCells, + ExplorerJob, + OverallSwimlaneData, + ViewBySwimLaneData, +} from './explorer_utils'; +import type { AnomalyExplorerCommonStateService } from './anomaly_explorer_common_state'; +import type { AnomalyExplorerSwimLaneUrlState } from '../../../common/types/locator'; +import type { TimefilterContract } from '../../../../../../src/plugins/data/public'; +import type { TimeRangeBounds } from '../../../../../../src/plugins/data/common'; +import { + ANOMALY_SWIM_LANE_HARD_LIMIT, + SWIMLANE_TYPE, + VIEW_BY_JOB_LABEL, +} from './explorer_constants'; +// FIXME get rid of the static import +import { mlJobService } from '../services/job_service'; +import { getSelectionInfluencers, getSelectionTimeRange } from './explorer_utils'; +import type { TimeBucketsInterval } from '../util/time_buckets'; +import { InfluencersFilterQuery } from '../../../common/types/es_client'; +// FIXME get rid of the static import +import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; +import type { Refresh } from '../routing/use_refresh'; + +interface SwimLanePagination { + viewByFromPage: number; + viewByPerPage: number; +} + +/** + * Service for managing anomaly timeline state. + */ +export class AnomalyTimelineStateService { + private _explorerURLStateCallback: + | ((update: AnomalyExplorerSwimLaneUrlState, replaceState?: boolean) => void) + | null = null; + + private _overallSwimLaneData$ = new BehaviorSubject(null); + private _viewBySwimLaneData$ = new BehaviorSubject(undefined); + + private _swimLaneUrlState$ = new BehaviorSubject< + AnomalyExplorerSwimLaneUrlState | undefined | null + >(null); + + private _containerWidth$ = new BehaviorSubject(0); + private _selectedCells$ = new BehaviorSubject(undefined); + private _swimLaneSeverity$ = new BehaviorSubject(0); + private _swimLanePaginations$ = new BehaviorSubject({ + viewByFromPage: 1, + viewByPerPage: 10, + }); + private _swimLaneCardinality$ = new BehaviorSubject(undefined); + private _viewBySwimlaneFieldName$ = new BehaviorSubject(undefined); + private _viewBySwimLaneOptions$ = new BehaviorSubject([]); + private _topFieldValues$ = new BehaviorSubject([]); + private _isOverallSwimLaneLoading$ = new BehaviorSubject(true); + private _isViewBySwimLaneLoading$ = new BehaviorSubject(true); + private _swimLaneBucketInterval$ = new BehaviorSubject(null); + + private _timeBounds$: Observable; + private _refreshSubject$: Observable; + + constructor( + private anomalyExplorerCommonStateService: AnomalyExplorerCommonStateService, + private anomalyTimelineService: AnomalyTimelineService, + private timefilter: TimefilterContract + ) { + this._timeBounds$ = this.timefilter.getTimeUpdate$().pipe( + startWith(null), + map(() => this.timefilter.getBounds()) + ); + this._refreshSubject$ = mlTimefilterRefresh$.pipe(startWith({ lastRefresh: 0 })); + this._init(); + } + + /** + * Initializes required subscriptions for fetching swim lanes data. + * @private + */ + private _init() { + this._initViewByData(); + + this._swimLaneUrlState$ + .pipe( + map((v) => v?.severity ?? 0), + distinctUntilChanged() + ) + .subscribe(this._swimLaneSeverity$); + + this._initSwimLanePagination(); + this._initOverallSwimLaneData(); + this._initTopFieldValues(); + this._initViewBySwimLaneData(); + + combineLatest([ + this.anomalyExplorerCommonStateService.getSelectedJobs$(), + this.getContainerWidth$(), + ]).subscribe(([selectedJobs, containerWidth]) => { + if (!selectedJobs) return; + this._swimLaneBucketInterval$.next( + this.anomalyTimelineService.getSwimlaneBucketInterval(selectedJobs, containerWidth!) + ); + }); + + this._initSelectedCells(); + } + + private _initViewByData(): void { + combineLatest([ + this._swimLaneUrlState$.pipe( + map((v) => v?.viewByFieldName), + distinctUntilChanged() + ), + this.anomalyExplorerCommonStateService.getSelectedJobs$(), + this.anomalyExplorerCommonStateService.getFilterSettings$(), + this._selectedCells$, + ]).subscribe(([currentlySelected, selectedJobs, filterSettings, selectedCells]) => { + const { viewBySwimlaneFieldName, viewBySwimlaneOptions } = this._getViewBySwimlaneOptions( + currentlySelected, + filterSettings.filterActive, + filterSettings.filteredFields as string[], + false, + selectedCells, + selectedJobs + ); + this._viewBySwimlaneFieldName$.next(viewBySwimlaneFieldName); + this._viewBySwimLaneOptions$.next(viewBySwimlaneOptions); + }); + } + + private _initSwimLanePagination() { + combineLatest([ + this._swimLaneUrlState$.pipe( + map((v) => { + return { + viewByFromPage: v?.viewByFromPage ?? 1, + viewByPerPage: v?.viewByPerPage ?? 10, + }; + }), + distinctUntilChanged(isEqual) + ), + this.anomalyExplorerCommonStateService.getInfluencerFilterQuery$(), + this._timeBounds$, + ]).subscribe(([pagination, influencersFilerQuery]) => { + let resultPaginaiton: SwimLanePagination = pagination; + if (influencersFilerQuery) { + resultPaginaiton = { viewByPerPage: pagination.viewByPerPage, viewByFromPage: 1 }; + } + this._swimLanePaginations$.next(resultPaginaiton); + }); + } + + private _initOverallSwimLaneData() { + combineLatest([ + this.anomalyExplorerCommonStateService.getSelectedJobs$(), + this._swimLaneSeverity$, + this.getContainerWidth$(), + this._timeBounds$, + this._refreshSubject$, + ]) + .pipe( + tap(() => { + this._isOverallSwimLaneLoading$.next(true); + }), + switchMap(([selectedJobs, severity, containerWidth]) => { + return from( + this.anomalyTimelineService.loadOverallData( + selectedJobs!, + containerWidth, + undefined, + severity + ) + ); + }) + ) + .subscribe((v) => { + this._overallSwimLaneData$.next(v); + this._isOverallSwimLaneLoading$.next(false); + }); + } + + private _initTopFieldValues() { + ( + combineLatest([ + this.anomalyExplorerCommonStateService.getSelectedJobs$(), + this.anomalyExplorerCommonStateService.getInfluencerFilterQuery$(), + this.getViewBySwimlaneFieldName$(), + this.getSwimLanePagination$(), + this.getSwimLaneCardinality$(), + this.getContainerWidth$(), + this.getSelectedCells$(), + this.getSwimLaneBucketInterval$(), + this._timeBounds$, + this._refreshSubject$, + ]) as Observable< + [ + ExplorerJob[], + InfluencersFilterQuery, + string, + SwimLanePagination, + number, + number, + AppStateSelectedCells, + TimeBucketsInterval + ] + > + ) + .pipe( + switchMap( + ([ + selectedJobs, + influencersFilterQuery, + viewBySwimlaneFieldName, + swimLanePagination, + swimLaneCardinality, + swimlaneContainerWidth, + selectedCells, + swimLaneBucketInterval, + ]) => { + if (!selectedCells?.showTopFieldValues) { + return of([]); + } + + const selectionInfluencers = getSelectionInfluencers( + selectedCells, + viewBySwimlaneFieldName + ); + + const timerange = getSelectionTimeRange( + selectedCells, + swimLaneBucketInterval.asSeconds(), + this.timefilter.getBounds() + ); + + return from( + this.anomalyTimelineService.loadViewByTopFieldValuesForSelectedTime( + timerange.earliestMs, + timerange.latestMs, + selectedJobs, + viewBySwimlaneFieldName!, + ANOMALY_SWIM_LANE_HARD_LIMIT, + swimLanePagination.viewByPerPage, + swimLanePagination.viewByFromPage, + swimlaneContainerWidth, + selectionInfluencers, + influencersFilterQuery + ) + ); + } + ) + ) + .subscribe(this._topFieldValues$); + } + + private _initViewBySwimLaneData() { + combineLatest([ + this._overallSwimLaneData$.pipe(skipWhile((v) => !v)), + this.anomalyExplorerCommonStateService.getSelectedJobs$(), + this.anomalyExplorerCommonStateService.getInfluencerFilterQuery$(), + this._swimLaneSeverity$, + this.getContainerWidth$(), + this.getViewBySwimlaneFieldName$(), + this.getSwimLanePagination$(), + this._topFieldValues$.pipe(distinctUntilChanged(isEqual)), + this._timeBounds$, + this._refreshSubject$, + ]) + .pipe( + tap(() => { + this._isViewBySwimLaneLoading$.next(true); + }), + switchMap( + ([ + overallSwimLaneData, + selectedJobs, + influencersFilterQuery, + severity, + swimlaneContainerWidth, + viewBySwimlaneFieldName, + swimLanePagination, + topFieldValues, + ]) => { + return from( + this.anomalyTimelineService.loadViewBySwimlane( + topFieldValues, + { + earliest: overallSwimLaneData!.earliest, + latest: overallSwimLaneData!.latest, + }, + selectedJobs, + viewBySwimlaneFieldName, + ANOMALY_SWIM_LANE_HARD_LIMIT, + swimLanePagination.viewByPerPage, + swimLanePagination.viewByFromPage, + swimlaneContainerWidth, + influencersFilterQuery, + undefined, + severity + ) + ); + } + ) + ) + .subscribe((v) => { + this._viewBySwimLaneData$.next(v); + this._isViewBySwimLaneLoading$.next(false); + this._swimLaneCardinality$.next(v?.cardinality); + }); + } + + private _initSelectedCells() { + combineLatest([ + this._viewBySwimlaneFieldName$, + this._swimLaneUrlState$, + this.getSwimLaneBucketInterval$(), + this._timeBounds$, + ]) + .pipe( + map(([viewByFieldName, swimLaneUrlState, swimLaneBucketInterval]) => { + if (!swimLaneUrlState?.selectedType) { + return; + } + + let times: AnomalyExplorerSwimLaneUrlState['selectedTimes'] = + swimLaneUrlState.selectedTimes ?? swimLaneUrlState.selectedTime!; + if (typeof times === 'number') { + times = [times, times + swimLaneBucketInterval!.asSeconds()]; + } + + let lanes = swimLaneUrlState.selectedLanes ?? swimLaneUrlState.selectedLane!; + + if (typeof lanes === 'string') { + lanes = [lanes]; + } + + times = this._getAdjustedTimeSelection(times, this.timefilter.getBounds()); + + if (!times) { + return; + } + + return { + type: swimLaneUrlState.selectedType, + lanes, + times, + showTopFieldValues: swimLaneUrlState.showTopFieldValues, + viewByFieldName, + } as AppStateSelectedCells; + }), + distinctUntilChanged(isEqual) + ) + .subscribe(this._selectedCells$); + } + + /** + * Adjust cell selection with respect to the time boundaries. + * @return adjusted time selection or undefined if out of current range entirely. + */ + private _getAdjustedTimeSelection( + times: AppStateSelectedCells['times'], + timeBounds: TimeRangeBounds + ): AppStateSelectedCells['times'] | undefined { + const [selectedFrom, selectedTo] = times; + + /** + * Because each cell on the swim lane represent the fixed bucket interval, + * the selection range could be out of the time boundaries with + * correction within the bucket interval. + */ + const bucketSpanInterval = this.getSwimLaneBucketInterval()!.asSeconds(); + + const rangeFrom = timeBounds.min!.unix() - bucketSpanInterval; + const rangeTo = timeBounds.max!.unix() + bucketSpanInterval; + + const resultFrom = Math.max(selectedFrom, rangeFrom); + const resultTo = Math.min(selectedTo, rangeTo); + + const isSelectionOutOfRange = rangeFrom > resultTo || rangeTo < resultFrom; + + if (isSelectionOutOfRange) { + // reset selection + return; + } + + if (selectedFrom === resultFrom && selectedTo === resultTo) { + // selection is correct, no need to adjust the range + return times; + } + + if (resultFrom !== rangeFrom || resultTo !== rangeTo) { + return [resultFrom, resultTo]; + } + } + + /** + * Obtain the list of 'View by' fields per job and viewBySwimlaneFieldName + * @private + * + * TODO check for possible enhancements/refactoring. Has been moved from explorer_utils as-is. + */ + private _getViewBySwimlaneOptions( + currentViewBySwimlaneFieldName: string | undefined, + filterActive: boolean, + filteredFields: string[], + isAndOperator: boolean, + selectedCells: AppStateSelectedCells | undefined, + selectedJobs: ExplorerJob[] | undefined + ) { + const selectedJobIds = selectedJobs?.map((d) => d.id) ?? []; + + // Unique influencers for the selected job(s). + const viewByOptions: string[] = sortBy( + uniq( + mlJobService.jobs.reduce((reducedViewByOptions, job) => { + if (selectedJobIds.some((jobId) => jobId === job.job_id)) { + return reducedViewByOptions.concat(job.analysis_config.influencers || []); + } + return reducedViewByOptions; + }, [] as string[]) + ), + (fieldName) => fieldName.toLowerCase() + ); + + viewByOptions.push(VIEW_BY_JOB_LABEL); + let viewBySwimlaneOptions = viewByOptions; + let viewBySwimlaneFieldName: string | undefined; + + if (viewBySwimlaneOptions.indexOf(currentViewBySwimlaneFieldName!) !== -1) { + // Set the swim lane viewBy to that stored in the state (URL) if set. + // This means we reset it to the current state because it was set by the listener + // on initialization. + viewBySwimlaneFieldName = currentViewBySwimlaneFieldName; + } else { + if (selectedJobIds.length > 1) { + // If more than one job selected, default to job ID. + viewBySwimlaneFieldName = VIEW_BY_JOB_LABEL; + } else if (mlJobService.jobs.length > 0 && selectedJobIds.length > 0) { + // For a single job, default to the first partition, over, + // by or influencer field of the first selected job. + const firstSelectedJob = mlJobService.jobs.find((job) => { + return job.job_id === selectedJobIds[0]; + }); + + const firstJobInfluencers = firstSelectedJob?.analysis_config.influencers ?? []; + firstSelectedJob?.analysis_config.detectors.forEach((detector) => { + if ( + detector.partition_field_name !== undefined && + firstJobInfluencers.indexOf(detector.partition_field_name) !== -1 + ) { + viewBySwimlaneFieldName = detector.partition_field_name; + return false; + } + + if ( + detector.over_field_name !== undefined && + firstJobInfluencers.indexOf(detector.over_field_name) !== -1 + ) { + viewBySwimlaneFieldName = detector.over_field_name; + return false; + } + + // For jobs with by and over fields, don't add the 'by' field as this + // field will only be added to the top-level fields for record type results + // if it also an influencer over the bucket. + if ( + detector.by_field_name !== undefined && + detector.over_field_name === undefined && + firstJobInfluencers.indexOf(detector.by_field_name) !== -1 + ) { + viewBySwimlaneFieldName = detector.by_field_name; + return false; + } + }); + + if (viewBySwimlaneFieldName === undefined) { + if (firstJobInfluencers.length > 0) { + viewBySwimlaneFieldName = firstJobInfluencers[0]; + } else { + // No influencers for first selected job - set to first available option. + viewBySwimlaneFieldName = + viewBySwimlaneOptions.length > 0 ? viewBySwimlaneOptions[0] : undefined; + } + } + } + } + + // filter View by options to relevant filter fields + // If it's an AND filter only show job Id view by as the rest will have no results + if (filterActive === true && isAndOperator === true && !selectedCells) { + viewBySwimlaneOptions = [VIEW_BY_JOB_LABEL]; + } else if ( + filterActive === true && + Array.isArray(viewBySwimlaneOptions) && + Array.isArray(filteredFields) + ) { + const filteredOptions = viewBySwimlaneOptions.filter((option) => { + return ( + filteredFields.includes(option) || + option === VIEW_BY_JOB_LABEL || + (selectedCells && selectedCells.viewByFieldName === option) + ); + }); + // only replace viewBySwimlaneOptions with filteredOptions if we found a relevant matching field + if (filteredOptions.length > 1) { + viewBySwimlaneOptions = filteredOptions; + if (!viewBySwimlaneOptions.includes(viewBySwimlaneFieldName!)) { + viewBySwimlaneFieldName = viewBySwimlaneOptions[0]; + } + } + } + + return { + viewBySwimlaneFieldName, + viewBySwimlaneOptions, + }; + } + + /** + * Provides overall swim lane data. + */ + public getOverallSwimLaneData$(): Observable { + return this._overallSwimLaneData$.asObservable(); + } + + public getViewBySwimLaneData$(): Observable { + return this._viewBySwimLaneData$.asObservable(); + } + + public getContainerWidth$(): Observable { + return this._containerWidth$.pipe( + debounceTime(500), + distinctUntilChanged((prev, curr) => { + const delta = Math.abs(prev - curr); + // Scrollbar appears during the page rendering, + // it causes small width change that we want to ignore. + return delta < 20; + }) + ); + } + + public getContainerWidth(): number | undefined { + return this._containerWidth$.getValue(); + } + + /** + * Provides updates for swim lanes cells selection. + */ + public getSelectedCells$(): Observable { + return this._selectedCells$.asObservable(); + } + + public getSwimLaneSeverity$(): Observable { + return this._swimLaneSeverity$.asObservable(); + } + + public getSwimLaneSeverity(): number | undefined { + return this._swimLaneSeverity$.getValue(); + } + + public getSwimLanePagination$(): Observable { + return this._swimLanePaginations$.asObservable(); + } + + public getSwimLanePagination(): SwimLanePagination { + return this._swimLanePaginations$.getValue(); + } + + public setSwimLanePagination(update: Partial) { + const resultUpdate = update; + if (resultUpdate.viewByPerPage) { + resultUpdate.viewByFromPage = 1; + } + this._explorerURLStateCallback!(resultUpdate); + } + + public getSwimLaneCardinality$(): Observable { + return this._swimLaneCardinality$.pipe(distinctUntilChanged()); + } + + public getViewBySwimlaneFieldName$(): Observable { + return this._viewBySwimlaneFieldName$.pipe(distinctUntilChanged()); + } + + public getViewBySwimLaneOptions$(): Observable { + return this._viewBySwimLaneOptions$.asObservable(); + } + + public getViewBySwimLaneOptions(): string[] { + return this._viewBySwimLaneOptions$.getValue(); + } + + public isOverallSwimLaneLoading$(): Observable { + return this._isOverallSwimLaneLoading$.asObservable(); + } + + public isViewBySwimLaneLoading$(): Observable { + return this._isViewBySwimLaneLoading$.asObservable(); + } + + /** + * Updates internal subject from the URL state. + * @param value + */ + public updateFromUrlState(value: AnomalyExplorerSwimLaneUrlState | undefined) { + this._swimLaneUrlState$.next(value); + } + + /** + * Updates callback for setting URL app state. + * @param callback + */ + public updateSetStateCallback(callback: (update: AnomalyExplorerSwimLaneUrlState) => void) { + this._explorerURLStateCallback = callback; + } + + /** + * Sets container width + * @param value + */ + public setContainerWidth(value: number) { + this._containerWidth$.next(value); + } + + /** + * Sets swim lanes severity. + * Updates the URL state. + * @param value + */ + public setSeverity(value: number) { + this._explorerURLStateCallback!({ severity: value, viewByFromPage: 1 }); + } + + /** + * Sets selected cells. + * @param swimLaneSelectedCells + */ + public setSelectedCells(swimLaneSelectedCells?: AppStateSelectedCells) { + const vall = this._swimLaneUrlState$.getValue(); + + const mlExplorerSwimlane = { + ...vall, + } as AnomalyExplorerSwimLaneUrlState; + + if (swimLaneSelectedCells !== undefined) { + swimLaneSelectedCells.showTopFieldValues = false; + + const currentSwimlaneType = this._selectedCells$.getValue()?.type; + const currentShowTopFieldValues = this._selectedCells$.getValue()?.showTopFieldValues; + const newSwimlaneType = swimLaneSelectedCells?.type; + + if ( + (currentSwimlaneType === SWIMLANE_TYPE.OVERALL && + newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) || + newSwimlaneType === SWIMLANE_TYPE.OVERALL || + currentShowTopFieldValues === true + ) { + swimLaneSelectedCells.showTopFieldValues = true; + } + + mlExplorerSwimlane.selectedType = swimLaneSelectedCells.type; + mlExplorerSwimlane.selectedLanes = swimLaneSelectedCells.lanes; + mlExplorerSwimlane.selectedTimes = swimLaneSelectedCells.times; + mlExplorerSwimlane.showTopFieldValues = swimLaneSelectedCells.showTopFieldValues; + + this._explorerURLStateCallback!(mlExplorerSwimlane); + } else { + delete mlExplorerSwimlane.selectedType; + delete mlExplorerSwimlane.selectedLanes; + delete mlExplorerSwimlane.selectedTimes; + delete mlExplorerSwimlane.showTopFieldValues; + + this._explorerURLStateCallback!(mlExplorerSwimlane, true); + } + } + + /** + * Updates View by swim lane value. + * @param fieldName - Influencer field name of job id. + */ + public setViewBySwimLaneFieldName(fieldName: string) { + this._explorerURLStateCallback!( + { + viewByFromPage: 1, + viewByPerPage: this._swimLanePaginations$.getValue().viewByPerPage, + viewByFieldName: fieldName, + }, + true + ); + } + + public getSwimLaneBucketInterval$(): Observable { + return this._swimLaneBucketInterval$.pipe(skipWhile((v) => !v)); + } + + public getSwimLaneBucketInterval(): TimeBucketsInterval | null { + return this._swimLaneBucketInterval$.getValue(); + } +} diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/index.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/index.js rename to x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/index.ts diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.tsx similarity index 87% rename from x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js rename to x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.tsx index 5eee341af686..39975d05d132 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.tsx @@ -5,16 +5,23 @@ * 2.0. */ -/* - * React component for rendering EuiEmptyPrompt when no results were found. - */ - -import React from 'react'; +import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiEmptyPrompt } from '@elastic/eui'; -export const ExplorerNoResultsFound = ({ hasResults, selectedJobsRunning }) => { +export interface ExplorerNoResultsFoundProps { + hasResults: boolean; + selectedJobsRunning: boolean; +} + +/* + * React component for rendering EuiEmptyPrompt when no results were found. + */ +export const ExplorerNoResultsFound: FC = ({ + hasResults, + selectedJobsRunning, +}) => { const resultsHaveNoAnomalies = hasResults === true; const noResults = hasResults === false; return ( diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/index.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/index.js rename to x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/index.ts diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_query_bar/explorer_query_bar.tsx b/x-pack/plugins/ml/public/application/explorer/components/explorer_query_bar/explorer_query_bar.tsx index f57d2c1b01d9..afdef906c416 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_query_bar/explorer_query_bar.tsx +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_query_bar/explorer_query_bar.tsx @@ -10,22 +10,30 @@ import { EuiCode, EuiInputPopover } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { fromKueryExpression, luceneStringToDsl, toElasticsearchQuery } from '@kbn/es-query'; import { Query, QueryStringInput } from '../../../../../../../../src/plugins/data/public'; -import { DataView } from '../../../../../../../../src/plugins/data_views/common'; +import type { DataView } from '../../../../../../../../src/plugins/data_views/common'; import { SEARCH_QUERY_LANGUAGE, ErrorMessage } from '../../../../../common/constants/search'; -import { explorerService } from '../../explorer_dashboard_service'; import { InfluencersFilterQuery } from '../../../../../common/types/es_client'; +import { useAnomalyExplorerContext } from '../../anomaly_explorer_context'; export const DEFAULT_QUERY_LANG = SEARCH_QUERY_LANGUAGE.KUERY; +export interface KQLFilterSettings { + filterQuery: InfluencersFilterQuery; + queryString: string; + tableQueryString: string; + isAndOperator: boolean; + filteredFields: string[]; +} + export function getKqlQueryValues({ inputString, queryLanguage, indexPattern, }: { - inputString: string | { [key: string]: any }; + inputString: string | { [key: string]: unknown }; queryLanguage: string; indexPattern: DataView; -}): { clearSettings: boolean; settings: any } { +}): { clearSettings: boolean; settings: KQLFilterSettings } { let influencersFilterQuery: InfluencersFilterQuery = {}; const filteredFields: string[] = []; const ast = fromKueryExpression(inputString); @@ -58,8 +66,8 @@ export function getKqlQueryValues({ clearSettings, settings: { filterQuery: influencersFilterQuery, - queryString: inputString, - tableQueryString: inputString, + queryString: inputString as string, + tableQueryString: inputString as string, isAndOperator, filteredFields, }, @@ -88,7 +96,7 @@ function getInitSearchInputState({ interface ExplorerQueryBarProps { filterActive: boolean; - filterPlaceHolder: string; + filterPlaceHolder?: string; indexPattern: DataView; queryString?: string; updateLanguage: (language: string) => void; @@ -101,6 +109,8 @@ export const ExplorerQueryBar: FC = ({ queryString, updateLanguage, }) => { + const { anomalyExplorerCommonStateService } = useAnomalyExplorerContext(); + // The internal state of the input query bar updated on every key stroke. const [searchInput, setSearchInput] = useState( getInitSearchInputState({ filterActive, queryString }) @@ -130,9 +140,9 @@ export const ExplorerQueryBar: FC = ({ }); if (clearSettings === true) { - explorerService.clearInfluencerFilterSettings(); + anomalyExplorerCommonStateService.clearFilterSettings(); } else { - explorerService.setInfluencerFilterSettings(settings); + anomalyExplorerCommonStateService.setFilterSettings(settings); } } catch (e) { console.log('Invalid query syntax in search bar', e); // eslint-disable-line no-console diff --git a/x-pack/plugins/ml/public/application/explorer/components/index.js b/x-pack/plugins/ml/public/application/explorer/components/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/explorer/components/index.js rename to x-pack/plugins/ml/public/application/explorer/components/index.ts diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer.d.ts deleted file mode 100644 index 44238d4d5acf..000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer.d.ts +++ /dev/null @@ -1,19 +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 { FC } from 'react'; -import { ExplorerState } from './reducers'; -import { AppStateSelectedCells } from './explorer_utils'; - -declare interface ExplorerProps { - explorerState: ExplorerState; - severity: number; - showCharts: boolean; - setSelectedCells: (swimlaneSelectedCells: AppStateSelectedCells) => void; -} - -export const Explorer: FC; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js deleted file mode 100644 index b96cd164e3dc..000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ /dev/null @@ -1,520 +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. - */ - -/* - * React component for rendering Explorer dashboard swimlanes. - */ - -import PropTypes from 'prop-types'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { - htmlIdGenerator, - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiIconTip, - EuiPageHeader, - EuiPageHeaderSection, - EuiSpacer, - EuiTitle, - EuiLoadingContent, - EuiPanel, - EuiAccordion, - EuiBadge, -} from '@elastic/eui'; - -import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; -import { AnnotationsTable } from '../components/annotations/annotations_table'; -import { ExplorerNoJobsSelected, ExplorerNoResultsFound } from './components'; -import { InfluencersList } from '../components/influencers_list'; -import { explorerService } from './explorer_dashboard_service'; -import { AnomalyResultsViewSelector } from '../components/anomaly_results_view_selector'; -import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts'; -import { JobSelector } from '../components/job_selector'; -import { SelectInterval } from '../components/controls/select_interval/select_interval'; -import { SelectSeverity } from '../components/controls/select_severity/select_severity'; -import { - ExplorerQueryBar, - getKqlQueryValues, - DEFAULT_QUERY_LANG, -} from './components/explorer_query_bar/explorer_query_bar'; -import { - getDateFormatTz, - removeFilterFromQueryString, - getQueryPattern, - escapeParens, - escapeDoubleQuotes, -} from './explorer_utils'; -import { AnomalyTimeline } from './anomaly_timeline'; - -import { FILTER_ACTION } from './explorer_constants'; - -// Explorer Charts -import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container'; - -// Anomalies Table -import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; - -// Anomalies Map -import { AnomaliesMap } from './anomalies_map'; - -import { getToastNotifications } from '../util/dependency_cache'; -import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { ML_APP_LOCATOR } from '../../../common/constants/locator'; -import { AnomalyContextMenu } from './anomaly_context_menu'; -import { isDefined } from '../../../common/types/guards'; -import { MlPageHeader } from '../components/page_header'; - -const ExplorerPage = ({ - children, - jobSelectorProps, - noInfluencersConfigured, - influencers, - filterActive, - filterPlaceHolder, - indexPattern, - queryString, - updateLanguage, -}) => ( - <> - - - - - - - - - - - - - - - {noInfluencersConfigured === false && influencers !== undefined ? ( - <> - - - - - ) : null} - - - {children} - -); - -export class ExplorerUI extends React.Component { - static propTypes = { - explorerState: PropTypes.object.isRequired, - setSelectedCells: PropTypes.func.isRequired, - severity: PropTypes.number.isRequired, - showCharts: PropTypes.bool.isRequired, - selectedJobsRunning: PropTypes.bool.isRequired, - }; - - state = { language: DEFAULT_QUERY_LANG }; - htmlIdGen = htmlIdGenerator(); - - componentDidMount() { - const { invalidTimeRangeError } = this.props; - if (invalidTimeRangeError) { - const toastNotifications = getToastNotifications(); - toastNotifications.addWarning( - i18n.translate('xpack.ml.explorer.invalidTimeRangeInUrlCallout', { - defaultMessage: - 'The time filter was changed to the full range due to an invalid default time filter. Check the advanced settings for {field}.', - values: { - field: ANOMALY_DETECTION_DEFAULT_TIME_RANGE, - }, - }) - ); - } - } - - // Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes - // and will cause a syntax error when called with getKqlQueryValues - applyFilter = (fieldName, fieldValue, action) => { - const { filterActive, indexPattern, queryString } = this.props.explorerState; - let newQueryString = ''; - const operator = 'and '; - const sanitizedFieldName = escapeParens(fieldName); - const sanitizedFieldValue = escapeDoubleQuotes(fieldValue); - - if (action === FILTER_ACTION.ADD) { - // Don't re-add if already exists in the query - const queryPattern = getQueryPattern(fieldName, fieldValue); - if (queryString.match(queryPattern) !== null) { - return; - } - newQueryString = `${ - queryString ? `${queryString} ${operator}` : '' - }${sanitizedFieldName}:"${sanitizedFieldValue}"`; - } else if (action === FILTER_ACTION.REMOVE) { - if (filterActive === false) { - return; - } else { - newQueryString = removeFilterFromQueryString( - queryString, - sanitizedFieldName, - sanitizedFieldValue - ); - } - } - - try { - const { clearSettings, settings } = getKqlQueryValues({ - inputString: `${newQueryString}`, - queryLanguage: this.state.language, - indexPattern, - }); - - if (clearSettings === true) { - explorerService.clearInfluencerFilterSettings(); - } else { - explorerService.setInfluencerFilterSettings(settings); - } - } catch (e) { - console.log('Invalid query syntax from table', e); // eslint-disable-line no-console - - const toastNotifications = getToastNotifications(); - toastNotifications.addDanger( - i18n.translate('xpack.ml.explorer.invalidKuerySyntaxErrorMessageFromTable', { - defaultMessage: - 'Invalid syntax in query bar. The input must be valid Kibana Query Language (KQL)', - }) - ); - } - }; - - updateLanguage = (language) => this.setState({ language }); - - render() { - const { share, charts: chartsService } = this.props.kibana.services; - - const mlLocator = share.url.locators.get(ML_APP_LOCATOR); - - const { - showCharts, - severity, - stoppedPartitions, - selectedJobsRunning, - timefilter, - timeBuckets, - } = this.props; - - const { - annotations, - chartsData, - filterActive, - filterPlaceHolder, - indexPattern, - influencers, - loading, - noInfluencersConfigured, - overallSwimlaneData, - queryString, - selectedCells, - selectedJobs, - tableData, - swimLaneSeverity, - } = this.props.explorerState; - const { annotationsData, totalCount: allAnnotationsCnt, error: annotationsError } = annotations; - - const annotationsCnt = Array.isArray(annotationsData) ? annotationsData.length : 0; - const badge = - allAnnotationsCnt > annotationsCnt ? ( - - - - ) : ( - - - - ); - - const jobSelectorProps = { - dateFormatTz: getDateFormatTz(), - }; - - const noJobsSelected = selectedJobs === null || selectedJobs.length === 0; - const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0; - const hasResultsWithAnomalies = - (hasResults && overallSwimlaneData.points.some((v) => v.value > 0)) || - tableData.anomalies?.length > 0; - - const hasActiveFilter = isDefined(swimLaneSeverity); - - if (noJobsSelected && !loading) { - return ( - - - - ); - } - - if (!hasResultsWithAnomalies && !loading && !hasActiveFilter) { - return ( - - - - ); - } - const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; - const mainColumnClasses = `column ${mainColumnWidthClassName}`; - - const bounds = timefilter.getActiveBounds(); - const selectedJobIds = Array.isArray(selectedJobs) ? selectedJobs.map((job) => job.id) : []; - return ( - -
    - {noInfluencersConfigured && ( -
    -
    - )} - - {noInfluencersConfigured === false && ( -
    - - -

    - - - } - position="right" - /> -

    -
    - {loading ? ( - - ) : ( - - )} -
    - )} - -
    - - {stoppedPartitions && ( - - } - /> - )} - - - - - - {annotationsError !== undefined && ( - <> - -

    - -

    -
    - - -

    {annotationsError}

    -
    -
    - - - )} - {loading === false && tableData.anomalies?.length ? ( - - ) : null} - {annotationsCnt > 0 && ( - <> - - -

    - -

    - - } - > - <> - - -
    -
    - - - - )} - {loading === false && ( - - - - -

    - -

    -
    -
    - - - - -
    - - - - - - - - - {chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && ( - - - - )} - - - - -
    - {showCharts ? ( - - ) : null} -
    - - -
    - )} -
    -
    -
    - ); - } -} - -export const Explorer = withKibana(ExplorerUI); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.tsx b/x-pack/plugins/ml/public/application/explorer/explorer.tsx new file mode 100644 index 000000000000..3d9c23b97de0 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer.tsx @@ -0,0 +1,547 @@ +/* + * Copyright 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, useCallback, useEffect, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + htmlIdGenerator, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIconTip, + EuiPageHeader, + EuiPageHeaderSection, + EuiSpacer, + EuiTitle, + EuiLoadingContent, + EuiPanel, + EuiAccordion, + EuiBadge, +} from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; +import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; +// @ts-ignore +import { AnnotationsTable } from '../components/annotations/annotations_table'; +import { ExplorerNoJobsSelected, ExplorerNoResultsFound } from './components'; +import { InfluencersList } from '../components/influencers_list'; +import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts'; +import { JobSelector } from '../components/job_selector'; +import { SelectInterval } from '../components/controls/select_interval/select_interval'; +import { SelectSeverity } from '../components/controls/select_severity/select_severity'; +import { + ExplorerQueryBar, + getKqlQueryValues, + DEFAULT_QUERY_LANG, +} from './components/explorer_query_bar/explorer_query_bar'; +import { + getDateFormatTz, + removeFilterFromQueryString, + getQueryPattern, + escapeParens, + escapeDoubleQuotes, + OverallSwimlaneData, + AppStateSelectedCells, +} from './explorer_utils'; +import { AnomalyTimeline } from './anomaly_timeline'; +import { FILTER_ACTION, FilterAction } from './explorer_constants'; +// Explorer Charts +// @ts-ignore +import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container'; +// Anomalies Table +// @ts-ignore +import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; +// Anomalies Map +import { AnomaliesMap } from './anomalies_map'; +import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; +import { AnomalyContextMenu } from './anomaly_context_menu'; +import { isDefined } from '../../../common/types/guards'; +import type { DataView } from '../../../../../../src/plugins/data_views/common'; +import type { JobSelectorProps } from '../components/job_selector/job_selector'; +import type { ExplorerState } from './reducers'; +import type { TimefilterContract } from '../../../../../../src/plugins/data/public'; +import type { TimeBuckets } from '../util/time_buckets'; +import { useToastNotificationService } from '../services/toast_notification_service'; +import { useMlKibana, useMlLocator } from '../contexts/kibana'; +import { useAnomalyExplorerContext } from './anomaly_explorer_context'; + +interface ExplorerPageProps { + jobSelectorProps: JobSelectorProps; + noInfluencersConfigured?: boolean; + influencers?: ExplorerState['influencers']; + filterActive?: boolean; + filterPlaceHolder?: string; + indexPattern?: DataView; + queryString?: string; + updateLanguage?: (language: string) => void; +} + +const ExplorerPage: FC = ({ + children, + jobSelectorProps, + noInfluencersConfigured, + influencers, + filterActive, + filterPlaceHolder, + indexPattern, + queryString, + updateLanguage, +}) => ( + <> + + + + + {noInfluencersConfigured === false && + influencers !== undefined && + indexPattern && + updateLanguage ? ( + <> + + + + + ) : null} + + + {children} + +); + +interface ExplorerUIProps { + explorerState: ExplorerState; + severity: number; + showCharts: boolean; + selectedJobsRunning: boolean; + overallSwimlaneData: OverallSwimlaneData | null; + invalidTimeRangeError?: boolean; + stoppedPartitions?: string[]; + // TODO Remove + timefilter: TimefilterContract; + // TODO Remove + timeBuckets: TimeBuckets; + selectedCells: AppStateSelectedCells | undefined; + swimLaneSeverity?: number; +} + +export const Explorer: FC = ({ + invalidTimeRangeError, + showCharts, + severity, + stoppedPartitions, + selectedJobsRunning, + timefilter, + timeBuckets, + selectedCells, + swimLaneSeverity, + explorerState, + overallSwimlaneData, +}) => { + const { displayWarningToast, displayDangerToast } = useToastNotificationService(); + const { anomalyTimelineStateService, anomalyExplorerCommonStateService } = + useAnomalyExplorerContext(); + + const htmlIdGen = useMemo(() => htmlIdGenerator(), []); + + const [language, updateLanguage] = useState(DEFAULT_QUERY_LANG); + + const filterSettings = useObservable( + anomalyExplorerCommonStateService.getFilterSettings$(), + anomalyExplorerCommonStateService.getFilterSettings() + ); + + const selectedJobs = useObservable( + anomalyExplorerCommonStateService.getSelectedJobs$(), + anomalyExplorerCommonStateService.getSelectedJobs() + ); + + const applyFilter = useCallback( + (fieldName: string, fieldValue: string, action: FilterAction) => { + const { filterActive, queryString } = filterSettings; + + const indexPattern = explorerState.indexPattern; + + let newQueryString = ''; + const operator = 'and '; + const sanitizedFieldName = escapeParens(fieldName); + const sanitizedFieldValue = escapeDoubleQuotes(fieldValue); + + if (action === FILTER_ACTION.ADD) { + // Don't re-add if already exists in the query + const queryPattern = getQueryPattern(fieldName, fieldValue); + if (queryString.match(queryPattern) !== null) { + return; + } + newQueryString = `${ + queryString ? `${queryString} ${operator}` : '' + }${sanitizedFieldName}:"${sanitizedFieldValue}"`; + } else if (action === FILTER_ACTION.REMOVE) { + if (filterActive === false) { + return; + } else { + newQueryString = removeFilterFromQueryString( + queryString, + sanitizedFieldName, + sanitizedFieldValue + ); + } + } + + try { + const { clearSettings, settings } = getKqlQueryValues({ + inputString: `${newQueryString}`, + queryLanguage: language, + indexPattern: indexPattern as DataView, + }); + + if (clearSettings === true) { + anomalyExplorerCommonStateService.clearFilterSettings(); + } else { + anomalyExplorerCommonStateService.setFilterSettings(settings); + } + } catch (e) { + console.log('Invalid query syntax from table', e); // eslint-disable-line no-console + + displayDangerToast( + i18n.translate('xpack.ml.explorer.invalidKuerySyntaxErrorMessageFromTable', { + defaultMessage: + 'Invalid syntax in query bar. The input must be valid Kibana Query Language (KQL)', + }) + ); + } + }, + [explorerState, language, filterSettings] + ); + + useEffect(() => { + if (invalidTimeRangeError) { + displayWarningToast( + i18n.translate('xpack.ml.explorer.invalidTimeRangeInUrlCallout', { + defaultMessage: + 'The time filter was changed to the full range due to an invalid default time filter. Check the advanced settings for {field}.', + values: { + field: ANOMALY_DETECTION_DEFAULT_TIME_RANGE, + }, + }) + ); + } + }, []); + + const { + services: { charts: chartsService }, + } = useMlKibana(); + + const mlLocator = useMlLocator(); + + const { + annotations, + chartsData, + filterPlaceHolder, + indexPattern, + influencers, + loading, + noInfluencersConfigured, + tableData, + } = explorerState; + + const { filterActive, queryString } = filterSettings; + + const isOverallSwimLaneLoading = useObservable( + anomalyTimelineStateService.isOverallSwimLaneLoading$(), + true + ); + const isViewBySwimLaneLoading = useObservable( + anomalyTimelineStateService.isViewBySwimLaneLoading$(), + true + ); + + const isDataLoading = loading || isOverallSwimLaneLoading || isViewBySwimLaneLoading; + + const swimLaneBucketInterval = useObservable( + anomalyTimelineStateService.getSwimLaneBucketInterval$(), + anomalyTimelineStateService.getSwimLaneBucketInterval() + ); + + const { annotationsData, totalCount: allAnnotationsCnt, error: annotationsError } = annotations; + + const annotationsCnt = Array.isArray(annotationsData) ? annotationsData.length : 0; + const badge = + (allAnnotationsCnt ?? 0) > annotationsCnt ? ( + + + + ) : ( + + + + ); + + const jobSelectorProps = { + dateFormatTz: getDateFormatTz(), + } as JobSelectorProps; + + const noJobsSelected = !selectedJobs || selectedJobs.length === 0; + const hasResults: boolean = + !!overallSwimlaneData?.points && overallSwimlaneData.points.length > 0; + const hasResultsWithAnomalies = + (hasResults && overallSwimlaneData!.points.some((v) => v.value > 0)) || + tableData.anomalies?.length > 0; + + const hasActiveFilter = isDefined(swimLaneSeverity); + + if (noJobsSelected && !loading) { + return ( + + + + ); + } + + if (!hasResultsWithAnomalies && !isDataLoading && !hasActiveFilter) { + return ( + + + + ); + } + const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; + const mainColumnClasses = `column ${mainColumnWidthClassName}`; + + const bounds = timefilter.getActiveBounds(); + const selectedJobIds = Array.isArray(selectedJobs) ? selectedJobs.map((job) => job.id) : []; + + return ( + +
    + {noInfluencersConfigured && ( +
    +
    + )} + + {noInfluencersConfigured === false && ( +
    + + +

    + + + } + position="right" + /> +

    +
    + {loading ? ( + + ) : ( + + )} +
    + )} + +
    + + {stoppedPartitions && ( + + } + /> + )} + + + + + + {annotationsError !== undefined && ( + <> + +

    + +

    +
    + + +

    {annotationsError}

    +
    +
    + + + )} + {loading === false && tableData.anomalies?.length ? ( + + ) : null} + {annotationsCnt > 0 && ( + <> + + +

    + +

    + + } + > + <> + + +
    +
    + + + + )} + {loading === false && ( + + + + +

    + +

    +
    +
    + + + + +
    + + + + + + + + + {chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && ( + + + + )} + + + + +
    + {showCharts ? ( + + ) : null} +
    + + +
    + )} +
    +
    +
    + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts index cd01de31e5e6..0a8f61fb80ff 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -25,22 +25,14 @@ export const EXPLORER_ACTION = { SET_CHARTS: 'setCharts', SET_CHARTS_DATA_LOADING: 'setChartsDataLoading', SET_EXPLORER_DATA: 'setExplorerData', - SET_FILTER_DATA: 'setFilterData', - SET_INFLUENCER_FILTER_SETTINGS: 'setInfluencerFilterSettings', - SET_SELECTED_CELLS: 'setSelectedCells', - SET_SWIMLANE_CONTAINER_WIDTH: 'setSwimlaneContainerWidth', - SET_VIEW_BY_SWIMLANE_FIELD_NAME: 'setViewBySwimlaneFieldName', - SET_VIEW_BY_SWIMLANE_LOADING: 'setViewBySwimlaneLoading', - SET_VIEW_BY_PER_PAGE: 'setViewByPerPage', - SET_VIEW_BY_FROM_PAGE: 'setViewByFromPage', - SET_SWIM_LANE_SEVERITY: 'setSwimLaneSeverity', - SET_SHOW_CHARTS: 'setShowCharts', }; export const FILTER_ACTION = { ADD: '+', REMOVE: '-', -}; +} as const; + +export type FilterAction = typeof FILTER_ACTION[keyof typeof FILTER_ACTION]; export const SWIMLANE_TYPE = { OVERALL: 'overall', diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts index deb1beed2d14..0517f80e2742 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts @@ -11,20 +11,13 @@ */ import { isEqual } from 'lodash'; - import { from, isObservable, Observable, Subject } from 'rxjs'; -import { distinctUntilChanged, flatMap, map, scan, shareReplay } from 'rxjs/operators'; - +import { distinctUntilChanged, flatMap, scan, shareReplay } from 'rxjs/operators'; import { DeepPartial } from '../../../common/types/common'; - import { jobSelectionActionCreator } from './actions'; -import { ExplorerChartsData } from './explorer_charts/explorer_charts_container_service'; +import type { ExplorerChartsData } from './explorer_charts/explorer_charts_container_service'; import { EXPLORER_ACTION } from './explorer_constants'; -import { AppStateSelectedCells } from './explorer_utils'; import { explorerReducer, getExplorerDefaultState, ExplorerState } from './reducers'; -import { ExplorerAppState } from '../../../common/types/locator'; - -export const ALLOW_CELL_RANGE_SELECTION = true; type ExplorerAction = Action | Observable; export const explorerAction$ = new Subject(); @@ -51,67 +44,13 @@ const explorerState$: Observable = explorerFilteredAction$.pipe( shareReplay(1) ); -const explorerAppState$: Observable = explorerState$.pipe( - map((state: ExplorerState): ExplorerAppState => { - const appState: ExplorerAppState = { - mlExplorerFilter: {}, - mlExplorerSwimlane: {}, - }; - - if (state.selectedCells !== undefined) { - const swimlaneSelectedCells = state.selectedCells; - appState.mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type; - appState.mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; - appState.mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; - appState.mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; - } - - if (state.viewBySwimlaneFieldName !== undefined) { - appState.mlExplorerSwimlane.viewByFieldName = state.viewBySwimlaneFieldName; - } - - if (state.viewByFromPage !== undefined) { - appState.mlExplorerSwimlane.viewByFromPage = state.viewByFromPage; - } - - if (state.viewByPerPage !== undefined) { - appState.mlExplorerSwimlane.viewByPerPage = state.viewByPerPage; - } - - if (state.swimLaneSeverity !== undefined) { - appState.mlExplorerSwimlane.severity = state.swimLaneSeverity; - } - - if (state.showCharts !== undefined) { - appState.mlShowCharts = state.showCharts; - } - - if (state.filterActive) { - appState.mlExplorerFilter.influencersFilterQuery = state.influencersFilterQuery; - appState.mlExplorerFilter.filterActive = state.filterActive; - appState.mlExplorerFilter.filteredFields = state.filteredFields; - appState.mlExplorerFilter.queryString = state.queryString; - } - - return appState; - }), - distinctUntilChanged(isEqual) -); - const setExplorerDataActionCreator = (payload: DeepPartial) => ({ type: EXPLORER_ACTION.SET_EXPLORER_DATA, payload, }); -const setFilterDataActionCreator = ( - payload: Partial> -) => ({ - type: EXPLORER_ACTION.SET_FILTER_DATA, - payload, -}); // Export observable state and action dispatchers as service export const explorerService = { - appState$: explorerAppState$, state$: explorerState$, clearExplorerData: () => { explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_EXPLORER_DATA }); @@ -128,51 +67,12 @@ export const explorerService = { setCharts: (payload: ExplorerChartsData) => { explorerAction$.next({ type: EXPLORER_ACTION.SET_CHARTS, payload }); }, - setInfluencerFilterSettings: (payload: any) => { - explorerAction$.next({ - type: EXPLORER_ACTION.SET_INFLUENCER_FILTER_SETTINGS, - payload, - }); - }, - setSelectedCells: (payload: AppStateSelectedCells | undefined) => { - explorerAction$.next({ - type: EXPLORER_ACTION.SET_SELECTED_CELLS, - payload, - }); - }, setExplorerData: (payload: DeepPartial) => { explorerAction$.next(setExplorerDataActionCreator(payload)); }, - setFilterData: (payload: Partial>) => { - explorerAction$.next(setFilterDataActionCreator(payload)); - }, setChartsDataLoading: () => { explorerAction$.next({ type: EXPLORER_ACTION.SET_CHARTS_DATA_LOADING }); }, - setSwimlaneContainerWidth: (payload: number) => { - explorerAction$.next({ - type: EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH, - payload, - }); - }, - setViewBySwimlaneFieldName: (payload: string) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME, payload }); - }, - setViewBySwimlaneLoading: (payload: any) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_LOADING, payload }); - }, - setViewByFromPage: (payload: number) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_FROM_PAGE, payload }); - }, - setViewByPerPage: (payload: number) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_PER_PAGE, payload }); - }, - setSwimLaneSeverity: (payload: number) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_SWIM_LANE_SEVERITY, payload }); - }, - setShowCharts: (payload: boolean) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_SHOW_CHARTS, payload }); - }, }; export type ExplorerService = typeof explorerService; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts deleted file mode 100644 index 5dba7a9f5a93..000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts +++ /dev/null @@ -1,208 +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 { AnnotationsTable } from '../../../common/types/annotations'; -import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; -import { SwimlaneType } from './explorer_constants'; -import { TimeRangeBounds } from '../util/time_buckets'; -import { RecordForInfluencer } from '../services/results_service/results_service'; -import { InfluencersFilterQuery } from '../../../common/types/es_client'; -import { MlResultsService } from '../services/results_service'; -import { EntityField } from '../../../common/util/anomaly_utils'; - -interface ClearedSelectedAnomaliesState { - selectedCells: undefined; - viewByLoadedForTimeFormatted: null; -} - -export declare const getClearedSelectedAnomaliesState: () => ClearedSelectedAnomaliesState; - -export interface SwimlanePoint { - laneLabel: string; - time: number; - value: number; -} - -export declare interface SwimlaneData { - fieldName?: string; - laneLabels: string[]; - points: SwimlanePoint[]; - interval: number; -} -interface ChartRecord extends RecordForInfluencer { - function: string; -} - -export declare interface OverallSwimlaneData extends SwimlaneData { - /** - * Earliest timestampt in seconds - */ - earliest: number; - /** - * Latest timestampt in seconds - */ - latest: number; -} - -export interface ViewBySwimLaneData extends OverallSwimlaneData { - cardinality: number; -} - -export declare const getDateFormatTz: () => any; - -export declare const getDefaultSwimlaneData: () => SwimlaneData; - -export declare const getInfluencers: (selectedJobs: any[]) => string[]; - -export declare const getSelectionJobIds: ( - selectedCells: AppStateSelectedCells | undefined, - selectedJobs: ExplorerJob[] -) => string[]; - -export declare const getSelectionInfluencers: ( - selectedCells: AppStateSelectedCells | undefined, - fieldName: string -) => EntityField[]; - -interface SelectionTimeRange { - earliestMs: number; - latestMs: number; -} - -export declare const getSelectionTimeRange: ( - selectedCells: AppStateSelectedCells | undefined, - interval: number, - bounds: TimeRangeBounds -) => SelectionTimeRange; - -export declare const getSwimlaneBucketInterval: ( - selectedJobs: ExplorerJob[], - swimlaneContainerWidth: number -) => any; - -interface ViewBySwimlaneOptionsArgs { - currentViewBySwimlaneFieldName: string | undefined; - filterActive: boolean; - filteredFields: any[]; - isAndOperator: boolean; - selectedCells: AppStateSelectedCells; - selectedJobs: ExplorerJob[]; -} - -interface ViewBySwimlaneOptions { - viewBySwimlaneFieldName: string; - viewBySwimlaneOptions: string[]; -} - -export declare const getViewBySwimlaneOptions: ( - arg: ViewBySwimlaneOptionsArgs -) => ViewBySwimlaneOptions; - -export declare interface ExplorerJob { - id: string; - selected: boolean; - bucketSpanSeconds: number; -} - -export declare const createJobs: (jobs: CombinedJob[]) => ExplorerJob[]; - -declare interface SwimlaneBounds { - earliest: number; - latest: number; -} - -export declare const loadOverallAnnotations: ( - selectedJobs: ExplorerJob[], - interval: number, - bounds: TimeRangeBounds -) => Promise; - -export declare const loadAnnotationsTableData: ( - selectedCells: AppStateSelectedCells | undefined, - selectedJobs: ExplorerJob[], - interval: number, - bounds: TimeRangeBounds -) => Promise; - -export declare interface AnomaliesTableData { - anomalies: any[]; - interval: number; - examplesByJobId: string[]; - showViewSeriesLink: boolean; - jobIds: string[]; -} - -export declare const loadAnomaliesTableData: ( - selectedCells: AppStateSelectedCells | undefined, - selectedJobs: ExplorerJob[], - dateFormatTz: any, - interval: number, - bounds: TimeRangeBounds, - fieldName: string, - tableInterval: string, - tableSeverity: number, - influencersFilterQuery: InfluencersFilterQuery -) => Promise; - -export declare const loadDataForCharts: ( - mlResultsService: MlResultsService, - jobIds: string[], - earliestMs: number, - latestMs: number, - influencers: any[], - selectedCells: AppStateSelectedCells | undefined, - influencersFilterQuery: InfluencersFilterQuery, - // choose whether or not to keep track of the request that could be out of date - takeLatestOnly: boolean -) => Promise; - -export declare const loadFilteredTopInfluencers: ( - mlResultsService: MlResultsService, - jobIds: string[], - earliestMs: number, - latestMs: number, - records: any[], - influencers: any[], - noInfluencersConfigured: boolean, - influencersFilterQuery: InfluencersFilterQuery -) => Promise; - -export declare const loadTopInfluencers: ( - mlResultsService: MlResultsService, - selectedJobIds: string[], - earliestMs: number, - latestMs: number, - influencers: any[], - noInfluencersConfigured?: boolean, - influencersFilterQuery?: any -) => Promise; - -declare interface LoadOverallDataResponse { - loading: boolean; - overallSwimlaneData: OverallSwimlaneData; -} - -export declare interface FilterData { - influencersFilterQuery: InfluencersFilterQuery; - filterActive: boolean; - filteredFields: string[]; - queryString: string; -} - -export declare interface AppStateSelectedCells { - type: SwimlaneType; - lanes: string[]; - times: [number, number]; - showTopFieldValues?: boolean; - viewByFieldName?: string; -} - -export declare const removeFilterFromQueryString: ( - currentQueryString: string, - fieldName: string, - fieldValue: string -) => string; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts similarity index 65% rename from x-pack/plugins/ml/public/application/explorer/explorer_utils.js rename to x-pack/plugins/ml/public/application/explorer/explorer_utils.ts index af2b9b07a43f..17406d7b5ead 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts @@ -9,14 +9,14 @@ * utils for Anomaly Explorer. */ -import { get, union, sortBy, uniq } from 'lodash'; +import { get, union, uniq } from 'lodash'; import moment from 'moment-timezone'; import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, ANOMALIES_TABLE_DEFAULT_QUERY_SIZE, } from '../../../common/constants/search'; -import { getEntityFieldList } from '../../../common/util/anomaly_utils'; +import { EntityField, getEntityFieldList } from '../../../common/util/anomaly_utils'; import { extractErrorMessage } from '../../../common/util/errors'; import { isSourceDataChartableForDetector, @@ -26,34 +26,102 @@ import { import { parseInterval } from '../../../common/util/parse_interval'; import { ml } from '../services/ml_api_service'; import { mlJobService } from '../services/job_service'; -import { getTimeBucketsFromCache } from '../util/time_buckets'; -import { getTimefilter, getUiSettings } from '../util/dependency_cache'; +import { getUiSettings } from '../util/dependency_cache'; import { MAX_CATEGORY_EXAMPLES, MAX_INFLUENCER_FIELD_VALUES, SWIMLANE_TYPE, + SwimlaneType, VIEW_BY_JOB_LABEL, } from './explorer_constants'; +import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; +import { MlResultsService } from '../services/results_service'; +import { InfluencersFilterQuery } from '../../../common/types/es_client'; +import { TimeRangeBounds } from '../util/time_buckets'; +import { Annotations, AnnotationsTable } from '../../../common/types/annotations'; +import { Influencer } from '../../../common/types/anomalies'; +import { RecordForInfluencer } from '../services/results_service/results_service'; + +export interface ExplorerJob { + id: string; + selected: boolean; + bucketSpanSeconds: number; +} + +interface ClearedSelectedAnomaliesState { + selectedCells: undefined; +} + +export interface SwimlanePoint { + laneLabel: string; + time: number; + value: number; +} + +export interface SwimlaneData { + fieldName?: string; + laneLabels: string[]; + points: SwimlanePoint[]; + interval: number; +} + +export interface AppStateSelectedCells { + type: SwimlaneType; + lanes: string[]; + times: [number, number]; + showTopFieldValues?: boolean; + viewByFieldName?: string; +} + +interface SelectionTimeRange { + earliestMs: number; + latestMs: number; +} + +export interface AnomaliesTableData { + anomalies: any[]; + interval: number; + examplesByJobId: string[]; + showViewSeriesLink: boolean; + jobIds: string[]; +} + +export interface ChartRecord extends RecordForInfluencer { + function: string; +} + +export interface OverallSwimlaneData extends SwimlaneData { + /** + * Earliest timestamp in seconds + */ + earliest: number; + /** + * Latest timestamp in seconds + */ + latest: number; +} + +export interface ViewBySwimLaneData extends OverallSwimlaneData { + cardinality: number; +} // create new job objects based on standard job config objects // new job objects just contain job id, bucket span in seconds and a selected flag. -export function createJobs(jobs) { +export function createJobs(jobs: CombinedJob[]): ExplorerJob[] { return jobs.map((job) => { const bucketSpan = parseInterval(job.analysis_config.bucket_span); - return { id: job.job_id, selected: false, bucketSpanSeconds: bucketSpan.asSeconds() }; + return { id: job.job_id, selected: false, bucketSpanSeconds: bucketSpan!.asSeconds() }; }); } -export function getClearedSelectedAnomaliesState() { +export function getClearedSelectedAnomaliesState(): ClearedSelectedAnomaliesState { return { selectedCells: undefined, - viewByLoadedForTimeFormatted: null, - swimlaneLimit: undefined, }; } -export function getDefaultSwimlaneData() { +export function getDefaultSwimlaneData(): SwimlaneData { return { fieldName: '', laneLabels: [], @@ -63,18 +131,18 @@ export function getDefaultSwimlaneData() { } export async function loadFilteredTopInfluencers( - mlResultsService, - jobIds, - earliestMs, - latestMs, - records, - influencers, - noInfluencersConfigured, - influencersFilterQuery -) { + mlResultsService: MlResultsService, + jobIds: string[], + earliestMs: number, + latestMs: number, + records: any[], + influencers: any[], + noInfluencersConfigured: boolean, + influencersFilterQuery: InfluencersFilterQuery +): Promise { // Filter the Top Influencers list to show just the influencers from // the records in the selected time range. - const recordInfluencersByName = {}; + const recordInfluencersByName: Record = {}; // Add the specified influencer(s) to ensure they are used in the filter // even if their influencer score for the selected time range is zero. @@ -88,7 +156,7 @@ export async function loadFilteredTopInfluencers( // Add the influencers from the top scoring anomalies. records.forEach((record) => { - const influencersByName = record.influencers || []; + const influencersByName: Influencer[] = record.influencers || []; influencersByName.forEach((influencer) => { const fieldName = influencer.influencer_field_name; const fieldValues = influencer.influencer_field_values; @@ -99,13 +167,13 @@ export async function loadFilteredTopInfluencers( }); }); - const uniqValuesByName = {}; + const uniqValuesByName: Record = {}; Object.keys(recordInfluencersByName).forEach((fieldName) => { const fieldValues = recordInfluencersByName[fieldName]; uniqValuesByName[fieldName] = uniq(fieldValues); }); - const filterInfluencers = []; + const filterInfluencers: EntityField[] = []; Object.keys(uniqValuesByName).forEach((fieldName) => { // Find record influencers with the same field name as the clicked on cell(s). const matchingFieldName = influencers.find((influencer) => { @@ -123,7 +191,7 @@ export async function loadFilteredTopInfluencers( } }); - return await loadTopInfluencers( + return (await loadTopInfluencers( mlResultsService, jobIds, earliestMs, @@ -131,11 +199,11 @@ export async function loadFilteredTopInfluencers( filterInfluencers, noInfluencersConfigured, influencersFilterQuery - ); + )) as any[]; } -export function getInfluencers(selectedJobs = []) { - const influencers = []; +export function getInfluencers(selectedJobs: any[]): string[] { + const influencers: string[] = []; selectedJobs.forEach((selectedJob) => { const job = mlJobService.getJob(selectedJob.id); if (job !== undefined && job.analysis_config && job.analysis_config.influencers) { @@ -145,7 +213,7 @@ export function getInfluencers(selectedJobs = []) { return influencers; } -export function getDateFormatTz() { +export function getDateFormatTz(): string { const uiSettings = getUiSettings(); // Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table. const tzConfig = uiSettings.get('dateFormat:tz'); @@ -174,29 +242,39 @@ export function getFieldsByJob() { reducedfieldsForJob.push(detector.by_field_name); } return reducedfieldsForJob; - }, []) + }, [] as string[]) .concat(influencers); reducedFieldsByJob[job.job_id] = uniq(fieldsForJob); reducedFieldsByJob['*'] = union(reducedFieldsByJob['*'], reducedFieldsByJob[job.job_id]); return reducedFieldsByJob; }, - { '*': [] } + { '*': [] } as Record ); } -export function getSelectionTimeRange(selectedCells, interval, bounds) { +export function getSelectionTimeRange( + selectedCells: AppStateSelectedCells | undefined, + interval: number, + bounds: TimeRangeBounds +): SelectionTimeRange { // Returns the time range of the cell(s) currently selected in the swimlane. // If no cell(s) are currently selected, returns the dashboard time range. - let earliestMs = bounds.min.valueOf(); - let latestMs = bounds.max.valueOf(); + + // TODO check why this code always expect both min and max defined. + const requiredBounds = bounds as Required; + + let earliestMs = requiredBounds.min.valueOf(); + let latestMs = requiredBounds.max.valueOf(); if (selectedCells !== undefined && selectedCells.times !== undefined) { // time property of the cell data is an array, with the elements being // the start times of the first and last cell selected. earliestMs = - selectedCells.times[0] !== undefined ? selectedCells.times[0] * 1000 : bounds.min.valueOf(); - latestMs = bounds.max.valueOf(); + selectedCells.times[0] !== undefined + ? selectedCells.times[0] * 1000 + : requiredBounds.min.valueOf(); + latestMs = requiredBounds.max.valueOf(); if (selectedCells.times[1] !== undefined) { // Subtract 1 ms so search does not include start of next bucket. latestMs = selectedCells.times[1] * 1000 - 1; @@ -206,7 +284,10 @@ export function getSelectionTimeRange(selectedCells, interval, bounds) { return { earliestMs, latestMs }; } -export function getSelectionInfluencers(selectedCells, fieldName) { +export function getSelectionInfluencers( + selectedCells: AppStateSelectedCells | undefined, + fieldName: string +): EntityField[] { if ( selectedCells !== undefined && selectedCells.type !== SWIMLANE_TYPE.OVERALL && @@ -219,7 +300,10 @@ export function getSelectionInfluencers(selectedCells, fieldName) { return []; } -export function getSelectionJobIds(selectedCells, selectedJobs) { +export function getSelectionJobIds( + selectedCells: AppStateSelectedCells | undefined, + selectedJobs: ExplorerJob[] +): string[] { if ( selectedCells !== undefined && selectedCells.type !== SWIMLANE_TYPE.OVERALL && @@ -232,159 +316,11 @@ export function getSelectionJobIds(selectedCells, selectedJobs) { return selectedJobs.map((d) => d.id); } -export function getSwimlaneBucketInterval(selectedJobs, swimlaneContainerWidth) { - // Bucketing interval should be the maximum of the chart related interval (i.e. time range related) - // and the max bucket span for the jobs shown in the chart. - const timefilter = getTimefilter(); - const bounds = timefilter.getActiveBounds(); - const buckets = getTimeBucketsFromCache(); - buckets.setInterval('auto'); - buckets.setBounds(bounds); - - const intervalSeconds = buckets.getInterval().asSeconds(); - - // if the swimlane cell widths are too small they will not be visible - // calculate how many buckets will be drawn before the swimlanes are actually rendered - // and increase the interval to widen the cells if they're going to be smaller than 8px - // this has to be done at this stage so all searches use the same interval - const timerangeSeconds = (bounds.max.valueOf() - bounds.min.valueOf()) / 1000; - const numBuckets = parseInt(timerangeSeconds / intervalSeconds); - const cellWidth = Math.floor((swimlaneContainerWidth / numBuckets) * 100) / 100; - - // if the cell width is going to be less than 8px, double the interval - if (cellWidth < 8) { - buckets.setInterval(intervalSeconds * 2 + 's'); - } - - const maxBucketSpanSeconds = selectedJobs.reduce( - (memo, job) => Math.max(memo, job.bucketSpanSeconds), - 0 - ); - if (maxBucketSpanSeconds > intervalSeconds) { - buckets.setInterval(maxBucketSpanSeconds + 's'); - buckets.setBounds(bounds); - } - - return buckets.getInterval(); -} - -// Obtain the list of 'View by' fields per job and viewBySwimlaneFieldName -export function getViewBySwimlaneOptions({ - currentViewBySwimlaneFieldName, - filterActive, - filteredFields, - isAndOperator, - selectedCells, - selectedJobs, -}) { - const selectedJobIds = selectedJobs.map((d) => d.id); - - // Unique influencers for the selected job(s). - const viewByOptions = sortBy( - uniq( - mlJobService.jobs.reduce((reducedViewByOptions, job) => { - if (selectedJobIds.some((jobId) => jobId === job.job_id)) { - return reducedViewByOptions.concat(job.analysis_config.influencers || []); - } - return reducedViewByOptions; - }, []) - ), - (fieldName) => fieldName.toLowerCase() - ); - - viewByOptions.push(VIEW_BY_JOB_LABEL); - let viewBySwimlaneOptions = viewByOptions; - - let viewBySwimlaneFieldName = undefined; - - if (viewBySwimlaneOptions.indexOf(currentViewBySwimlaneFieldName) !== -1) { - // Set the swimlane viewBy to that stored in the state (URL) if set. - // This means we reset it to the current state because it was set by the listener - // on initialization. - viewBySwimlaneFieldName = currentViewBySwimlaneFieldName; - } else { - if (selectedJobIds.length > 1) { - // If more than one job selected, default to job ID. - viewBySwimlaneFieldName = VIEW_BY_JOB_LABEL; - } else if (mlJobService.jobs.length > 0 && selectedJobIds.length > 0) { - // For a single job, default to the first partition, over, - // by or influencer field of the first selected job. - const firstSelectedJob = mlJobService.jobs.find((job) => { - return job.job_id === selectedJobIds[0]; - }); - - const firstJobInfluencers = firstSelectedJob.analysis_config.influencers || []; - firstSelectedJob.analysis_config.detectors.forEach((detector) => { - if ( - detector.partition_field_name !== undefined && - firstJobInfluencers.indexOf(detector.partition_field_name) !== -1 - ) { - viewBySwimlaneFieldName = detector.partition_field_name; - return false; - } - - if ( - detector.over_field_name !== undefined && - firstJobInfluencers.indexOf(detector.over_field_name) !== -1 - ) { - viewBySwimlaneFieldName = detector.over_field_name; - return false; - } - - // For jobs with by and over fields, don't add the 'by' field as this - // field will only be added to the top-level fields for record type results - // if it also an influencer over the bucket. - if ( - detector.by_field_name !== undefined && - detector.over_field_name === undefined && - firstJobInfluencers.indexOf(detector.by_field_name) !== -1 - ) { - viewBySwimlaneFieldName = detector.by_field_name; - return false; - } - }); - - if (viewBySwimlaneFieldName === undefined) { - if (firstJobInfluencers.length > 0) { - viewBySwimlaneFieldName = firstJobInfluencers[0]; - } else { - // No influencers for first selected job - set to first available option. - viewBySwimlaneFieldName = - viewBySwimlaneOptions.length > 0 ? viewBySwimlaneOptions[0] : undefined; - } - } - } - } - - // filter View by options to relevant filter fields - // If it's an AND filter only show job Id view by as the rest will have no results - if (filterActive === true && isAndOperator === true && selectedCells === null) { - viewBySwimlaneOptions = [VIEW_BY_JOB_LABEL]; - } else if ( - filterActive === true && - Array.isArray(viewBySwimlaneOptions) && - Array.isArray(filteredFields) - ) { - const filteredOptions = viewBySwimlaneOptions.filter((option) => { - return ( - filteredFields.includes(option) || - option === VIEW_BY_JOB_LABEL || - (selectedCells && selectedCells.viewByFieldName === option) - ); - }); - // only replace viewBySwimlaneOptions with filteredOptions if we found a relevant matching field - if (filteredOptions.length > 1) { - viewBySwimlaneOptions = filteredOptions; - } - } - - return { - viewBySwimlaneFieldName, - viewBySwimlaneOptions, - }; -} - -export function loadOverallAnnotations(selectedJobs, interval, bounds) { +export function loadOverallAnnotations( + selectedJobs: ExplorerJob[], + interval: number, + bounds: TimeRangeBounds +): Promise { const jobIds = selectedJobs.map((d) => d.id); const timeRange = getSelectionTimeRange(undefined, interval, bounds); @@ -406,7 +342,7 @@ export function loadOverallAnnotations(selectedJobs, interval, bounds) { }); } - const annotationsData = []; + const annotationsData: Annotations = []; jobIds.forEach((jobId) => { const jobAnnotations = resp.annotations[jobId]; if (jobAnnotations !== undefined) { @@ -435,7 +371,12 @@ export function loadOverallAnnotations(selectedJobs, interval, bounds) { }); } -export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, bounds) { +export function loadAnnotationsTableData( + selectedCells: AppStateSelectedCells | undefined, + selectedJobs: ExplorerJob[], + interval: number, + bounds: Required +): Promise { const jobIds = getSelectionJobIds(selectedCells, selectedJobs); const timeRange = getSelectionTimeRange(selectedCells, interval, bounds); @@ -458,7 +399,7 @@ export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, }); } - const annotationsData = []; + const annotationsData: Annotations = []; jobIds.forEach((jobId) => { const jobAnnotations = resp.annotations[jobId]; if (jobAnnotations !== undefined) { @@ -490,16 +431,16 @@ export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, } export async function loadAnomaliesTableData( - selectedCells, - selectedJobs, - dateFormatTz, - interval, - bounds, - fieldName, - tableInterval, - tableSeverity, - influencersFilterQuery -) { + selectedCells: AppStateSelectedCells | undefined, + selectedJobs: ExplorerJob[], + dateFormatTz: any, + interval: number, + bounds: Required, + fieldName: string, + tableInterval: string, + tableSeverity: number, + influencersFilterQuery: InfluencersFilterQuery +): Promise { const jobIds = getSelectionJobIds(selectedCells, selectedJobs); const influencers = getSelectionInfluencers(selectedCells, fieldName); const timeRange = getSelectionTimeRange(selectedCells, interval, bounds); @@ -523,6 +464,7 @@ export async function loadAnomaliesTableData( .then((resp) => { const anomalies = resp.anomalies; const detectorsByJob = mlJobService.detectorsByJob; + // @ts-ignore anomalies.forEach((anomaly) => { // Add a detector property to each anomaly. // Default to functionDescription if no description available. @@ -571,6 +513,7 @@ export async function loadAnomaliesTableData( }); }) .catch((resp) => { + // eslint-disable-next-line no-console console.log('Explorer - error loading data for anomalies table:', resp); reject(); }); @@ -578,13 +521,13 @@ export async function loadAnomaliesTableData( } export async function loadTopInfluencers( - mlResultsService, - selectedJobIds, - earliestMs, - latestMs, - influencers = [], - noInfluencersConfigured, - influencersFilterQuery + mlResultsService: MlResultsService, + selectedJobIds: string[], + earliestMs: number, + latestMs: number, + influencers: any[], + noInfluencersConfigured?: boolean, + influencersFilterQuery?: InfluencersFilterQuery ) { return new Promise((resolve) => { if (noInfluencersConfigured !== true) { @@ -611,26 +554,30 @@ export async function loadTopInfluencers( // Recommended by MDN for escaping user input to be treated as a literal string within a regular expression // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions -export function escapeRegExp(string) { +export function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } -export function escapeParens(string) { +export function escapeParens(string: string): string { return string.replace(/[()]/g, '\\$&'); } -export function escapeDoubleQuotes(string) { +export function escapeDoubleQuotes(string: string): string { return string.replace(/[\\"]/g, '\\$&'); } -export function getQueryPattern(fieldName, fieldValue) { +export function getQueryPattern(fieldName: string, fieldValue: string) { const sanitizedFieldName = escapeRegExp(fieldName); const sanitizedFieldValue = escapeRegExp(fieldValue); return new RegExp(`(${sanitizedFieldName})\\s?:\\s?(")?(${sanitizedFieldValue})(")?`, 'i'); } -export function removeFilterFromQueryString(currentQueryString, fieldName, fieldValue) { +export function removeFilterFromQueryString( + currentQueryString: string, + fieldName: string, + fieldValue: string +) { let newQueryString = ''; // Remove the passed in fieldName and value from the existing filter const queryPattern = getQueryPattern(fieldName, fieldValue); diff --git a/x-pack/plugins/ml/public/application/explorer/has_matching_points.ts b/x-pack/plugins/ml/public/application/explorer/has_matching_points.ts index 21ac4429d69d..faf2c3fc3fdd 100644 --- a/x-pack/plugins/ml/public/application/explorer/has_matching_points.ts +++ b/x-pack/plugins/ml/public/application/explorer/has_matching_points.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { ExplorerState } from './reducers'; import { OverallSwimlaneData, SwimlaneData } from './explorer_utils'; +import type { FilterSettings } from './anomaly_explorer_common_state'; interface HasMatchingPointsParams { - filteredFields?: ExplorerState['filteredFields']; + filteredFields?: FilterSettings['filteredFields']; swimlaneData: SwimlaneData | OverallSwimlaneData; } @@ -18,9 +18,11 @@ export const hasMatchingPoints = ({ swimlaneData, }: HasMatchingPointsParams): boolean => { // If filtered fields includes a wildcard search maskAll only if there are no points matching the pattern - const wildCardField = filteredFields.find((field) => /\@kuery-wildcard\@$/.test(field)); + const wildCardField = filteredFields.find((field) => /\@kuery-wildcard\@$/.test(field as string)); const substring = - wildCardField !== undefined ? wildCardField.replace(/\@kuery-wildcard\@$/, '') : null; + wildCardField !== undefined + ? (wildCardField as string).replace(/\@kuery-wildcard\@$/, '') + : null; return ( substring !== null && diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts index 421018abb854..5af9684c3a09 100644 --- a/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts +++ b/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts @@ -5,10 +5,12 @@ * 2.0. */ -import { usePageUrlState } from '../../util/url_state'; +import { PageUrlStateService, usePageUrlState } from '../../util/url_state'; import { ExplorerAppState } from '../../../../common/types/locator'; import { ML_PAGES } from '../../../../common/constants/locator'; +export type AnomalyExplorerUrlStateService = PageUrlStateService; + export function useExplorerUrlState() { /** * Originally `mlExplorerSwimlane` resided directly in the app URL state (`_a` URL state key). diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.test.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.test.ts deleted file mode 100644 index 724d85d91e30..000000000000 --- a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import moment from 'moment'; -import { renderHook } from '@testing-library/react-hooks'; -import { useSelectedCells } from './use_selected_cells'; -import { ExplorerAppState } from '../../../../common/types/locator'; -import { TimefilterContract } from '../../../../../../../src/plugins/data/public'; - -import { useTimefilter } from '../../contexts/kibana'; - -jest.mock('../../contexts/kibana'); - -describe('useSelectedCells', () => { - test('should not set state when the cell selection is correct', () => { - (useTimefilter() as jest.Mocked).getBounds.mockReturnValue({ - min: moment(1498824778 * 1000), - max: moment(1502366798 * 1000), - }); - - const urlState = { - mlExplorerSwimlane: { - selectedType: 'overall', - selectedLanes: ['Overall'], - selectedTimes: [1498780800, 1498867200], - showTopFieldValues: true, - viewByFieldName: 'apache2.access.remote_ip', - viewByFromPage: 1, - viewByPerPage: 10, - }, - mlExplorerFilter: {}, - } as ExplorerAppState; - - const setUrlState = jest.fn(); - - const bucketInterval = 86400; - - renderHook(() => useSelectedCells(urlState, setUrlState, bucketInterval)); - - expect(setUrlState).not.toHaveBeenCalled(); - }); - - test('should reset cell selection when it is completely out of range', () => { - (useTimefilter() as jest.Mocked).getBounds.mockReturnValue({ - min: moment(1501071178 * 1000), - max: moment(1502366798 * 1000), - }); - - const urlState = { - mlExplorerSwimlane: { - selectedType: 'overall', - selectedLanes: ['Overall'], - selectedTimes: [1498780800, 1498867200], - showTopFieldValues: true, - viewByFieldName: 'apache2.access.remote_ip', - viewByFromPage: 1, - viewByPerPage: 10, - }, - mlExplorerFilter: {}, - } as ExplorerAppState; - - const setUrlState = jest.fn(); - - const bucketInterval = 86400; - - const { result } = renderHook(() => useSelectedCells(urlState, setUrlState, bucketInterval)); - - expect(result.current[0]).toEqual({ - lanes: ['Overall'], - showTopFieldValues: true, - times: [1498780800, 1498867200], - type: 'overall', - viewByFieldName: 'apache2.access.remote_ip', - }); - - expect(setUrlState).toHaveBeenCalledWith({ - mlExplorerSwimlane: { - viewByFieldName: 'apache2.access.remote_ip', - viewByFromPage: 1, - viewByPerPage: 10, - }, - }); - }); - - test('should adjust cell selection time boundaries based on the main time range', () => { - (useTimefilter() as jest.Mocked).getBounds.mockReturnValue({ - min: moment(1501071178 * 1000), - max: moment(1502366798 * 1000), - }); - - const urlState = { - mlExplorerSwimlane: { - selectedType: 'overall', - selectedLanes: ['Overall'], - selectedTimes: [1498780800, 1502366798], - showTopFieldValues: true, - viewByFieldName: 'apache2.access.remote_ip', - viewByFromPage: 1, - viewByPerPage: 10, - }, - mlExplorerFilter: {}, - } as ExplorerAppState; - - const setUrlState = jest.fn(); - - const bucketInterval = 86400; - - const { result } = renderHook(() => useSelectedCells(urlState, setUrlState, bucketInterval)); - - expect(result.current[0]).toEqual({ - lanes: ['Overall'], - showTopFieldValues: true, - times: [1498780800, 1502366798], - type: 'overall', - viewByFieldName: 'apache2.access.remote_ip', - }); - - expect(setUrlState).toHaveBeenCalledWith({ - mlExplorerSwimlane: { - selectedLanes: ['Overall'], - selectedTimes: [1500984778, 1502366798], - selectedType: 'overall', - showTopFieldValues: true, - viewByFieldName: 'apache2.access.remote_ip', - viewByFromPage: 1, - viewByPerPage: 10, - }, - }); - }); - - test('should extend single time point selection with a bucket interval value', () => { - (useTimefilter() as jest.Mocked).getBounds.mockReturnValue({ - min: moment(1498824778 * 1000), - max: moment(1502366798 * 1000), - }); - - const urlState = { - mlExplorerSwimlane: { - selectedType: 'overall', - selectedLanes: ['Overall'], - selectedTimes: 1498780800, - showTopFieldValues: true, - viewByFieldName: 'apache2.access.remote_ip', - viewByFromPage: 1, - viewByPerPage: 10, - }, - mlExplorerFilter: {}, - } as ExplorerAppState; - - const setUrlState = jest.fn(); - - const bucketInterval = 86400; - - const { result } = renderHook(() => useSelectedCells(urlState, setUrlState, bucketInterval)); - - expect(result.current[0]).toEqual({ - lanes: ['Overall'], - showTopFieldValues: true, - times: [1498780800, 1498867200], - type: 'overall', - viewByFieldName: 'apache2.access.remote_ip', - }); - }); -}); diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts index 1840ffc7235d..9b2665f8f21f 100644 --- a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts +++ b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts @@ -5,133 +5,7 @@ * 2.0. */ -import { useCallback, useEffect, useMemo } from 'react'; -import { SWIMLANE_TYPE } from '../explorer_constants'; import { AppStateSelectedCells } from '../explorer_utils'; -import { ExplorerAppState } from '../../../../common/types/locator'; -import { useTimefilter } from '../../contexts/kibana'; - -export const useSelectedCells = ( - appState: ExplorerAppState, - setAppState: (update: Partial) => void, - bucketIntervalInSeconds: number | undefined -): [AppStateSelectedCells | undefined, (swimlaneSelectedCells: AppStateSelectedCells) => void] => { - const timeFilter = useTimefilter(); - const timeBounds = timeFilter.getBounds(); - - // keep swimlane selection, restore selectedCells from AppState - const selectedCells: AppStateSelectedCells | undefined = useMemo(() => { - if (!appState?.mlExplorerSwimlane?.selectedType) { - return; - } - - let times = - appState.mlExplorerSwimlane.selectedTimes ?? appState.mlExplorerSwimlane.selectedTime!; - if (typeof times === 'number') { - times = [times, times + bucketIntervalInSeconds!]; - } - - let lanes = - appState.mlExplorerSwimlane.selectedLanes ?? appState.mlExplorerSwimlane.selectedLane!; - - if (typeof lanes === 'string') { - lanes = [lanes]; - } - - return { - type: appState.mlExplorerSwimlane.selectedType, - lanes, - times, - showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues, - viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName, - } as AppStateSelectedCells; - // TODO fix appState to use memoization - }, [JSON.stringify(appState?.mlExplorerSwimlane), bucketIntervalInSeconds]); - - const setSelectedCells = useCallback( - (swimlaneSelectedCells?: AppStateSelectedCells) => { - const mlExplorerSwimlane = { - ...appState.mlExplorerSwimlane, - } as ExplorerAppState['mlExplorerSwimlane']; - - if (swimlaneSelectedCells !== undefined) { - swimlaneSelectedCells.showTopFieldValues = false; - - const currentSwimlaneType = selectedCells?.type; - const currentShowTopFieldValues = selectedCells?.showTopFieldValues; - const newSwimlaneType = swimlaneSelectedCells?.type; - - if ( - (currentSwimlaneType === SWIMLANE_TYPE.OVERALL && - newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) || - newSwimlaneType === SWIMLANE_TYPE.OVERALL || - currentShowTopFieldValues === true - ) { - swimlaneSelectedCells.showTopFieldValues = true; - } - - mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type; - mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; - mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; - mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; - setAppState({ mlExplorerSwimlane }); - } else { - delete mlExplorerSwimlane.selectedType; - delete mlExplorerSwimlane.selectedLanes; - delete mlExplorerSwimlane.selectedTimes; - delete mlExplorerSwimlane.showTopFieldValues; - setAppState({ mlExplorerSwimlane }); - } - }, - [appState?.mlExplorerSwimlane, selectedCells, setAppState] - ); - - /** - * Adjust cell selection with respect to the time boundaries. - * Reset it entirely when it out of range. - */ - useEffect( - function adjustSwimLaneTimeSelection() { - if (selectedCells?.times === undefined || bucketIntervalInSeconds === undefined) return; - - const [selectedFrom, selectedTo] = selectedCells.times; - - /** - * Because each cell on the swim lane represent the fixed bucket interval, - * the selection range could be outside of the time boundaries with - * correction within the bucket interval. - */ - const rangeFrom = timeBounds.min!.unix() - bucketIntervalInSeconds; - const rangeTo = timeBounds.max!.unix() + bucketIntervalInSeconds; - - const resultFrom = Math.max(selectedFrom, rangeFrom); - const resultTo = Math.min(selectedTo, rangeTo); - - const isSelectionOutOfRange = rangeFrom > resultTo || rangeTo < resultFrom; - - if (isSelectionOutOfRange) { - // reset selection - setSelectedCells(); - return; - } - - if (selectedFrom === resultFrom && selectedTo === resultTo) { - // selection is correct, no need to adjust the range - return; - } - - if (resultFrom !== rangeFrom || resultTo !== rangeTo) { - setSelectedCells({ - ...selectedCells, - times: [resultFrom, resultTo], - }); - } - }, - [timeBounds.min?.unix(), timeBounds.max?.unix(), selectedCells, bucketIntervalInSeconds] - ); - - return [selectedCells, setSelectedCells]; -}; export interface SelectionTimeRange { earliestMs: number; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts deleted file mode 100644 index e41ec55c6685..000000000000 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts +++ /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 { SWIMLANE_TYPE } from '../../explorer_constants'; -import { getClearedSelectedAnomaliesState } from '../../explorer_utils'; - -import { ExplorerState } from './state'; - -interface SwimlanePoint { - laneLabel: string; - time: number; -} - -// do a sanity check against selectedCells. It can happen that a previously -// selected lane loaded via URL/AppState is not available anymore. -// If filter is active - selectedCell may not be available due to swimlane view by change to filter fieldName -// Ok to keep cellSelection in this case -export const checkSelectedCells = (state: ExplorerState) => { - const { filterActive, loading, selectedCells, viewBySwimlaneData, viewBySwimlaneDataLoading } = - state; - - if (loading || viewBySwimlaneDataLoading) { - return {}; - } - - let clearSelection = false; - if ( - selectedCells !== undefined && - selectedCells !== null && - selectedCells.type === SWIMLANE_TYPE.VIEW_BY && - viewBySwimlaneData !== undefined && - viewBySwimlaneData.points !== undefined && - viewBySwimlaneData.points.length > 0 - ) { - clearSelection = - filterActive === false && - !selectedCells.lanes.some((lane: string) => { - return viewBySwimlaneData.points.some((point: SwimlanePoint) => { - return ( - point.laneLabel === lane && - point.time >= selectedCells.times[0] && - point.time <= selectedCells.times[1] - ); - }); - }); - } - - if (clearSelection === true) { - return { - ...getClearedSelectedAnomaliesState(), - }; - } - - return {}; -}; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts index 70929681b196..dec50d0b985e 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts @@ -12,14 +12,10 @@ import { ExplorerState } from './state'; export function clearInfluencerFilterSettings(state: ExplorerState): ExplorerState { return { ...state, - filterActive: false, - filteredFields: [], - influencersFilterQuery: undefined, isAndOperator: false, maskAll: false, queryString: '', tableQueryString: '', ...getClearedSelectedAnomaliesState(), - viewByFromPage: 1, }; } diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts index a6d16cd2b777..dbf5dc2c8a8b 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts @@ -5,25 +5,18 @@ * 2.0. */ -import { isEqual } from 'lodash'; -import { ActionPayload } from '../../explorer_dashboard_service'; -import { getDefaultSwimlaneData, getInfluencers } from '../../explorer_utils'; +import type { ActionPayload } from '../../explorer_dashboard_service'; +import { getInfluencers } from '../../explorer_utils'; import { getIndexPattern } from './get_index_pattern'; -import { ExplorerState } from './state'; +import type { ExplorerState } from './state'; export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload): ExplorerState => { const { selectedJobs } = payload; const stateUpdate: ExplorerState = { ...state, noInfluencersConfigured: getInfluencers(selectedJobs).length === 0, - overallSwimlaneData: getDefaultSwimlaneData(), selectedJobs, - // currently job selection set asynchronously so - // we want to preserve the pagination from the url state - // on initial load - viewByFromPage: - !state.selectedJobs || isEqual(state.selectedJobs, selectedJobs) ? state.viewByFromPage : 1, }; // clear filter if selected jobs have no influencers diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts index 192699afc2cf..632ade186a44 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts @@ -5,25 +5,15 @@ * 2.0. */ -import { formatHumanReadableDateTime } from '../../../../../common/util/date_utils'; - import { getDefaultChartsData } from '../../explorer_charts/explorer_charts_container_service'; -import { EXPLORER_ACTION, VIEW_BY_JOB_LABEL } from '../../explorer_constants'; +import { EXPLORER_ACTION } from '../../explorer_constants'; import { Action } from '../../explorer_dashboard_service'; -import { - getClearedSelectedAnomaliesState, - getDefaultSwimlaneData, - getSwimlaneBucketInterval, - getViewBySwimlaneOptions, -} from '../../explorer_utils'; +import { getClearedSelectedAnomaliesState } from '../../explorer_utils'; -import { checkSelectedCells } from './check_selected_cells'; import { clearInfluencerFilterSettings } from './clear_influencer_filter_settings'; import { jobSelectionChange } from './job_selection_change'; import { ExplorerState, getExplorerDefaultState } from './state'; -import { setInfluencerFilterSettings } from './set_influencer_filter_settings'; import { setKqlQueryBarPlaceholder } from './set_kql_query_bar_placeholder'; -import { getTimeBoundsFromSelection } from '../../hooks/use_selected_cells'; export const explorerReducer = (state: ExplorerState, nextAction: Action): ExplorerState => { const { type, payload } = nextAction; @@ -44,7 +34,6 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo ...state, ...getClearedSelectedAnomaliesState(), loading: false, - viewByFromPage: 1, selectedJobs: [], }; break; @@ -75,96 +64,10 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo }; break; - case EXPLORER_ACTION.SET_INFLUENCER_FILTER_SETTINGS: - nextState = setInfluencerFilterSettings(state, payload); - break; - - case EXPLORER_ACTION.SET_SELECTED_CELLS: - const selectedCells = payload; - nextState = { - ...state, - selectedCells, - }; - break; - case EXPLORER_ACTION.SET_EXPLORER_DATA: - case EXPLORER_ACTION.SET_FILTER_DATA: nextState = { ...state, ...payload }; break; - case EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH: - nextState = { ...state, swimlaneContainerWidth: payload }; - break; - - case EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME: - const { filteredFields, influencersFilterQuery } = state; - const viewBySwimlaneFieldName = payload; - - let maskAll = false; - - if (influencersFilterQuery !== undefined) { - maskAll = - viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL || - filteredFields.includes(viewBySwimlaneFieldName) === false; - } - - nextState = { - ...state, - ...getClearedSelectedAnomaliesState(), - maskAll, - viewBySwimlaneFieldName, - viewBySwimlaneData: getDefaultSwimlaneData(), - viewByFromPage: 1, - viewBySwimlaneDataLoading: true, - }; - break; - - case EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_LOADING: - const { annotationsData, overallState, tableData } = payload; - nextState = { - ...state, - annotations: annotationsData, - overallSwimlaneData: overallState, - tableData, - viewBySwimlaneData: { - ...getDefaultSwimlaneData(), - }, - viewBySwimlaneDataLoading: true, - }; - break; - - case EXPLORER_ACTION.SET_VIEW_BY_FROM_PAGE: - nextState = { - ...state, - viewByFromPage: payload, - }; - break; - - case EXPLORER_ACTION.SET_VIEW_BY_PER_PAGE: - nextState = { - ...state, - // reset current page on the page size change - viewByFromPage: 1, - viewByPerPage: payload, - }; - break; - - case EXPLORER_ACTION.SET_SWIM_LANE_SEVERITY: - nextState = { - ...state, - // reset current page on the page size change - viewByFromPage: 1, - swimLaneSeverity: payload, - }; - break; - - case EXPLORER_ACTION.SET_SHOW_CHARTS: - nextState = { - ...state, - showCharts: payload, - }; - break; - default: nextState = state; } @@ -173,37 +76,8 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo return nextState; } - const swimlaneBucketInterval = getSwimlaneBucketInterval( - nextState.selectedJobs, - nextState.swimlaneContainerWidth - ); - - // Does a sanity check on the selected `viewBySwimlaneFieldName` - // and returns the available `viewBySwimlaneOptions`. - const { viewBySwimlaneFieldName, viewBySwimlaneOptions } = getViewBySwimlaneOptions({ - currentViewBySwimlaneFieldName: nextState.viewBySwimlaneFieldName, - filterActive: nextState.filterActive, - filteredFields: nextState.filteredFields, - isAndOperator: nextState.isAndOperator, - selectedJobs: nextState.selectedJobs, - selectedCells: nextState.selectedCells!, - }); - - const { selectedCells } = nextState; - - const timeRange = getTimeBoundsFromSelection(selectedCells); - return { ...nextState, - swimlaneBucketInterval, - viewByLoadedForTimeFormatted: timeRange - ? `${formatHumanReadableDateTime(timeRange.earliestMs)} - ${formatHumanReadableDateTime( - timeRange.latestMs - )}` - : null, - viewBySwimlaneFieldName, - viewBySwimlaneOptions, - ...checkSelectedCells(nextState), ...setKqlQueryBarPlaceholder(nextState), }; }; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts deleted file mode 100644 index 4180353a2222..000000000000 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts +++ /dev/null @@ -1,63 +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 { VIEW_BY_JOB_LABEL } from '../../explorer_constants'; -import { ActionPayload } from '../../explorer_dashboard_service'; - -import { ExplorerState } from './state'; - -export function setInfluencerFilterSettings( - state: ExplorerState, - payload: ActionPayload -): ExplorerState { - const { - filterQuery: influencersFilterQuery, - isAndOperator, - filteredFields, - queryString, - tableQueryString, - } = payload; - - const { selectedCells, viewBySwimlaneOptions } = state; - let selectedViewByFieldName = state.viewBySwimlaneFieldName; - const filteredViewBySwimlaneOptions = viewBySwimlaneOptions.filter((d) => - filteredFields.includes(d) - ); - - // if it's an AND filter set view by swimlane to job ID as the others will have no results - if (isAndOperator && selectedCells === undefined) { - selectedViewByFieldName = VIEW_BY_JOB_LABEL; - } else { - // Set View by dropdown to first relevant fieldName based on incoming filter if there's no cell selection already - // or if selected cell is from overall swimlane as this won't include an additional influencer filter - for (let i = 0; i < filteredFields.length; i++) { - if ( - filteredViewBySwimlaneOptions.includes(filteredFields[i]) && - (selectedCells === undefined || (selectedCells && selectedCells.type === 'overall')) - ) { - selectedViewByFieldName = filteredFields[i]; - break; - } - } - } - - return { - ...state, - filterActive: true, - filteredFields, - influencersFilterQuery, - isAndOperator, - queryString, - tableQueryString, - maskAll: - selectedViewByFieldName === VIEW_BY_JOB_LABEL || - filteredFields.includes(selectedViewByFieldName) === false, - viewBySwimlaneFieldName: selectedViewByFieldName, - viewBySwimlaneOptions: filteredViewBySwimlaneOptions, - viewByFromPage: 1, - }; -} diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index cfc9f076fbb3..c9a09ad5e310 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -6,25 +6,14 @@ */ import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns'; -import { Dictionary } from '../../../../../common/types/common'; - import { getDefaultChartsData, ExplorerChartsData, } from '../../explorer_charts/explorer_charts_container_service'; -import { - getDefaultSwimlaneData, - AnomaliesTableData, - ExplorerJob, - AppStateSelectedCells, - OverallSwimlaneData, - SwimlaneData, - ViewBySwimLaneData, -} from '../../explorer_utils'; +import { AnomaliesTableData, ExplorerJob } from '../../explorer_utils'; import { AnnotationsTable } from '../../../../../common/types/annotations'; -import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants'; -import { InfluencersFilterQuery } from '../../../../../common/types/es_client'; -import { TimeBucketsInterval } from '../../../util/time_buckets'; +import type { DataView } from '../../../../../../../../src/plugins/data_views/common'; +import type { InfluencerValueData } from '../../../components/influencers_list/influencers_list'; export interface ExplorerState { overallAnnotations: AnnotationsTable; @@ -32,38 +21,24 @@ export interface ExplorerState { anomalyChartsDataLoading: boolean; chartsData: ExplorerChartsData; fieldFormatsLoading: boolean; - filterActive: boolean; - filteredFields: any[]; - filterPlaceHolder: any; - indexPattern: { title: string; fields: any[] }; - influencersFilterQuery?: InfluencersFilterQuery; - influencers: Dictionary; + filterPlaceHolder: string | undefined; + indexPattern: { + title: string; + fields: Array<{ name: string; type: string; aggregatable: boolean; searchable: boolean }>; + }; + influencers: Record; isAndOperator: boolean; loading: boolean; maskAll: boolean; noInfluencersConfigured: boolean; - overallSwimlaneData: SwimlaneData | OverallSwimlaneData; queryString: string; - selectedCells: AppStateSelectedCells | undefined; selectedJobs: ExplorerJob[] | null; - swimlaneBucketInterval: TimeBucketsInterval | undefined; - swimlaneContainerWidth: number; tableData: AnomaliesTableData; tableQueryString: string; - viewByLoadedForTimeFormatted: string | null; - viewBySwimlaneData: SwimlaneData | ViewBySwimLaneData; - viewBySwimlaneDataLoading: boolean; - viewBySwimlaneFieldName?: string; - viewByPerPage: number; - viewByFromPage: number; - viewBySwimlaneOptions: string[]; - swimlaneLimit?: number; - swimLaneSeverity?: number; - showCharts: boolean; } function getDefaultIndexPattern() { - return { title: ML_RESULTS_INDEX_PATTERN, fields: [] }; + return { title: ML_RESULTS_INDEX_PATTERN, fields: [] } as unknown as DataView; } export function getExplorerDefaultState(): ExplorerState { @@ -79,22 +54,15 @@ export function getExplorerDefaultState(): ExplorerState { anomalyChartsDataLoading: true, chartsData: getDefaultChartsData(), fieldFormatsLoading: false, - filterActive: false, - filteredFields: [], filterPlaceHolder: undefined, indexPattern: getDefaultIndexPattern(), - influencersFilterQuery: undefined, influencers: {}, isAndOperator: false, loading: true, maskAll: false, noInfluencersConfigured: true, - overallSwimlaneData: getDefaultSwimlaneData(), queryString: '', - selectedCells: undefined, selectedJobs: null, - swimlaneBucketInterval: undefined, - swimlaneContainerWidth: 0, tableData: { anomalies: [], examplesByJobId: [''], @@ -103,14 +71,5 @@ export function getExplorerDefaultState(): ExplorerState { showViewSeriesLink: false, }, tableQueryString: '', - viewByLoadedForTimeFormatted: null, - viewBySwimlaneData: getDefaultSwimlaneData(), - viewBySwimlaneDataLoading: false, - viewBySwimlaneFieldName: undefined, - viewBySwimlaneOptions: [], - viewByPerPage: SWIM_LANE_DEFAULT_PAGE_SIZE, - viewByFromPage: 1, - swimlaneLimit: undefined, - showCharts: true, }; } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index 71a6b7d88fe1..18826667298e 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -318,7 +318,7 @@ export class JobsList extends Component { diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index 083982e8fccd..fe43aa9d3b12 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -38,6 +38,10 @@ import { getDocLinks } from '../../../../util/dependency_cache'; // @ts-ignore undeclared module import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_view/index'; import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; +import { + ModelsList, + getDefaultModelsListState, +} from '../../../../trained_models/models_management/models_list'; import { AccessDeniedPage } from '../access_denied_page'; import { InsufficientLicensePage } from '../insufficient_license_page'; import type { SharePluginStart } from '../../../../../../../../../src/plugins/share/public'; @@ -48,7 +52,8 @@ import { getMlGlobalServices } from '../../../../app'; import { ListingPageUrlState } from '../../../../../../common/types/common'; import { getDefaultDFAListState } from '../../../../data_frame_analytics/pages/analytics_management/page'; import { ExportJobsFlyout, ImportJobsFlyout } from '../../../../components/import_export_jobs'; -import type { JobType } from '../../../../../../common/types/saved_objects'; +import type { JobType, TrainedModelType } from '../../../../../../common/types/saved_objects'; +import type { FieldFormatsStart } from '../../../../../../../../../src/plugins/field_formats/public'; interface Tab extends EuiTabbedContentTab { 'data-test-subj': string; @@ -77,6 +82,7 @@ const getEmptyFunctionComponent: React.FC = ({ children }) = function useTabs(isMlEnabledInSpace: boolean, spacesApi: SpacesPluginStart | undefined): Tab[] { const [adPageState, updateAdPageState] = usePageState(getDefaultAnomalyDetectionJobsListState()); const [dfaPageState, updateDfaPageState] = usePageState(getDefaultDFAListState()); + const [modelListState, updateModelListState] = usePageState(getDefaultModelsListState()); return useMemo( () => [ @@ -118,8 +124,33 @@ function useTabs(isMlEnabledInSpace: boolean, spacesApi: SpacesPluginStart | und ), }, + { + 'data-test-subj': 'mlStackManagementJobsListAnalyticsTab', + id: 'trained-model', + name: i18n.translate('xpack.ml.management.jobsList.trainedModelsTab', { + defaultMessage: 'Trained models', + }), + content: ( + + + + + ), + }, ], - [isMlEnabledInSpace, adPageState, updateAdPageState, dfaPageState, updateDfaPageState] + [ + isMlEnabledInSpace, + adPageState, + updateAdPageState, + dfaPageState, + updateDfaPageState, + modelListState, + updateModelListState, + ] ); } @@ -130,7 +161,8 @@ export const JobsListPage: FC<{ spacesApi?: SpacesPluginStart; data: DataPublicPluginStart; usageCollection?: UsageCollectionSetup; -}> = ({ coreStart, share, history, spacesApi, data, usageCollection }) => { + fieldFormats: FieldFormatsStart; +}> = ({ coreStart, share, history, spacesApi, data, usageCollection, fieldFormats }) => { const spacesEnabled = spacesApi !== undefined; const [initialized, setInitialized] = useState(false); const [accessDenied, setAccessDenied] = useState(false); @@ -138,7 +170,7 @@ export const JobsListPage: FC<{ const [showSyncFlyout, setShowSyncFlyout] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); const tabs = useTabs(isMlEnabledInSpace, spacesApi); - const [currentTabId, setCurrentTabId] = useState('anomaly-detector'); + const [currentTabId, setCurrentTabId] = useState('anomaly-detector'); const I18nContext = coreStart.i18n.Context; const theme$ = coreStart.theme.theme$; @@ -228,6 +260,8 @@ export const JobsListPage: FC<{ share, data, usageCollection, + fieldFormats, + spacesApi, mlServices: getMlGlobalServices(coreStart.http, usageCollection), }} > @@ -274,7 +308,12 @@ export const JobsListPage: FC<{ )} - + diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts index b1dd8344b86f..547f8c8b622a 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts +++ b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts @@ -18,6 +18,7 @@ import { setDependencyCache, clearCache } from '../../util/dependency_cache'; import './_index.scss'; import type { SharePluginStart } from '../../../../../../../src/plugins/share/public'; import type { SpacesPluginStart } from '../../../../../spaces/public'; +import type { FieldFormatsStart } from '../../../../../../../src/plugins/field_formats/public'; const renderApp = ( element: HTMLElement, @@ -25,6 +26,7 @@ const renderApp = ( coreStart: CoreStart, share: SharePluginStart, data: DataPublicPluginStart, + fieldFormats: FieldFormatsStart, spacesApi?: SpacesPluginStart, usageCollection?: UsageCollectionSetup ) => { @@ -36,6 +38,7 @@ const renderApp = ( data, spacesApi, usageCollection, + fieldFormats, }), element ); @@ -66,6 +69,7 @@ export async function mountApp( coreStart, pluginsStart.share, pluginsStart.data, + pluginsStart.fieldFormats, pluginsStart.spaces, deps.usageCollection ); diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index 47e2b9babb4a..38d4b9795abe 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -21,7 +21,6 @@ import { useRefresh } from '../use_refresh'; import { useResolver } from '../use_resolver'; import { basicResolvers } from '../resolvers'; import { Explorer } from '../../explorer'; -import { useSelectedCells } from '../../explorer/hooks/use_selected_cells'; import { mlJobService } from '../../services/job_service'; import { ml } from '../../services/ml_api_service'; import { useExplorerData } from '../../explorer/actions'; @@ -33,7 +32,6 @@ import { useTableSeverity } from '../../components/controls/select_severity'; import { useUrlState } from '../../util/url_state'; import { getBreadcrumbWithUrlForApp } from '../breadcrumbs'; import { useTimefilter } from '../../contexts/kibana'; -import { isViewBySwimLaneData } from '../../explorer/swimlane_container'; import { JOB_ID } from '../../../../common/constants/anomalies'; import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context'; import { AnnotationUpdatesService } from '../../services/annotations_service'; @@ -42,6 +40,11 @@ import { useTimeBuckets } from '../../components/custom_hooks/use_time_buckets'; import { MlPageHeader } from '../../components/page_header'; import { AnomalyResultsViewSelector } from '../../components/anomaly_results_view_selector'; import { AnomalyDetectionEmptyState } from '../../jobs/jobs_list/components/anomaly_detection_empty_state'; +import { + AnomalyExplorerContext, + useAnomalyExplorerContextValue, +} from '../../explorer/anomaly_explorer_context'; +import type { AnomalyExplorerSwimLaneUrlState } from '../../../../common/types/locator'; export const explorerRouteFactory = ( navigateToPath: NavigateToPath, @@ -94,7 +97,9 @@ interface ExplorerUrlStateManagerProps { } const ExplorerUrlStateManager: FC = ({ jobsWithTimeRange }) => { - const [explorerUrlState, setExplorerUrlState] = useExplorerUrlState(); + const [explorerUrlState, setExplorerUrlState, explorerUrlStateService] = useExplorerUrlState(); + + const anomalyExplorerContext = useAnomalyExplorerContextValue(explorerUrlStateService); const [globalState] = useUrlState('_g'); const [stoppedPartitions, setStoppedPartitions] = useState(); @@ -108,7 +113,6 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim (job) => jobIds.includes(job.id) && job.isRunning === true ); - const explorerAppState = useObservable(explorerService.appState$); const explorerState = useObservable(explorerService.state$); const refresh = useRefresh(); @@ -147,14 +151,41 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim } }, []); - useEffect(() => { - if (jobIds.length > 0) { - explorerService.updateJobSelection(jobIds); - getJobsWithStoppedPartitions(jobIds); - } else { - explorerService.clearJobs(); - } - }, [JSON.stringify(jobIds)]); + const updateSwimLaneUrlState = useCallback( + (update: AnomalyExplorerSwimLaneUrlState | undefined, replaceState = false) => { + const ccc = explorerUrlState?.mlExplorerSwimlane; + const resultUpdate = replaceState ? update : { ...ccc, ...update }; + return setExplorerUrlState({ + ...explorerUrlState, + mlExplorerSwimlane: resultUpdate, + }); + }, + [explorerUrlState, setExplorerUrlState] + ); + + useEffect( + // TODO URL state service should provide observable with updates + // and immutable method for updates + function updateAnomalyTimelineStateFromUrl() { + const { anomalyTimelineStateService } = anomalyExplorerContext; + + anomalyTimelineStateService.updateSetStateCallback(updateSwimLaneUrlState); + anomalyTimelineStateService.updateFromUrlState(explorerUrlState?.mlExplorerSwimlane); + }, + [explorerUrlState?.mlExplorerSwimlane, updateSwimLaneUrlState] + ); + + useEffect( + function handleJobSelection() { + if (jobIds.length > 0) { + explorerService.updateJobSelection(jobIds); + getJobsWithStoppedPartitions(jobIds); + } else { + explorerService.clearJobs(); + } + }, + [JSON.stringify(jobIds)] + ); useEffect(() => { return () => { @@ -164,48 +195,6 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim }; }, []); - /** - * TODO get rid of the intermediate state in explorerService. - * URL state should be the only source of truth for related props. - */ - useEffect(() => { - const filterData = explorerUrlState?.mlExplorerFilter; - if (filterData !== undefined) { - explorerService.setFilterData(filterData); - } - - const { viewByFieldName, viewByFromPage, viewByPerPage, severity } = - explorerUrlState?.mlExplorerSwimlane ?? {}; - - if (viewByFieldName !== undefined) { - explorerService.setViewBySwimlaneFieldName(viewByFieldName); - } - - if (viewByPerPage !== undefined) { - explorerService.setViewByPerPage(viewByPerPage); - } - - if (viewByFromPage !== undefined) { - explorerService.setViewByFromPage(viewByFromPage); - } - - if (severity !== undefined) { - explorerService.setSwimLaneSeverity(severity); - } - - if (explorerUrlState.mlShowCharts !== undefined) { - explorerService.setShowCharts(explorerUrlState.mlShowCharts); - } - }, []); - - /** Sync URL state with {@link explorerService} state */ - useEffect(() => { - const replaceState = explorerUrlState?.mlExplorerSwimlane?.viewByFieldName === undefined; - if (explorerAppState?.mlExplorerSwimlane?.viewByFieldName !== undefined) { - setExplorerUrlState(explorerAppState, replaceState); - } - }, [explorerAppState]); - const [explorerData, loadExplorerData] = useExplorerData(); useEffect(() => { @@ -217,60 +206,75 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim const [tableInterval] = useTableInterval(); const [tableSeverity] = useTableSeverity(); - const [selectedCells, setSelectedCells] = useSelectedCells( - explorerUrlState, - setExplorerUrlState, - explorerState?.swimlaneBucketInterval?.asSeconds() + const showCharts = useObservable( + anomalyExplorerContext.anomalyExplorerCommonStateService.getShowCharts$(), + anomalyExplorerContext.anomalyExplorerCommonStateService.getShowCharts() ); - useEffect(() => { - explorerService.setSelectedCells(selectedCells); - }, [JSON.stringify(selectedCells)]); + const selectedCells = useObservable( + anomalyExplorerContext.anomalyTimelineStateService.getSelectedCells$() + ); + + const swimlaneContainerWidth = useObservable( + anomalyExplorerContext.anomalyTimelineStateService.getContainerWidth$(), + anomalyExplorerContext.anomalyTimelineStateService.getContainerWidth() + ); + + const viewByFieldName = useObservable( + anomalyExplorerContext.anomalyTimelineStateService.getViewBySwimlaneFieldName$() + ); + + const swimLaneSeverity = useObservable( + anomalyExplorerContext.anomalyTimelineStateService.getSwimLaneSeverity$(), + anomalyExplorerContext.anomalyTimelineStateService.getSwimLaneSeverity() + ); + + const swimLaneBucketInterval = useObservable( + anomalyExplorerContext.anomalyTimelineStateService.getSwimLaneBucketInterval$(), + anomalyExplorerContext.anomalyTimelineStateService.getSwimLaneBucketInterval() + ); + + const influencersFilterQuery = useObservable( + anomalyExplorerContext.anomalyExplorerCommonStateService.getInfluencerFilterQuery$() + ); const loadExplorerDataConfig = explorerState !== undefined ? { lastRefresh, - influencersFilterQuery: explorerState.influencersFilterQuery, + influencersFilterQuery, noInfluencersConfigured: explorerState.noInfluencersConfigured, selectedCells, selectedJobs: explorerState.selectedJobs, - swimlaneBucketInterval: explorerState.swimlaneBucketInterval, + swimlaneBucketInterval: swimLaneBucketInterval, tableInterval: tableInterval.val, tableSeverity: tableSeverity.val, - viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName, - swimlaneContainerWidth: explorerState.swimlaneContainerWidth, - viewByPerPage: explorerState.viewByPerPage, - viewByFromPage: explorerState.viewByFromPage, - swimLaneSeverity: explorerState.swimLaneSeverity, + viewBySwimlaneFieldName: viewByFieldName, + swimlaneContainerWidth, } : undefined; + useEffect( + function updateAnomalyExplorerCommonState() { + anomalyExplorerContext.anomalyExplorerCommonStateService.setSelectedJobs( + loadExplorerDataConfig?.selectedJobs! + ); + }, + [loadExplorerDataConfig] + ); + useEffect(() => { - /** - * For the "View by" swim lane the limit is the cardinality of the influencer values, - * which is known after the initial fetch. - * When looking up for top influencers for selected range in Overall swim lane - * the result is filtered by top influencers values, hence there is no need to set the limit. - */ - const swimlaneLimit = - isViewBySwimLaneData(explorerState?.viewBySwimlaneData) && !selectedCells?.showTopFieldValues - ? explorerState?.viewBySwimlaneData.cardinality - : undefined; - - if (explorerState && explorerState.swimlaneContainerWidth > 0) { - loadExplorerData({ - ...loadExplorerDataConfig, - swimlaneLimit, - }); + if (explorerState && loadExplorerDataConfig?.swimlaneContainerWidth! > 0) { + loadExplorerData(loadExplorerDataConfig); } - }, [JSON.stringify(loadExplorerDataConfig), selectedCells?.showTopFieldValues]); + }, [JSON.stringify(loadExplorerDataConfig)]); + + const overallSwimlaneData = useObservable( + anomalyExplorerContext.anomalyTimelineStateService.getOverallSwimLaneData$(), + null + ); - if ( - explorerState === undefined || - refresh === undefined || - explorerAppState?.mlShowCharts === undefined - ) { + if (explorerState === undefined || refresh === undefined) { return null; } @@ -286,23 +290,27 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim - {jobsWithTimeRange.length === 0 ? ( - - ) : ( - - )} + + {jobsWithTimeRange.length === 0 ? ( + + ) : ( + + )} +
    ); }; diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts index c271c004f366..ca1183129cfa 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts @@ -729,6 +729,7 @@ export class AnomalyExplorerChartsService { config: SeriesConfigWithMetadata, range: ChartRange ) { + // FIXME performs an API call per chart. should perform 1 call for all charts return mlResultsService .getScheduledEventsByBucket( [config.jobId], diff --git a/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts index df6f9bcf1ac7..e22f5680532b 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts @@ -186,7 +186,7 @@ export class AnomalyTimelineService { influencersFilterQuery?: any, bucketInterval?: TimeBucketsInterval, swimLaneSeverity?: number - ): Promise { + ): Promise { const timefilterBounds = this.getTimeBounds(); if (timefilterBounds === undefined) { @@ -353,7 +353,7 @@ export class AnomalyTimelineService { bounds: any, viewBySwimlaneFieldName: string, interval: number - ): OverallSwimlaneData { + ): ViewBySwimLaneData { // Processes the scores for the 'view by' swim lane. // Sorts the lanes according to the supplied array of lane // values in the order in which they should be displayed, 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 b6575c48b21f..3d49d03c1cde 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 @@ -43,6 +43,8 @@ declare interface JobService { getJobAndGroupIds(): Promise; getJob(jobId: string): CombinedJob; loadJobsWrapper(): Promise; + customUrlsByJob: Record; + detectorsByJob: Record; } export const mlJobService: JobService; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts index a9f6dbb45f6e..5275680c499b 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts @@ -21,12 +21,13 @@ import { ESSearchResponse, } from '../../../../../../../src/core/types/elasticsearch'; import { MLAnomalyDoc } from '../../../../common/types/anomalies'; +import type { EntityField } from '../../../../common/util/anomaly_utils'; export const resultsApiProvider = (httpService: HttpService) => ({ getAnomaliesTableData( jobIds: string[], criteriaFields: string[], - influencers: string[], + influencers: EntityField[], aggregationInterval: string, threshold: number, earliestMs: number, diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts index b256f4530ff0..ff43cb75653a 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts @@ -7,16 +7,21 @@ // Service for managing job saved objects +import { useMemo } from 'react'; +import { useMlKibana } from '../../contexts/kibana'; + import { HttpService } from '../http_service'; import { basePath } from './index'; import { JobType, + TrainedModelType, CanDeleteJobResponse, SyncSavedObjectResponse, InitializeSavedObjectResponse, SavedObjectResult, JobsSpacesResponse, + TrainedModelsSpacesResponse, SyncCheckResponse, } from '../../../../common/types/saved_objects'; @@ -62,7 +67,7 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({ query: { simulate }, }); }, - syncCheck(jobType?: JobType) { + syncCheck(jobType?: JobType | TrainedModelType) { const body = JSON.stringify({ jobType }); return httpService.http({ path: `${basePath()}/saved_objects/sync_check`, @@ -78,4 +83,32 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({ body, }); }, + trainedModelsSpaces() { + return httpService.http({ + path: `${basePath()}/saved_objects/trained_models_spaces`, + method: 'GET', + }); + }, + updateModelsSpaces(modelIds: string[], spacesToAdd: string[], spacesToRemove: string[]) { + const body = JSON.stringify({ modelIds, spacesToAdd, spacesToRemove }); + return httpService.http({ + path: `${basePath()}/saved_objects/update_trained_models_spaces`, + method: 'POST', + body, + }); + }, }); + +type SavedObjectsApiService = ReturnType; + +/** + * Hooks for accessing {@link TrainedModelsApiService} in React components. + */ +export function useSavedObjectsApiService(): SavedObjectsApiService { + const { + services: { + mlServices: { httpService }, + }, + } = useMlKibana(); + return useMemo(() => savedObjectsApiProvider(httpService), [httpService]); +} 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 8ea7ff07345e..dcadb1ab2c50 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 @@ -61,13 +61,10 @@ export function trainedModelsApiProvider(httpService: HttpService) { * @param params - Optional query params */ getTrainedModels(modelId?: string | string[], params?: InferenceQueryParams) { - let model = modelId ?? ''; - if (Array.isArray(modelId)) { - model = modelId.join(','); - } + const model = Array.isArray(modelId) ? modelId.join(',') : modelId; return httpService.http({ - path: `${apiBasePath}/trained_models${model && `/${model}`}`, + path: `${apiBasePath}/trained_models${model ? `/${model}` : ''}`, method: 'GET', ...(params ? { query: params as HttpFetchQuery } : {}), }); @@ -81,10 +78,10 @@ export function trainedModelsApiProvider(httpService: HttpService) { * @param params - Optional query params */ getTrainedModelStats(modelId?: string | string[], params?: InferenceStatsQueryParams) { - const model = (Array.isArray(modelId) ? modelId.join(',') : modelId) || '_all'; + const model = Array.isArray(modelId) ? modelId.join(',') : modelId; return httpService.http({ - path: `${apiBasePath}/trained_models/${model}/_stats`, + path: `${apiBasePath}/trained_models${model ? `/${model}` : ''}/_stats`, method: 'GET', }); }, diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts index f40db03ab446..d6fef2f0a965 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts @@ -28,7 +28,7 @@ export function resultsServiceProvider(mlApiServices: MlApiServices): { selectedJobIds: string[], earliestMs: number, latestMs: number, - maxFieldValues: number, + maxFieldValues?: number, perPage?: number, fromPage?: number, influencers?: EntityField[], diff --git a/x-pack/plugins/ml/public/application/services/timefilter_refresh_service.tsx b/x-pack/plugins/ml/public/application/services/timefilter_refresh_service.tsx index fa11b830a386..4fcedcba56b1 100644 --- a/x-pack/plugins/ml/public/application/services/timefilter_refresh_service.tsx +++ b/x-pack/plugins/ml/public/application/services/timefilter_refresh_service.tsx @@ -7,6 +7,6 @@ import { Subject } from 'rxjs'; -import { Refresh } from '../routing/use_refresh'; +import type { Refresh } from '../routing/use_refresh'; export const mlTimefilterRefresh$ = new Subject(); 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 97a6fd3eb7b2..9f399046751e 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 @@ -17,6 +17,7 @@ import { EuiSpacer, EuiTitle, SearchFilterConfig, + EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -29,6 +30,7 @@ import { ModelsTableToConfigMapping } from './index'; import { ModelsBarStats, StatsBar } from '../../components/stats_bar'; import { useMlKibana, useMlLocator, useNavigateToPath, useTimefilter } from '../../contexts/kibana'; import { useTrainedModelsApiService } from '../../services/ml_api_service/trained_models'; +import { useSavedObjectsApiService } from '../../services/ml_api_service/saved_objects'; import { ModelPipelines, TrainedModelConfigResponse, @@ -47,8 +49,10 @@ import { useToastNotificationService } from '../../services/toast_notification_s import { useFieldFormatter } from '../../contexts/kibana/use_field_formatter'; import { FIELD_FORMAT_IDS } from '../../../../../../../src/plugins/field_formats/common'; import { useRefresh } from '../../routing/use_refresh'; -import { DEPLOYMENT_STATE } from '../../../../common/constants/trained_models'; +import { DEPLOYMENT_STATE, TRAINED_MODEL_TYPE } from '../../../../common/constants/trained_models'; import { getUserConfirmationProvider } from './force_stop_dialog'; +import { JobSpacesList } from '../../components/job_spaces_list'; +import { SavedObjectsWarning } from '../../components/saved_objects_warning'; type Stats = Omit; @@ -72,12 +76,23 @@ export const BUILT_IN_MODEL_TYPE = i18n.translate( { defaultMessage: 'built-in' } ); -export const ModelsList: FC = () => { +interface Props { + isManagementTable?: boolean; + pageState?: ListingPageUrlState; + updatePageState?: (update: Partial) => void; +} + +export const ModelsList: FC = ({ + isManagementTable = false, + pageState: pageStateExternal, + updatePageState: updatePageStateExternal, +}) => { const { services: { application: { navigateToUrl, capabilities }, overlays, theme, + spacesApi, }, } = useMlKibana(); const urlLocator = useMlLocator()!; @@ -86,18 +101,28 @@ export const ModelsList: FC = () => { const dateFormatter = useFieldFormatter(FIELD_FORMAT_IDS.DATE); - const [pageState, updatePageState] = usePageUrlState( + // allow for an internally controlled page state which stores the state in the URL + // or an external page state, which is passed in as a prop. + // external page state is used on the management page. + const [pageStateInternal, updatePageStateInternal] = usePageUrlState( ML_PAGES.TRAINED_MODELS_MANAGE, getDefaultModelsListState() ); + const [pageState, updatePageState] = + pageStateExternal && updatePageStateExternal + ? [pageStateExternal, updatePageStateExternal] + : [pageStateInternal, updatePageStateInternal]; + const refresh = useRefresh(); const searchQueryText = pageState.queryText ?? ''; - const canDeleteDataFrameAnalytics = capabilities.ml.canDeleteDataFrameAnalytics as boolean; + const canDeleteTrainedModels = capabilities.ml.canDeleteTrainedModels as boolean; + const canStartStopTrainedModels = capabilities.ml.canStartStopTrainedModels as boolean; const trainedModelsApiService = useTrainedModelsApiService(); + const savedObjectsApiService = useSavedObjectsApiService(); const { displayErrorToast, displayDangerToast, displaySuccessToast } = useToastNotificationService(); @@ -106,6 +131,7 @@ export const ModelsList: FC = () => { const [items, setItems] = useState([]); const [selectedModels, setSelectedModels] = useState([]); const [modelsToDelete, setModelsToDelete] = useState([]); + const [modelSpaces, setModelSpaces] = useState<{ [modelId: string]: string[] }>({}); const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState>( {} ); @@ -123,11 +149,16 @@ export const ModelsList: FC = () => { * Fetches trained models. */ const fetchModelsData = useCallback(async () => { + setIsLoading(true); try { const response = await trainedModelsApiService.getTrainedModels(undefined, { with_pipelines: true, size: 1000, }); + if (isManagementTable) { + const { trainedModels } = await savedObjectsApiService.trainedModelsSpaces(); + setModelSpaces(trainedModels); + } const newItems: ModelItem[] = []; const expandedItemsToRefresh = []; @@ -154,7 +185,9 @@ 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('pytorch'))); + await fetchModelsStats( + newItems.filter((v) => v.model_type.includes(TRAINED_MODEL_TYPE.PYTORCH)) + ); setItems(newItems); @@ -357,125 +390,141 @@ export const ModelsList: FC = () => { await navigateToPath(path, false); }, }, - { - name: i18n.translate('xpack.ml.inference.modelsList.startModelDeploymentActionLabel', { - defaultMessage: 'Start deployment', - }), - description: i18n.translate('xpack.ml.inference.modelsList.startModelDeploymentActionLabel', { - defaultMessage: 'Start deployment', - }), - icon: 'play', - type: 'icon', - isPrimary: true, - enabled: (item) => { - const { state } = item.stats?.deployment_stats ?? {}; - return ( - !isLoading && state !== DEPLOYMENT_STATE.STARTED && state !== DEPLOYMENT_STATE.STARTING - ); - }, - available: (item) => item.model_type === 'pytorch', - onClick: async (item) => { - try { - setIsLoading(true); - await trainedModelsApiService.startModelAllocation(item.model_id); - displaySuccessToast( - i18n.translate('xpack.ml.trainedModels.modelsList.startSuccess', { - defaultMessage: 'Deployment for "{modelId}" has been started successfully.', - values: { - modelId: item.model_id, - }, - }) - ); - await fetchModelsData(); - } catch (e) { - displayErrorToast( - e, - i18n.translate('xpack.ml.trainedModels.modelsList.startFailed', { - defaultMessage: 'Failed to start "{modelId}"', - values: { - modelId: item.model_id, - }, - }) - ); - setIsLoading(false); - } - }, - }, - { - name: i18n.translate('xpack.ml.inference.modelsList.stopModelDeploymentActionLabel', { - defaultMessage: 'Stop deployment', - }), - description: i18n.translate('xpack.ml.inference.modelsList.stopModelDeploymentActionLabel', { - defaultMessage: 'Stop deployment', - }), - icon: 'stop', - type: 'icon', - isPrimary: true, - available: (item) => item.model_type === 'pytorch', - enabled: (item) => - !isLoading && - isPopulatedObject(item.stats?.deployment_stats) && - item.stats?.deployment_stats?.state !== DEPLOYMENT_STATE.STOPPING, - onClick: async (item) => { - const requireForceStop = isPopulatedObject(item.pipelines); - - if (requireForceStop) { - const hasUserApproved = await getUserConfirmation(item); - if (!hasUserApproved) return; - } - - try { - setIsLoading(true); - await trainedModelsApiService.stopModelAllocation(item.model_id, { - force: requireForceStop, - }); - displaySuccessToast( - i18n.translate('xpack.ml.trainedModels.modelsList.stopSuccess', { - defaultMessage: 'Deployment for "{modelId}" has been stopped successfully.', - values: { - modelId: item.model_id, - }, - }) - ); - // Need to fetch model state updates - await fetchModelsData(); - } catch (e) { - displayErrorToast( - e, - i18n.translate('xpack.ml.trainedModels.modelsList.stopFailed', { - defaultMessage: 'Failed to stop "{modelId}"', - values: { - modelId: item.model_id, - }, - }) - ); - setIsLoading(false); - } - }, - }, - { - name: i18n.translate('xpack.ml.trainedModels.modelsList.deleteModelActionLabel', { - defaultMessage: 'Delete model', - }), - description: i18n.translate('xpack.ml.trainedModels.modelsList.deleteModelActionLabel', { - defaultMessage: 'Delete model', - }), - 'data-test-subj': 'mlModelsTableRowDeleteAction', - icon: 'trash', - type: 'icon', - color: 'danger', - isPrimary: false, - onClick: async (model) => { - await prepareModelsForDeletion([model]); - }, - available: (item) => canDeleteDataFrameAnalytics && !isBuiltInModel(item), - enabled: (item) => { - // TODO check for permissions to delete ingest pipelines. - // ATM undefined means pipelines fetch failed server-side. - return !isPopulatedObject(item.pipelines); - }, - }, ]; + if (isManagementTable === false) { + actions.push( + ...([ + { + name: i18n.translate('xpack.ml.inference.modelsList.startModelDeploymentActionLabel', { + defaultMessage: 'Start deployment', + }), + description: i18n.translate( + 'xpack.ml.inference.modelsList.startModelDeploymentActionLabel', + { + defaultMessage: 'Start deployment', + } + ), + icon: 'play', + type: 'icon', + isPrimary: true, + enabled: (item) => { + const { state } = item.stats?.deployment_stats ?? {}; + return ( + canStartStopTrainedModels && + !isLoading && + state !== DEPLOYMENT_STATE.STARTED && + state !== DEPLOYMENT_STATE.STARTING + ); + }, + available: (item) => item.model_type === TRAINED_MODEL_TYPE.PYTORCH, + onClick: async (item) => { + try { + setIsLoading(true); + await trainedModelsApiService.startModelAllocation(item.model_id); + displaySuccessToast( + i18n.translate('xpack.ml.trainedModels.modelsList.startSuccess', { + defaultMessage: 'Deployment for "{modelId}" has been started successfully.', + values: { + modelId: item.model_id, + }, + }) + ); + await fetchModelsData(); + } catch (e) { + displayErrorToast( + e, + i18n.translate('xpack.ml.trainedModels.modelsList.startFailed', { + defaultMessage: 'Failed to start "{modelId}"', + values: { + modelId: item.model_id, + }, + }) + ); + setIsLoading(false); + } + }, + }, + { + name: i18n.translate('xpack.ml.inference.modelsList.stopModelDeploymentActionLabel', { + defaultMessage: 'Stop deployment', + }), + description: i18n.translate( + 'xpack.ml.inference.modelsList.stopModelDeploymentActionLabel', + { + defaultMessage: 'Stop deployment', + } + ), + icon: 'stop', + type: 'icon', + isPrimary: true, + available: (item) => item.model_type === TRAINED_MODEL_TYPE.PYTORCH, + enabled: (item) => + canStartStopTrainedModels && + !isLoading && + isPopulatedObject(item.stats?.deployment_stats) && + item.stats?.deployment_stats?.state !== DEPLOYMENT_STATE.STOPPING, + onClick: async (item) => { + const requireForceStop = isPopulatedObject(item.pipelines); + + if (requireForceStop) { + const hasUserApproved = await getUserConfirmation(item); + if (!hasUserApproved) return; + } + + try { + setIsLoading(true); + await trainedModelsApiService.stopModelAllocation(item.model_id, { + force: requireForceStop, + }); + displaySuccessToast( + i18n.translate('xpack.ml.trainedModels.modelsList.stopSuccess', { + defaultMessage: 'Deployment for "{modelId}" has been stopped successfully.', + values: { + modelId: item.model_id, + }, + }) + ); + // Need to fetch model state updates + await fetchModelsData(); + } catch (e) { + displayErrorToast( + e, + i18n.translate('xpack.ml.trainedModels.modelsList.stopFailed', { + defaultMessage: 'Failed to stop "{modelId}"', + values: { + modelId: item.model_id, + }, + }) + ); + setIsLoading(false); + } + }, + }, + { + name: i18n.translate('xpack.ml.trainedModels.modelsList.deleteModelActionLabel', { + defaultMessage: 'Delete model', + }), + description: i18n.translate('xpack.ml.trainedModels.modelsList.deleteModelActionLabel', { + defaultMessage: 'Delete model', + }), + 'data-test-subj': 'mlModelsTableRowDeleteAction', + icon: 'trash', + type: 'icon', + color: 'danger', + isPrimary: false, + onClick: async (model) => { + await prepareModelsForDeletion([model]); + }, + available: (item) => canDeleteTrainedModels && !isBuiltInModel(item), + enabled: (item) => { + // TODO check for permissions to delete ingest pipelines. + // ATM undefined means pipelines fetch failed server-side. + return !isPopulatedObject(item.pipelines); + }, + }, + ] as Array>) + ); + } const toggleDetails = async (item: ModelItem) => { const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; @@ -582,6 +631,29 @@ export const ModelsList: FC = () => { }, ]; + if (isManagementTable) { + columns.splice(columns.length - 1, 0, { + field: ModelsTableToConfigMapping.id, + name: i18n.translate('xpack.ml.trainedModels.modelsList.spacesLabel', { + defaultMessage: 'Spaces', + }), + render: (id: string) => { + const spaces = modelSpaces[id]; + return ( + + ); + }, + sortable: false, + 'data-test-subj': 'mlModelsTableColumnSpacesLabel', + }); + } + const filters: SearchFilterConfig[] = inferenceTypesOptions && inferenceTypesOptions.length > 0 ? [ @@ -623,7 +695,7 @@ export const ModelsList: FC = () => {
    ); - const isSelectionAllowed = canDeleteDataFrameAnalytics; + const isSelectionAllowed = canDeleteTrainedModels; const selection: EuiTableSelectionType | undefined = isSelectionAllowed ? { @@ -686,12 +758,27 @@ export const ModelsList: FC = () => { return ( <> - + {isManagementTable ? null : ( + <> + + + )} {modelsStats && ( - - - + <> + + + + {isManagementTable ? ( + + + + ) : null} + )} @@ -707,7 +794,7 @@ export const ModelsList: FC = () => { itemId={ModelsTableToConfigMapping.id} loading={isLoading} search={search} - selection={selection} + selection={isManagementTable ? undefined : selection} rowProps={(item) => ({ 'data-test-subj': `mlModelsTableRow row-${item.model_id}`, })} @@ -731,3 +818,21 @@ export const ModelsList: FC = () => { ); }; + +export const RefreshModelsListButton: FC<{ refresh: () => Promise; isLoading: boolean }> = ({ + refresh, + isLoading, +}) => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/util/url_state.tsx b/x-pack/plugins/ml/public/application/util/url_state.tsx index 7b20b841a9d9..09be67a2203e 100644 --- a/x-pack/plugins/ml/public/application/util/url_state.tsx +++ b/x-pack/plugins/ml/public/application/util/url_state.tsx @@ -6,15 +6,26 @@ */ import { parse, stringify } from 'query-string'; -import React, { createContext, useCallback, useContext, useMemo, FC } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useMemo, + FC, + useRef, + useEffect, +} from 'react'; import { isEqual } from 'lodash'; import { decode, encode } from 'rison-node'; import { useHistory, useLocation } from 'react-router-dom'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { distinctUntilChanged } from 'rxjs/operators'; import { Dictionary } from '../../../common/types/common'; import { getNestedProperty } from './object_utils'; import { MlPages } from '../../../common/constants/locator'; +import { isPopulatedObject } from '../../../common'; type Accessor = '_a' | '_g'; export type SetUrlState = ( @@ -146,7 +157,12 @@ export const UrlStateProvider: FC = ({ children }) => { return {children}; }; -export const useUrlState = (accessor: Accessor) => { +export const useUrlState = ( + accessor: Accessor +): [ + Record, + (attribute: string | Dictionary, value?: unknown, replaceState?: boolean) => void +] => { const { searchString, setUrlState: setUrlStateContext } = useContext(urlStateStore); const urlState = useMemo(() => { @@ -157,7 +173,7 @@ export const useUrlState = (accessor: Accessor) => { }, [searchString]); const setUrlState = useCallback( - (attribute: string | Dictionary, value?: any, replaceState?: boolean) => { + (attribute: string | Dictionary, value?: unknown, replaceState?: boolean) => { setUrlStateContext(accessor, attribute, value, replaceState); }, [accessor, setUrlStateContext] @@ -174,26 +190,90 @@ export type AppStateKey = | MlPages | LegacyUrlKeys; +/** + * Service for managing URL state of particular page. + */ +export class PageUrlStateService { + private _pageUrlState$ = new BehaviorSubject(null); + private _pageUrlStateCallback: ((update: Partial, replaceState?: boolean) => void) | null = + null; + + /** + * Provides updates for the page URL state. + */ + public getPageUrlState$(): Observable { + return this._pageUrlState$.pipe(distinctUntilChanged(isEqual)); + } + + public updateUrlState(update: Partial, replaceState?: boolean): void { + if (!this._pageUrlStateCallback) { + throw new Error('Callback has not been initialized.'); + } + this._pageUrlStateCallback(update, replaceState); + } + + public setCurrentState(currentState: T): void { + this._pageUrlState$.next(currentState); + } + + public setUpdateCallback(callback: (update: Partial, replaceState?: boolean) => void): void { + this._pageUrlStateCallback = callback; + } +} + /** * Hook for managing the URL state of the page. */ -export const usePageUrlState = ( +export const usePageUrlState = ( pageKey: AppStateKey, defaultState?: PageUrlState -): [PageUrlState, (update: Partial, replaceState?: boolean) => void] => { +): [ + PageUrlState, + (update: Partial, replaceState?: boolean) => void, + PageUrlStateService +] => { const [appState, setAppState] = useUrlState('_a'); const pageState = appState?.[pageKey]; + const setCallback = useRef(); + + useEffect(() => { + setCallback.current = setAppState; + }, [setAppState]); + + const prevPageState = useRef(); + const resultPageState: PageUrlState = useMemo(() => { - return { + const result = { ...(defaultState ?? {}), ...(pageState ?? {}), }; + + if (isEqual(result, prevPageState.current)) { + return prevPageState.current; + } + + // Compare prev and current states to only update changed values + if (isPopulatedObject(prevPageState.current)) { + for (const key in result) { + if (isEqual(result[key], prevPageState.current[key])) { + result[key] = prevPageState.current[key]; + } + } + } + + prevPageState.current = result; + + return result; }, [pageState]); const onStateUpdate = useCallback( (update: Partial, replaceState?: boolean) => { - setAppState( + if (!setCallback?.current) { + throw new Error('Callback for URL state update has not been initialized.'); + } + + setCallback.current( pageKey, { ...resultPageState, @@ -202,10 +282,20 @@ export const usePageUrlState = ( replaceState ); }, - [pageKey, resultPageState, setAppState] + [pageKey, resultPageState] + ); + + const pageUrlStateService = useMemo(() => new PageUrlStateService(), []); + + useEffect( + function updatePageUrlService() { + pageUrlStateService.setCurrentState(resultPageState); + pageUrlStateService.setUpdateCallback(onStateUpdate); + }, + [pageUrlStateService, onStateUpdate, resultPageState] ); return useMemo(() => { - return [resultPageState, onStateUpdate]; - }, [resultPageState, onStateUpdate]); + return [resultPageState, onStateUpdate, pageUrlStateService]; + }, [resultPageState, onStateUpdate, pageUrlStateService]); }; diff --git a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts index c853179e7121..654c3db184aa 100644 --- a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts +++ b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts @@ -47,7 +47,7 @@ describe('check_capabilities', () => { ); const { capabilities } = await getCapabilities(); const count = Object.keys(capabilities).length; - expect(count).toBe(32); + expect(count).toBe(36); }); }); @@ -75,6 +75,7 @@ describe('check_capabilities', () => { expect(capabilities.canCreateAnnotation).toBe(true); expect(capabilities.canDeleteAnnotation).toBe(true); expect(capabilities.canUseMlAlerts).toBe(true); + expect(capabilities.canGetTrainedModels).toBe(true); expect(capabilities.canCreateJob).toBe(false); expect(capabilities.canDeleteJob).toBe(false); @@ -98,6 +99,9 @@ describe('check_capabilities', () => { expect(capabilities.canStartStopDataFrameAnalytics).toBe(false); expect(capabilities.canCreateMlAlerts).toBe(false); expect(capabilities.canViewMlNodes).toBe(false); + expect(capabilities.canCreateTrainedModels).toBe(false); + expect(capabilities.canDeleteTrainedModels).toBe(false); + expect(capabilities.canStartStopTrainedModels).toBe(false); }); test('full capabilities', async () => { @@ -122,6 +126,8 @@ describe('check_capabilities', () => { expect(capabilities.canGetAnnotations).toBe(true); expect(capabilities.canCreateAnnotation).toBe(true); expect(capabilities.canDeleteAnnotation).toBe(true); + expect(capabilities.canUseMlAlerts).toBe(true); + expect(capabilities.canGetTrainedModels).toBe(true); expect(capabilities.canCreateJob).toBe(true); expect(capabilities.canDeleteJob).toBe(true); @@ -143,7 +149,11 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteDataFrameAnalytics).toBe(true); expect(capabilities.canCreateDataFrameAnalytics).toBe(true); expect(capabilities.canStartStopDataFrameAnalytics).toBe(true); + expect(capabilities.canCreateMlAlerts).toBe(true); expect(capabilities.canViewMlNodes).toBe(true); + expect(capabilities.canCreateTrainedModels).toBe(true); + expect(capabilities.canDeleteTrainedModels).toBe(true); + expect(capabilities.canStartStopTrainedModels).toBe(true); }); test('upgrade in progress with full capabilities', async () => { @@ -168,6 +178,8 @@ describe('check_capabilities', () => { expect(capabilities.canGetAnnotations).toBe(true); expect(capabilities.canCreateAnnotation).toBe(false); expect(capabilities.canDeleteAnnotation).toBe(false); + expect(capabilities.canUseMlAlerts).toBe(false); + expect(capabilities.canGetTrainedModels).toBe(true); expect(capabilities.canCreateJob).toBe(false); expect(capabilities.canDeleteJob).toBe(false); @@ -189,6 +201,11 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteDataFrameAnalytics).toBe(false); expect(capabilities.canCreateDataFrameAnalytics).toBe(false); expect(capabilities.canStartStopDataFrameAnalytics).toBe(false); + expect(capabilities.canCreateMlAlerts).toBe(false); + expect(capabilities.canViewMlNodes).toBe(false); + expect(capabilities.canCreateTrainedModels).toBe(false); + expect(capabilities.canDeleteTrainedModels).toBe(false); + expect(capabilities.canStartStopTrainedModels).toBe(false); }); test('upgrade in progress with partial capabilities', async () => { @@ -213,6 +230,8 @@ describe('check_capabilities', () => { expect(capabilities.canGetAnnotations).toBe(true); expect(capabilities.canCreateAnnotation).toBe(false); expect(capabilities.canDeleteAnnotation).toBe(false); + expect(capabilities.canUseMlAlerts).toBe(false); + expect(capabilities.canGetTrainedModels).toBe(true); expect(capabilities.canCreateJob).toBe(false); expect(capabilities.canDeleteJob).toBe(false); @@ -234,6 +253,11 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteDataFrameAnalytics).toBe(false); expect(capabilities.canCreateDataFrameAnalytics).toBe(false); expect(capabilities.canStartStopDataFrameAnalytics).toBe(false); + expect(capabilities.canCreateMlAlerts).toBe(false); + expect(capabilities.canViewMlNodes).toBe(false); + expect(capabilities.canCreateTrainedModels).toBe(false); + expect(capabilities.canDeleteTrainedModels).toBe(false); + expect(capabilities.canStartStopTrainedModels).toBe(false); }); test('full capabilities, ml disabled in space', async () => { @@ -258,6 +282,8 @@ describe('check_capabilities', () => { expect(capabilities.canGetAnnotations).toBe(false); expect(capabilities.canCreateAnnotation).toBe(false); expect(capabilities.canDeleteAnnotation).toBe(false); + expect(capabilities.canUseMlAlerts).toBe(false); + expect(capabilities.canGetTrainedModels).toBe(false); expect(capabilities.canCreateJob).toBe(false); expect(capabilities.canDeleteJob).toBe(false); @@ -279,6 +305,11 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteDataFrameAnalytics).toBe(false); expect(capabilities.canCreateDataFrameAnalytics).toBe(false); expect(capabilities.canStartStopDataFrameAnalytics).toBe(false); + expect(capabilities.canCreateMlAlerts).toBe(false); + expect(capabilities.canViewMlNodes).toBe(false); + expect(capabilities.canCreateTrainedModels).toBe(false); + expect(capabilities.canDeleteTrainedModels).toBe(false); + expect(capabilities.canStartStopTrainedModels).toBe(false); }); }); @@ -305,6 +336,8 @@ describe('check_capabilities', () => { expect(capabilities.canGetAnnotations).toBe(false); expect(capabilities.canCreateAnnotation).toBe(false); expect(capabilities.canDeleteAnnotation).toBe(false); + expect(capabilities.canUseMlAlerts).toBe(false); + expect(capabilities.canGetTrainedModels).toBe(false); expect(capabilities.canCreateJob).toBe(false); expect(capabilities.canDeleteJob).toBe(false); @@ -326,5 +359,10 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteDataFrameAnalytics).toBe(false); expect(capabilities.canCreateDataFrameAnalytics).toBe(false); expect(capabilities.canStartStopDataFrameAnalytics).toBe(false); + expect(capabilities.canCreateMlAlerts).toBe(false); + expect(capabilities.canViewMlNodes).toBe(false); + expect(capabilities.canCreateTrainedModels).toBe(false); + expect(capabilities.canDeleteTrainedModels).toBe(false); + expect(capabilities.canStartStopTrainedModels).toBe(false); }); }); diff --git a/x-pack/plugins/ml/server/lib/ml_client/errors.ts b/x-pack/plugins/ml/server/lib/ml_client/errors.ts index 23e544613806..c9d2158e0006 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/errors.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/errors.ts @@ -5,6 +5,8 @@ * 2.0. */ +/* eslint-disable max-classes-per-file */ + export class MLJobNotFound extends Error { statusCode = 404; constructor(message?: string) { @@ -12,3 +14,11 @@ export class MLJobNotFound extends Error { Object.setPrototypeOf(this, new.target.prototype); } } + +export class MLModelNotFound extends Error { + statusCode = 404; + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/x-pack/plugins/ml/server/lib/ml_client/index.ts b/x-pack/plugins/ml/server/lib/ml_client/index.ts index b5329ba2cfbc..03fc5e6244b3 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/index.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/index.ts @@ -6,5 +6,5 @@ */ export { getMlClient } from './ml_client'; -export { MLJobNotFound } from './errors'; +export { MLJobNotFound, MLModelNotFound } from './errors'; export type { MlClient } from './types'; 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 3dc71cc64add..11d5de7c45ed 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 @@ -5,21 +5,24 @@ * 2.0. */ +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { IScopedClusterClient } from 'kibana/server'; import { JobSavedObjectService } from '../../saved_objects'; +import { getJobDetailsFromTrainedModel } from '../../saved_objects/util'; import { JobType } from '../../../common/types/saved_objects'; import { Job, Datafeed } from '../../../common/types/anomaly_detection_jobs'; import { searchProvider } from './search'; import { DataFrameAnalyticsConfig } from '../../../common/types/data_frame_analytics'; -import { MLJobNotFound } from './errors'; +import { MLJobNotFound, MLModelNotFound } from './errors'; import { MlClient, MlClientParams, MlGetADParams, MlGetDFAParams, MlGetDatafeedParams, + MlGetTrainedModelParams, } from './types'; export function getMlClient( @@ -32,11 +35,11 @@ export function getMlClient( const jobIds = jobType === 'anomaly-detector' ? getADJobIdsFromRequest(p) : getDFAJobIdsFromRequest(p); if (jobIds.length) { - await checkIds(jobType, jobIds, allowWildcards); + await checkJobIds(jobType, jobIds, allowWildcards); } } - async function checkIds(jobType: JobType, jobIds: string[], allowWildcards: boolean = false) { + async function checkJobIds(jobType: JobType, jobIds: string[], allowWildcards: boolean = false) { const filteredJobIds = await jobSavedObjectService.filterJobIdsForSpace(jobType, jobIds); let missingIds = jobIds.filter((j) => filteredJobIds.indexOf(j) === -1); if (allowWildcards === true && missingIds.join().match('\\*') !== null) { @@ -90,7 +93,7 @@ export function getMlClient( // check the remaining jobs ids if (requestedJobIds.length) { - await checkIds('anomaly-detector', requestedJobIds, true); + await checkJobIds('anomaly-detector', requestedJobIds, true); } } } @@ -100,7 +103,7 @@ export function getMlClient( ...p: Parameters ) { // similar to groupIdsCheck above, however we need to load the jobs first to get the groups information - const ids = getADJobIdsFromRequest(p); + const ids = filterAll(getADJobIdsFromRequest(p)); if (ids.length) { const body = await mlClient.getJobs(...p); await groupIdsCheck(p, body.jobs, filteredJobIds); @@ -124,6 +127,25 @@ export function getMlClient( } } + async function modelIdsCheck(p: MlClientParams, allowWildcards: boolean = false) { + const modelIds = filterAll(getModelIdsFromRequest(p)); + if (modelIds.length) { + await checkModelIds(modelIds, allowWildcards); + } + } + + async function checkModelIds(modelIds: string[], allowWildcards: boolean = false) { + const filteredModelIds = await jobSavedObjectService.filterTrainedModelIdsForSpace(modelIds); + let missingIds = modelIds.filter((j) => filteredModelIds.indexOf(j) === -1); + if (allowWildcards === true && missingIds.join().match('\\*') !== null) { + // filter out wildcard ids from the error + missingIds = missingIds.filter((id) => id.match('\\*') === null); + } + if (missingIds.length) { + throw new MLModelNotFound(`No known model with id '${missingIds.join(',')}'`); + } + } + // @ts-expect-error promise and TransportRequestPromise are incompatible. missing abort return { async closeJob(...p: Parameters) { @@ -178,6 +200,7 @@ export function getMlClient( return mlClient.deleteModelSnapshot(...p); }, async deleteTrainedModel(...p: Parameters) { + await modelIdsCheck(p); return mlClient.deleteTrainedModel(...p); }, async estimateModelMemory(...p: Parameters) { @@ -432,15 +455,45 @@ export function getMlClient( return mlClient.getRecords(...p); }, async getTrainedModels(...p: Parameters) { - return mlClient.getTrainedModels(...p); + await modelIdsCheck(p, true); + try { + const body = await mlClient.getTrainedModels(...p); + const models = + await jobSavedObjectService.filterTrainedModelsForSpace( + body.trained_model_configs, + 'model_id' + ); + return { ...body, count: models.length, trained_model_configs: models }; + } catch (error) { + if (error.statusCode === 404) { + throw new MLModelNotFound(error.body.error.reason); + } + throw error.body ?? error; + } }, async getTrainedModelsStats(...p: Parameters) { - return mlClient.getTrainedModelsStats(...p); + await modelIdsCheck(p, true); + try { + const body = await mlClient.getTrainedModelsStats(...p); + const models = + await jobSavedObjectService.filterTrainedModelsForSpace( + body.trained_model_stats, + 'model_id' + ); + return { ...body, count: models.length, trained_model_stats: models }; + } catch (error) { + if (error.statusCode === 404) { + throw new MLModelNotFound(error.body.error.reason); + } + throw error.body ?? error; + } }, async startTrainedModelDeployment(...p: Parameters) { + await modelIdsCheck(p); return mlClient.startTrainedModelDeployment(...p); }, async stopTrainedModelDeployment(...p: Parameters) { + await modelIdsCheck(p); return mlClient.stopTrainedModelDeployment(...p); }, async info(...p: Parameters) { @@ -497,7 +550,14 @@ export function getMlClient( return resp; }, async putTrainedModel(...p: Parameters) { - return mlClient.putTrainedModel(...p); + const resp = await mlClient.putTrainedModel(...p); + const [modelId] = getModelIdsFromRequest(p); + if (modelId !== undefined) { + const model = (p[0] as estypes.MlPutTrainedModelRequest).body; + const job = getJobDetailsFromTrainedModel(model); + await jobSavedObjectService.createTrainedModel(modelId, job); + } + return resp; }, async revertModelSnapshot(...p: Parameters) { await jobIdsCheck('anomaly-detector', p); @@ -586,6 +646,12 @@ function getDFAJobIdsFromRequest([params]: MlGetDFAParams): string[] { return ids || []; } +function getModelIdsFromRequest([params]: MlGetTrainedModelParams): string[] { + const id = params?.model_id; + const ids = Array.isArray(id) ? id : id?.split(','); + return ids || []; +} + function getADJobIdsFromRequest([params]: MlGetADParams): string[] { const ids = typeof params?.job_id === 'string' ? params?.job_id.split(',') : params?.job_id; return ids || []; @@ -601,3 +667,11 @@ function getJobIdFromBody(p: any): string | undefined { const [params] = p; return params?.body?.job_id; } + +function filterAll(ids: string[]) { + // if _all has been passed as the only id, remove it and assume it was + // an empty list, so all items are returned. + // if _all is one of many ids, the endpoint should look for + // something called _all, which will subsequently fail. + return ids.length === 1 && ids[0] === '_all' ? [] : ids; +} diff --git a/x-pack/plugins/ml/server/lib/ml_client/types.ts b/x-pack/plugins/ml/server/lib/ml_client/types.ts index b4778f4e6d5b..7a2a565e5b04 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/types.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/types.ts @@ -86,3 +86,11 @@ export type MlGetDFAParams = | Parameters | Parameters | Parameters; + +export type MlGetTrainedModelParams = + | Parameters + | Parameters + | Parameters + | Parameters + | Parameters + | Parameters; diff --git a/x-pack/plugins/ml/server/lib/route_guard.ts b/x-pack/plugins/ml/server/lib/route_guard.ts index 6614affdf0ea..0b445eeeae39 100644 --- a/x-pack/plugins/ml/server/lib/route_guard.ts +++ b/x-pack/plugins/ml/server/lib/route_guard.ts @@ -101,6 +101,7 @@ export class RouteGuard { internalSavedObjectsClient, this._spacesPlugin !== undefined, this._authorization, + client, this._isMlReady ); diff --git a/x-pack/plugins/ml/server/models/annotation_service/__mocks__/get_annotations_response.json b/x-pack/plugins/ml/server/models/annotation_service/__mocks__/get_annotations_response.json index a0b8f6b24231..829b9c6581be 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/__mocks__/get_annotations_response.json +++ b/x-pack/plugins/ml/server/models/annotation_service/__mocks__/get_annotations_response.json @@ -15,8 +15,7 @@ "max_score": 0, "hits": [ { - "_index": ".ml-annotations-6", - "_type": "doc", + "_index": ".ml-annotations-000001", "_id": "T-CNvmgBQUJYQVn7TCPA", "_score": 0, "_source": { @@ -32,8 +31,7 @@ } }, { - "_index": ".ml-annotations-6", - "_type": "doc", + "_index": ".ml-annotations-000001", "_id": "3lVpvmgB5xYzd3PM-MSe", "_score": 0, "_source": { diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts index fdeacd148434..e6aa31501a55 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts @@ -41,7 +41,7 @@ describe('annotation_service', () => { const annotationMockId = 'mockId'; const deleteParamsMock: DeleteParams = { - index: '.ml-annotations-6', + index: '.ml-annotations-000001', id: annotationMockId, refresh: 'wait_for', }; diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts index 4717a2ea1ce2..60d633b16097 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts @@ -78,6 +78,31 @@ export interface AggByJob { } export function annotationProvider({ asInternalUser }: IScopedClusterClient) { + // Find the index the annotation is stored in. + async function fetchAnnotationIndex(id: string) { + const searchParams: estypes.SearchRequest = { + index: ML_ANNOTATIONS_INDEX_ALIAS_READ, + size: 1, + body: { + query: { + ids: { + values: [id], + }, + }, + }, + }; + + const body = await asInternalUser.search(searchParams); + const totalCount = + typeof body.hits.total === 'number' ? body.hits.total : body.hits.total!.value; + + if (totalCount === 0) { + throw Boom.notFound(`Cannot find annotation with ID ${id}`); + } + + return body.hits.hits[0]._index; + } + async function indexAnnotation(annotation: Annotation, username: string) { if (isAnnotation(annotation) === false) { // No need to translate, this will not be exposed in the UI. @@ -101,6 +126,8 @@ export function annotationProvider({ asInternalUser }: IScopedClusterClient) { if (typeof annotation._id !== 'undefined') { params.id = annotation._id; + params.index = await fetchAnnotationIndex(annotation._id); + params.require_alias = false; delete params.body._id; delete params.body.key; } @@ -387,28 +414,7 @@ export function annotationProvider({ asInternalUser }: IScopedClusterClient) { } async function deleteAnnotation(id: string) { - // Find the index the annotation is stored in. - const searchParams: estypes.SearchRequest = { - index: ML_ANNOTATIONS_INDEX_ALIAS_READ, - size: 1, - body: { - query: { - ids: { - values: [id], - }, - }, - }, - }; - - const body = await asInternalUser.search(searchParams); - const totalCount = - typeof body.hits.total === 'number' ? body.hits.total : body.hits.total!.value; - - if (totalCount === 0) { - throw Boom.notFound(`Cannot find annotation with ID ${id}`); - } - - const index = body.hits.hits[0]._index; + const index = await fetchAnnotationIndex(id); const deleteParams: DeleteParams = { index, diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/models_provider.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/models_provider.ts index c12f611c011f..714d02704ff9 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/models_provider.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/models_provider.ts @@ -94,7 +94,6 @@ export function modelsProvider( } const { trained_model_stats: trainedModelStats } = await mlClient.getTrainedModelsStats({ - model_id: '_all', size: 10000, }); diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index 3df5016f560c..4a621bc5f608 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -124,6 +124,17 @@ export class DataRecognizer { private _resultsService: ReturnType; private _calculateModelMemoryLimit: ReturnType; + /** + * A temporary cache of configs loaded from disk and from save object service. + * The configs from disk will not change while kibana is running. + * The configs from saved objects could potentially change while an instance of + * DataRecognizer exists, if a fleet package containing modules is installed. + * However the chance of this happening is very low and so the benefit of using + * this cache outweighs the risk of the cache being out of date during the short + * existence of a DataRecognizer instance. + */ + private _configCache: Config[] | null = null; + /** * List of the module jobs that require model memory estimation */ @@ -181,6 +192,10 @@ export class DataRecognizer { } private async _loadConfigs(): Promise { + if (this._configCache !== null) { + return this._configCache; + } + const configs: Config[] = []; const dirs = await this._listDirs(this._modulesDir); await Promise.all( @@ -211,7 +226,9 @@ export class DataRecognizer { isSavedObject: true, })); - return [...configs, ...savedObjectConfigs]; + this._configCache = [...configs, ...savedObjectConfigs]; + + return this._configCache; } private async _loadSavedObjectModules() { diff --git a/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts b/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts index 76e81120c6f3..c40f8b78cb20 100644 --- a/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts +++ b/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts @@ -93,7 +93,11 @@ export function modelSnapshotProvider(client: IScopedClusterClient, mlClient: Ml await cm.newCalendar(calendar); } - forceStartDatafeeds([datafeedId], +snapshot.model_snapshots[0].latest_record_time_stamp, end); + forceStartDatafeeds( + [datafeedId], + +snapshot.model_snapshots[0].latest_record_time_stamp!, + end + ); } return { success: true }; diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 6ce9a8d93be2..8c59c0f9d519 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -62,6 +62,7 @@ import { ML_ALERT_TYPES } from '../common/constants/alerts'; import { alertingRoutes } from './routes/alerting'; import { registerCollector } from './usage'; import { FieldFormatsStart } from '../../../../src/plugins/field_formats/server'; +import { SavedObjectsSyncService } from './saved_objects/sync_task'; export type MlPluginSetup = SharedServices; export type MlPluginStart = void; @@ -81,11 +82,13 @@ export class MlServerPlugin private dataViews: DataViewsPluginStart | null = null; private isMlReady: Promise; private setMlReady: () => void = () => {}; + private savedObjectsSyncService: SavedObjectsSyncService; constructor(ctx: PluginInitializerContext) { this.log = ctx.logger.get(); this.mlLicense = new MlLicense(); this.isMlReady = new Promise((resolve) => (this.setMlReady = resolve)); + this.savedObjectsSyncService = new SavedObjectsSyncService(this.log); } public setup(coreSetup: CoreSetup, plugins: PluginsSetup): MlPluginSetup { @@ -141,6 +144,12 @@ export class MlServerPlugin // initialize capabilities switcher to add license filter to ml capabilities setupCapabilitiesSwitcher(coreSetup, plugins.licensing.license$, this.log); setupSavedObjects(coreSetup.savedObjects); + this.savedObjectsSyncService.registerSyncTask( + plugins.taskManager, + plugins.security, + this.spacesPlugin !== undefined, + () => this.isMlReady + ); const { getInternalSavedObjectsClient, getMlSavedObjectsClient } = savedObjectClientsFactory( () => this.savedObjectsStart @@ -255,6 +264,7 @@ export class MlServerPlugin initializeJobs().finally(() => { this.setMlReady(); }); + this.savedObjectsSyncService.scheduleSyncTask(plugins.taskManager, coreStart); } public stop() { diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index 29edb6106a99..cf1dd61a9593 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -154,17 +154,21 @@ "InitializeJobSavedObjects", "SyncCheck", "UpdateJobsSpaces", + "updateTrainedModelsSpaces", "RemoveJobsFromCurrentSpace", "JobsSpaces", + "TrainedModelsSpaces", "CanDeleteJob", "TrainedModels", "GetTrainedModel", "GetTrainedModelStats", + "GetTrainedModelStatsById", "GetTrainedModelsNodesOverview", "GetTrainedModelPipelines", "StartTrainedModelDeployment", "StopTrainedModelDeployment", + "PutTrainedModel", "DeleteTrainedModel", "Alerting", diff --git a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts index 2ab10bda3619..1fa7217e7d25 100644 --- a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts @@ -609,12 +609,12 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense, routeGuard }: Rout /** * @apiGroup DataFrameAnalytics * - * @api {post} /api/ml/data_frame/analytics/job_exists Check whether jobs exists in current or any space - * @apiName JobExists - * @apiDescription Checks if each of the jobs in the specified list of IDs exist. + * @api {post} /api/ml/data_frame/analytics/jobs_exist Check whether jobs exist in current or any space + * @apiName JobsExist + * @apiDescription Checks if each of the jobs in the specified list of IDs exists. * If allSpaces is true, the check will look across all spaces. * - * @apiSchema (params) analyticsIdSchema + * @apiSchema (params) jobsExistSchema */ router.post( { @@ -707,7 +707,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense, routeGuard }: Rout /** * @apiGroup DataFrameAnalytics * - * @api {get} api/data_frame/analytics/fields/:indexPattern Get fields for a pattern of indices used for analytics + * @api {get} /api/ml/data_frame/analytics/new_job_caps/:indexPattern Get fields for a pattern of indices used for analytics * @apiName AnalyticsNewJobCaps * @apiDescription Retrieve the index fields for analytics */ diff --git a/x-pack/plugins/ml/server/routes/saved_objects.ts b/x-pack/plugins/ml/server/routes/saved_objects.ts index fded5500824b..b3f7818389be 100644 --- a/x-pack/plugins/ml/server/routes/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/saved_objects.ts @@ -7,9 +7,10 @@ import { wrapError } from '../client/error_wrapper'; import { RouteInitialization, SavedObjectsRouteDeps } from '../types'; -import { checksFactory, syncSavedObjectsFactory } from '../saved_objects'; +import { checksFactory, syncSavedObjectsFactory, JobSavedObjectStatus } from '../saved_objects'; import { - jobsAndSpaces, + updateJobsSpaces, + updateTrainedModelsSpaces, jobsAndCurrentSpace, syncJobObjects, syncCheckSchema, @@ -17,7 +18,7 @@ import { jobTypeSchema, } from './schemas/saved_objects'; import { spacesUtilsProvider } from '../lib/spaces_utils'; -import { JobType } from '../../common/types/saved_objects'; +import type { JobType, TrainedModelType } from '../../common/types/saved_objects'; /** * Routes for job saved object management @@ -39,7 +40,7 @@ export function savedObjectsRoutes( path: '/api/ml/saved_objects/status', validate: false, options: { - tags: ['access:ml:canGetJobs'], + tags: ['access:ml:canGetJobs', 'access:ml:canGetTrainedModels'], }, }, routeGuard.fullLicenseAPIGuard(async ({ client, response, jobSavedObjectService }) => { @@ -74,7 +75,11 @@ export function savedObjectsRoutes( query: syncJobObjects, }, options: { - tags: ['access:ml:canCreateJob', 'access:ml:canCreateDataFrameAnalytics'], + tags: [ + 'access:ml:canCreateJob', + 'access:ml:canCreateDataFrameAnalytics', + 'access:ml:canCreateTrainedModels', + ], }, }, routeGuard.fullLicenseAPIGuard(async ({ client, request, response, jobSavedObjectService }) => { @@ -107,7 +112,11 @@ export function savedObjectsRoutes( query: syncJobObjects, }, options: { - tags: ['access:ml:canCreateJob', 'access:ml:canCreateDataFrameAnalytics'], + tags: [ + 'access:ml:canCreateJob', + 'access:ml:canCreateDataFrameAnalytics', + 'access:ml:canCreateTrainedModels', + ], }, }, routeGuard.fullLicenseAPIGuard(async ({ client, request, response, jobSavedObjectService }) => { @@ -140,14 +149,18 @@ export function savedObjectsRoutes( body: syncCheckSchema, }, options: { - tags: ['access:ml:canGetJobs', 'access:ml:canGetDataFrameAnalytics'], + tags: [ + 'access:ml:canGetJobs', + 'access:ml:canGetDataFrameAnalytics', + 'access:ml:canGetTrainedModels', + ], }, }, routeGuard.fullLicenseAPIGuard(async ({ client, request, response, jobSavedObjectService }) => { try { const { jobType } = request.body; const { isSyncNeeded } = syncSavedObjectsFactory(client, jobSavedObjectService); - const result = await isSyncNeeded(jobType as JobType); + const result = await isSyncNeeded(jobType as JobType | TrainedModelType); return response.ok({ body: { result }, @@ -163,15 +176,15 @@ export function savedObjectsRoutes( * * @api {post} /api/ml/saved_objects/update_jobs_spaces Update what spaces jobs are assigned to * @apiName UpdateJobsSpaces - * @apiDescription Update a list of jobs to add and/or remove them from given spaces + * @apiDescription Update a list of jobs to add and/or remove them from given spaces. * - * @apiSchema (body) jobsAndSpaces + * @apiSchema (body) updateJobsSpaces */ router.post( { path: '/api/ml/saved_objects/update_jobs_spaces', validate: { - body: jobsAndSpaces, + body: updateJobsSpaces, }, options: { tags: ['access:ml:canCreateJob', 'access:ml:canCreateDataFrameAnalytics'], @@ -197,12 +210,50 @@ export function savedObjectsRoutes( }) ); + /** + * @apiGroup JobSavedObjects + * + * @api {post} /api/ml/saved_objects/update_trained_models_spaces Update what spaces trained models are assigned to + * @apiName UpdateTrainedModelsSpaces + * @apiDescription Update a list of trained models to add and/or remove them from given spaces. + * + * @apiSchema (body) updateTrainedModelsSpaces + */ + router.post( + { + path: '/api/ml/saved_objects/update_trained_models_spaces', + validate: { + body: updateTrainedModelsSpaces, + }, + options: { + tags: ['access:ml:canCreateTrainedModels'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService }) => { + try { + const { modelIds, spacesToAdd, spacesToRemove } = request.body; + + const body = await jobSavedObjectService.updateTrainedModelsSpaces( + modelIds, + spacesToAdd, + spacesToRemove + ); + + return response.ok({ + body, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + /** * @apiGroup JobSavedObjects * * @api {post} /api/ml/saved_objects/remove_job_from_current_space Remove jobs from the current space * @apiName RemoveJobsFromCurrentSpace - * @apiDescription Remove a list of jobs from the current space + * @apiDescription Remove a list of jobs from the current space. * * @apiSchema (body) jobsAndCurrentSpace */ @@ -262,15 +313,19 @@ export function savedObjectsRoutes( path: '/api/ml/saved_objects/jobs_spaces', validate: false, options: { - tags: ['access:ml:canGetJobs'], + tags: ['access:ml:canGetJobs', 'access:ml:canGetDataFrameAnalytics'], }, }, routeGuard.fullLicenseAPIGuard(async ({ response, jobSavedObjectService, client }) => { try { const { checkStatus } = checksFactory(client, jobSavedObjectService); - const allStatuses = Object.values((await checkStatus()).savedObjects).flat(); - - const body = allStatuses + const savedObjects = (await checkStatus()).savedObjects; + const jobStatus = ( + Object.entries(savedObjects) + .filter(([type]) => type === 'anomaly-detector' || type === 'data-frame-analytics') + .map(([, status]) => status) + .flat() as JobSavedObjectStatus[] + ) .filter((s) => s.checks.jobExists) .reduce((acc, cur) => { const type = cur.type; @@ -282,7 +337,46 @@ export function savedObjectsRoutes( }, {} as { [id: string]: { [id: string]: string[] | undefined } }); return response.ok({ - body, + body: jobStatus, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobSavedObjects + * + * @api {get} /api/ml/saved_objects/trained_models_spaces Get all trained models and their spaces + * @apiName TrainedModelsSpaces + * @apiDescription List all trained models and their spaces. + * + */ + router.get( + { + path: '/api/ml/saved_objects/trained_models_spaces', + validate: false, + options: { + tags: ['access:ml:canGetTrainedModels'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ response, jobSavedObjectService, client }) => { + try { + const { checkStatus } = checksFactory(client, jobSavedObjectService); + const savedObjects = (await checkStatus()).savedObjects; + const modelStatus = savedObjects['trained-model'] + .filter((s) => s.checks.trainedModelExists) + .reduce( + (acc, cur) => { + acc.trainedModels[cur.modelId] = cur.namespaces; + return acc; + }, + { trainedModels: {} } as { trainedModels: { [id: string]: string[] | undefined } } + ); + + return response.ok({ + body: modelStatus, }); } catch (e) { return response.customError(wrapError(e)); 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 062dbd41436d..941edb31c79f 100644 --- a/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts @@ -26,3 +26,7 @@ export const getInferenceQuerySchema = schema.object({ with_pipelines: schema.maybe(schema.string()), include: schema.maybe(schema.string()), }); + +export const putTrainedModelQuerySchema = schema.object({ + defer_definition_decompression: schema.maybe(schema.boolean()), +}); diff --git a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts index 5a62392b5f5e..7fb229d80a2b 100644 --- a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts @@ -14,13 +14,19 @@ export const jobTypeLiterals = schema.oneOf([ export const jobTypeSchema = schema.object({ jobType: jobTypeLiterals }); -export const jobsAndSpaces = schema.object({ +export const updateJobsSpaces = schema.object({ jobType: jobTypeLiterals, jobIds: schema.arrayOf(schema.string()), spacesToAdd: schema.arrayOf(schema.string()), spacesToRemove: schema.arrayOf(schema.string()), }); +export const updateTrainedModelsSpaces = schema.object({ + modelIds: schema.arrayOf(schema.string()), + spacesToAdd: schema.arrayOf(schema.string()), + spacesToRemove: schema.arrayOf(schema.string()), +}); + export const jobsAndCurrentSpace = schema.object({ jobType: jobTypeLiterals, jobIds: schema.arrayOf(schema.string()), diff --git a/x-pack/plugins/ml/server/routes/trained_models.ts b/x-pack/plugins/ml/server/routes/trained_models.ts index 72b93ba45a10..2cbf9a4dde76 100644 --- a/x-pack/plugins/ml/server/routes/trained_models.ts +++ b/x-pack/plugins/ml/server/routes/trained_models.ts @@ -5,12 +5,14 @@ * 2.0. */ +import { schema } from '@kbn/config-schema'; import { RouteInitialization } from '../types'; import { wrapError } from '../client/error_wrapper'; import { getInferenceQuerySchema, modelIdSchema, optionalModelIdSchema, + putTrainedModelQuerySchema, } from './schemas/inference_schema'; import { modelsProvider } from '../models/data_frame_analytics'; import { TrainedModelConfigResponse } from '../../common/types/trained_models'; @@ -34,7 +36,7 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) query: getInferenceQuerySchema, }, options: { - tags: ['access:ml:canGetDataFrameAnalytics'], + tags: ['access:ml:canGetTrainedModels'], }, }, routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => { @@ -79,7 +81,9 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) } } catch (e) { // the user might not have required permissions to fetch pipelines - mlLog.error(e); + // log the error to the debug log as this might be a common situation and + // we don't need to fill kibana's log with these messages. + mlLog.debug(e); } return response.ok({ @@ -94,8 +98,35 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) /** * @apiGroup TrainedModels * - * @api {get} /api/ml/trained_models/:modelId/_stats Get stats of a trained model + * @api {get} /api/ml/trained_models/_stats Get stats for all trained models * @apiName GetTrainedModelStats + * @apiDescription Retrieves usage information for all trained models. + */ + router.get( + { + path: '/api/ml/trained_models/_stats', + validate: false, + options: { + tags: ['access:ml:canGetTrainedModels'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { + try { + const body = await mlClient.getTrainedModelsStats(); + return response.ok({ + body, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup TrainedModels + * + * @api {get} /api/ml/trained_models/:modelId/_stats Get stats of a trained model + * @apiName GetTrainedModelStatsById * @apiDescription Retrieves usage information for trained models. */ router.get( @@ -105,7 +136,7 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) params: modelIdSchema, }, options: { - tags: ['access:ml:canGetDataFrameAnalytics'], + tags: ['access:ml:canGetTrainedModels'], }, }, routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { @@ -137,7 +168,7 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) params: modelIdSchema, }, options: { - tags: ['access:ml:canGetDataFrameAnalytics'], + tags: ['access:ml:canGetTrainedModels'], }, }, routeGuard.fullLicenseAPIGuard(async ({ client, request, mlClient, response }) => { @@ -155,6 +186,44 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) }) ); + /** + * @apiGroup TrainedModels + * + * @api {put} /api/ml/trained_models/:modelId Put a trained model + * @apiName PutTrainedModel + * @apiDescription Adds a new trained model + */ + router.put( + { + path: '/api/ml/trained_models/{modelId}', + validate: { + params: modelIdSchema, + body: schema.any(), + query: putTrainedModelQuerySchema, + }, + options: { + tags: ['access:ml:canCreateTrainedModels'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { + try { + const { modelId } = request.params; + const body = await mlClient.putTrainedModel({ + model_id: modelId, + body: request.body, + ...(request.query?.defer_definition_decompression + ? { defer_definition_decompression: true } + : {}), + }); + return response.ok({ + body, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + /** * @apiGroup TrainedModels * @@ -169,7 +238,7 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) params: modelIdSchema, }, options: { - tags: ['access:ml:canDeleteDataFrameAnalytics'], + tags: ['access:ml:canDeleteTrainedModels'], }, }, routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { @@ -203,6 +272,7 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) 'access:ml:canViewMlNodes', 'access:ml:canGetDataFrameAnalytics', 'access:ml:canGetJobs', + 'access:ml:canGetTrainedModels', ], }, }, @@ -237,7 +307,7 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) params: modelIdSchema, }, options: { - tags: ['access:ml:canGetDataFrameAnalytics'], + tags: ['access:ml:canStartStopTrainedModels'], }, }, routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { @@ -270,7 +340,7 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) query: forceQuerySchema, }, options: { - tags: ['access:ml:canGetDataFrameAnalytics'], + tags: ['access:ml:canStartStopTrainedModels'], }, }, routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { diff --git a/x-pack/plugins/ml/server/saved_objects/authorization.ts b/x-pack/plugins/ml/server/saved_objects/authorization.ts index a012fec8029c..67109cbe2296 100644 --- a/x-pack/plugins/ml/server/saved_objects/authorization.ts +++ b/x-pack/plugins/ml/server/saved_objects/authorization.ts @@ -7,7 +7,7 @@ import { KibanaRequest } from 'kibana/server'; import type { SecurityPluginSetup } from '../../../security/server'; -import { ML_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects'; +import { ML_JOB_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects'; export function authorizationProvider(authorization: SecurityPluginSetup['authz']) { async function authorizationCheck(request: KibanaRequest) { @@ -28,7 +28,7 @@ export function authorizationProvider(authorization: SecurityPluginSetup['authz' const checkPrivilegesDynamicallyWithRequest = authorization.checkPrivilegesDynamicallyWithRequest(request); const createMLJobAuthorizationAction = authorization.actions.savedObject.get( - ML_SAVED_OBJECT_TYPE, + ML_JOB_SAVED_OBJECT_TYPE, 'create' ); const canCreateGlobally = ( diff --git a/x-pack/plugins/ml/server/saved_objects/checks.ts b/x-pack/plugins/ml/server/saved_objects/checks.ts index f4085d99ad63..efb1dce8eac2 100644 --- a/x-pack/plugins/ml/server/saved_objects/checks.ts +++ b/x-pack/plugins/ml/server/saved_objects/checks.ts @@ -6,14 +6,16 @@ */ import Boom from '@hapi/boom'; -import { IScopedClusterClient, KibanaRequest } from 'kibana/server'; -import type { JobSavedObjectService } from './service'; -import { JobType, DeleteJobCheckResponse } from '../../common/types/saved_objects'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { IScopedClusterClient, KibanaRequest } from 'kibana/server'; +import type { JobSavedObjectService, TrainedModelJob } from './service'; +import type { JobType, DeleteJobCheckResponse } from '../../common/types/saved_objects'; -import { DataFrameAnalyticsConfig } from '../../common/types/data_frame_analytics'; -import { ResolveMlCapabilities } from '../../common/types/capabilities'; +import type { DataFrameAnalyticsConfig } from '../../common/types/data_frame_analytics'; +import type { ResolveMlCapabilities } from '../../common/types/capabilities'; +import { getJobDetailsFromTrainedModel } from './util'; -interface JobSavedObjectStatus { +export interface JobSavedObjectStatus { jobId: string; type: JobType; datafeedId?: string | null; @@ -24,6 +26,16 @@ interface JobSavedObjectStatus { }; } +export interface TrainedModelSavedObjectStatus { + modelId: string; + namespaces: string[] | undefined; + job: null | TrainedModelJob; + checks: { + trainedModelExists: boolean; + dfaJobExists: boolean | null; + }; +} + export interface JobStatus { jobId: string; datafeedId?: string | null; @@ -32,12 +44,24 @@ export interface JobStatus { }; } +export interface TrainedModelStatus { + modelId: string; + checks: { + savedObjectExits: boolean; + dfaJobReferenced: boolean | null; + }; +} + export interface StatusResponse { savedObjects: { - [type in JobType]: JobSavedObjectStatus[]; + 'anomaly-detector': JobSavedObjectStatus[]; + 'data-frame-analytics': JobSavedObjectStatus[]; + 'trained-model': TrainedModelSavedObjectStatus[]; }; jobs: { - [type in JobType]: JobStatus[]; + 'anomaly-detector': JobStatus[]; + 'data-frame-analytics': JobStatus[]; + 'trained-model': TrainedModelStatus[]; }; } @@ -46,16 +70,29 @@ export function checksFactory( jobSavedObjectService: JobSavedObjectService ) { async function checkStatus(): Promise { - const jobObjects = await jobSavedObjectService.getAllJobObjects(undefined, false); - - // load all non-space jobs and datafeeds - const adJobs = await client.asInternalUser.ml.getJobs(); - const datafeeds = await client.asInternalUser.ml.getDatafeeds(); - const dfaJobs = (await client.asInternalUser.ml.getDataFrameAnalytics()) as unknown as { - data_frame_analytics: DataFrameAnalyticsConfig[]; - }; + const [ + jobObjects, + allJobObjects, + modelObjects, + allModelObjects, + adJobs, + datafeeds, + dfaJobs, + models, + ] = await Promise.all([ + jobSavedObjectService.getAllJobObjects(undefined, false), + jobSavedObjectService.getAllJobObjectsForAllSpaces(), + jobSavedObjectService.getAllTrainedModelObjects(false), + jobSavedObjectService.getAllTrainedModelObjectsForAllSpaces(), + client.asInternalUser.ml.getJobs(), + client.asInternalUser.ml.getDatafeeds(), + client.asInternalUser.ml.getDataFrameAnalytics() as unknown as { + data_frame_analytics: DataFrameAnalyticsConfig[]; + }, + client.asInternalUser.ml.getTrainedModels(), + ]); - const savedObjectsStatus: JobSavedObjectStatus[] = jobObjects.map( + const jobSavedObjectsStatus: JobSavedObjectStatus[] = jobObjects.map( ({ attributes, namespaces }) => { const type: JobType = attributes.type; const jobId = attributes.job_id; @@ -84,7 +121,42 @@ export function checksFactory( } ); - const allJobObjects = await jobSavedObjectService.getAllJobObjectsForAllSpaces(); + const dfaJobsCreateTimeMap = dfaJobs.data_frame_analytics.reduce((acc, cur) => { + acc.set(cur.id, cur.create_time); + return acc; + }, new Map()); + + const modelJobExits = models.trained_model_configs.reduce((acc, cur) => { + const job = getJobDetailsFromTrainedModel(cur); + if (job === null) { + return acc; + } + + const { job_id: jobId, create_time: createTime } = job; + const exists = createTime === dfaJobsCreateTimeMap.get(jobId); + + if (jobId && createTime) { + acc.set(cur.model_id, exists); + } + return acc; + }, new Map()); + + const modelSavedObjectsStatus: TrainedModelSavedObjectStatus[] = modelObjects.map( + ({ attributes: { job, model_id: modelId }, namespaces }) => { + const trainedModelExists = models.trained_model_configs.some((m) => m.model_id === modelId); + const dfaJobExists = modelJobExits.get(modelId) ?? null; + + return { + modelId, + namespaces, + job, + checks: { + trainedModelExists, + dfaJobExists, + }, + }; + } + ); const nonSpaceADObjectIds = new Set( allJobObjects @@ -97,16 +169,23 @@ export function checksFactory( .map(({ attributes }) => attributes.job_id) ); + const nonSpaceModelObjectIds = new Map( + allModelObjects.map((model) => [model.attributes.model_id, model]) + ); + const adObjectIds = new Set( - savedObjectsStatus.filter(({ type }) => type === 'anomaly-detector').map(({ jobId }) => jobId) + jobSavedObjectsStatus + .filter(({ type }) => type === 'anomaly-detector') + .map(({ jobId }) => jobId) ); const dfaObjectIds = new Set( - savedObjectsStatus + jobSavedObjectsStatus .filter(({ type }) => type === 'data-frame-analytics') .map(({ jobId }) => jobId) ); + const modelObjectIds = new Set(modelSavedObjectsStatus.map(({ modelId }) => modelId)); - const anomalyDetectors = adJobs.jobs + const anomalyDetectorsStatus = adJobs.jobs .filter(({ job_id: jobId }) => { // only list jobs which are in the current space (adObjectIds) // or are not in any spaces (nonSpaceADObjectIds) @@ -123,7 +202,7 @@ export function checksFactory( }; }); - const dataFrameAnalytics = dfaJobs.data_frame_analytics + const dataFrameAnalyticsStatus = dfaJobs.data_frame_analytics .filter(({ id: jobId }) => { // only list jobs which are in the current space (dfaObjectIds) // or are not in any spaces (nonSpaceDFAObjectIds) @@ -139,16 +218,47 @@ export function checksFactory( }; }); + const modelsStatus = models.trained_model_configs + .filter(({ model_id: modelId }) => { + // only list jobs which are in the current space (adObjectIds) + // or are not in any spaces (nonSpaceADObjectIds) + return ( + modelObjectIds.has(modelId) === true || nonSpaceModelObjectIds.has(modelId) === false + ); + }) + .map((model: estypes.MlTrainedModelConfig) => { + const modelId = model.model_id; + const modelObject = nonSpaceModelObjectIds.get(modelId); + const savedObjectExits = modelObject !== undefined; + const job = getJobDetailsFromTrainedModel(model); + let dfaJobReferenced = null; + if (job !== null) { + dfaJobReferenced = + modelObject?.attributes.job?.job_id === job.job_id && + modelObject?.attributes.job?.create_time === job.create_time; + } + + return { + modelId, + checks: { + savedObjectExits, + dfaJobReferenced, + }, + }; + }); + return { savedObjects: { - 'anomaly-detector': savedObjectsStatus.filter(({ type }) => type === 'anomaly-detector'), - 'data-frame-analytics': savedObjectsStatus.filter( + 'anomaly-detector': jobSavedObjectsStatus.filter(({ type }) => type === 'anomaly-detector'), + 'data-frame-analytics': jobSavedObjectsStatus.filter( ({ type }) => type === 'data-frame-analytics' ), + 'trained-model': modelSavedObjectsStatus, }, jobs: { - 'anomaly-detector': anomalyDetectors, - 'data-frame-analytics': dataFrameAnalytics, + 'anomaly-detector': anomalyDetectorsStatus, + 'data-frame-analytics': dataFrameAnalyticsStatus, + 'trained-model': modelsStatus, }, }; } diff --git a/x-pack/plugins/ml/server/saved_objects/index.ts b/x-pack/plugins/ml/server/saved_objects/index.ts index 652f47122ae2..7537c7ed01dc 100644 --- a/x-pack/plugins/ml/server/saved_objects/index.ts +++ b/x-pack/plugins/ml/server/saved_objects/index.ts @@ -9,6 +9,7 @@ export { setupSavedObjects } from './saved_objects'; export type { JobObject, JobSavedObjectService } from './service'; export { jobSavedObjectServiceFactory } from './service'; export { checksFactory } from './checks'; +export type { JobSavedObjectStatus } from './checks'; export { syncSavedObjectsFactory } from './sync'; export { jobSavedObjectsInitializationFactory } from './initialization'; export { savedObjectClientsFactory } from './util'; diff --git a/x-pack/plugins/ml/server/saved_objects/initialization/initialization.ts b/x-pack/plugins/ml/server/saved_objects/initialization/initialization.ts index c0ab8600794c..1764c59f7e44 100644 --- a/x-pack/plugins/ml/server/saved_objects/initialization/initialization.ts +++ b/x-pack/plugins/ml/server/saved_objects/initialization/initialization.ts @@ -10,7 +10,7 @@ import { savedObjectClientsFactory } from '../util'; import { syncSavedObjectsFactory } from '../sync'; import { jobSavedObjectServiceFactory, JobObject } from '../service'; import { mlLog } from '../../lib/log'; -import { ML_SAVED_OBJECT_TYPE } from '../../../common/types/saved_objects'; +import { ML_JOB_SAVED_OBJECT_TYPE } from '../../../common/types/saved_objects'; import { createJobSpaceOverrides } from './space_overrides'; import type { SecurityPluginSetup } from '../../../../security/server'; @@ -52,18 +52,19 @@ export function jobSavedObjectsInitializationFactory( savedObjectsClient, spacesEnabled, security?.authz, + client, () => Promise.resolve() // pretend isMlReady, to allow us to initialize the saved objects ); - mlLog.info('Initializing job saved objects'); + mlLog.info('Initializing ML saved objects'); // create space overrides for specific jobs const jobSpaceOverrides = await createJobSpaceOverrides(client); // initialize jobs const { initSavedObjects } = syncSavedObjectsFactory(client, jobSavedObjectService); - const { jobs } = await initSavedObjects(false, jobSpaceOverrides); - mlLog.info(`${jobs.length} job saved objects initialized`); + const { jobs, trainedModels } = await initSavedObjects(false, jobSpaceOverrides); + mlLog.info(`${jobs.length + trainedModels.length} ML saved objects initialized`); } catch (error) { - mlLog.error(`Error Initializing jobs ${JSON.stringify(error)}`); + mlLog.error(`Error Initializing ML saved objects ${JSON.stringify(error)}`); } } @@ -88,7 +89,7 @@ export function jobSavedObjectsInitializationFactory( async function _jobSavedObjectsExist(savedObjectsClient: SavedObjectsClientContract) { const options = { - type: ML_SAVED_OBJECT_TYPE, + type: ML_JOB_SAVED_OBJECT_TYPE, perPage: 0, namespaces: ['*'], }; diff --git a/x-pack/plugins/ml/server/saved_objects/mappings.ts b/x-pack/plugins/ml/server/saved_objects/mappings.ts index f45299101572..7560df4b6ab2 100644 --- a/x-pack/plugins/ml/server/saved_objects/mappings.ts +++ b/x-pack/plugins/ml/server/saved_objects/mappings.ts @@ -31,6 +31,34 @@ export const mlJob: SavedObjectsTypeMappingDefinition = { }, }; +export const mlTrainedModel: SavedObjectsTypeMappingDefinition = { + properties: { + model_id: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + }, + }, + }, + job: { + properties: { + job_id: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + }, + }, + }, + create_time: { + type: 'date', + }, + }, + }, + }, +}; + export const mlModule: SavedObjectsTypeMappingDefinition = { dynamic: false, properties: { diff --git a/x-pack/plugins/ml/server/saved_objects/saved_objects.ts b/x-pack/plugins/ml/server/saved_objects/saved_objects.ts index f3061d3e38d7..2fdcd58416e6 100644 --- a/x-pack/plugins/ml/server/saved_objects/saved_objects.ts +++ b/x-pack/plugins/ml/server/saved_objects/saved_objects.ts @@ -6,22 +6,30 @@ */ import { SavedObjectsServiceSetup } from 'kibana/server'; -import { mlJob, mlModule } from './mappings'; +import { mlJob, mlTrainedModel, mlModule } from './mappings'; import { migrations } from './migrations'; import { - ML_SAVED_OBJECT_TYPE, + ML_JOB_SAVED_OBJECT_TYPE, ML_MODULE_SAVED_OBJECT_TYPE, + ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, } from '../../common/types/saved_objects'; export function setupSavedObjects(savedObjects: SavedObjectsServiceSetup) { savedObjects.registerType({ - name: ML_SAVED_OBJECT_TYPE, + name: ML_JOB_SAVED_OBJECT_TYPE, hidden: false, namespaceType: 'multiple', migrations, mappings: mlJob, }); + savedObjects.registerType({ + name: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'multiple', + migrations, + mappings: mlTrainedModel, + }); savedObjects.registerType({ name: ML_MODULE_SAVED_OBJECT_TYPE, hidden: false, diff --git a/x-pack/plugins/ml/server/saved_objects/service.ts b/x-pack/plugins/ml/server/saved_objects/service.ts index ff8856457ca4..6e3b9a2485c8 100644 --- a/x-pack/plugins/ml/server/saved_objects/service.ts +++ b/x-pack/plugins/ml/server/saved_objects/service.ts @@ -11,10 +11,15 @@ import { SavedObjectsClientContract, SavedObjectsFindOptions, SavedObjectsFindResult, + IScopedClusterClient, } from 'kibana/server'; import type { SecurityPluginSetup } from '../../../security/server'; -import { JobType, ML_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects'; -import { MLJobNotFound } from '../lib/ml_client'; +import type { JobType, MlSavedObjectType } from '../../common/types/saved_objects'; +import { + ML_JOB_SAVED_OBJECT_TYPE, + ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, +} from '../../common/types/saved_objects'; +import { MLJobNotFound, MLModelNotFound } from '../lib/ml_client'; import { getSavedObjectClientError } from './util'; import { authorizationProvider } from './authorization'; @@ -25,13 +30,31 @@ export interface JobObject { } type JobObjectFilter = { [k in keyof JobObject]?: string }; +export interface TrainedModelObject { + model_id: string; + job: null | TrainedModelJob; +} + +export interface TrainedModelJob { + job_id: string; + create_time: number; +} + +type TrainedModelObjectFilter = { [k in keyof TrainedModelObject]?: string }; + export type JobSavedObjectService = ReturnType; +type UpdateJobsSpacesResult = Record< + string, + { success: boolean; type: MlSavedObjectType; error?: any } +>; + export function jobSavedObjectServiceFactory( savedObjectsClient: SavedObjectsClientContract, internalSavedObjectsClient: SavedObjectsClientContract, spacesEnabled: boolean, authorization: SecurityPluginSetup['authz'] | undefined, + client: IScopedClusterClient, isMlReady: () => Promise ) { async function _getJobObjects( @@ -51,10 +74,13 @@ export function jobSavedObjectServiceFactory( } else if (datafeedId !== undefined) { filterObject.datafeed_id = datafeedId; } - const { filter, searchFields } = createSavedObjectFilter(filterObject); + const { filter, searchFields } = createSavedObjectFilter( + filterObject, + ML_JOB_SAVED_OBJECT_TYPE + ); const options: SavedObjectsFindOptions = { - type: ML_SAVED_OBJECT_TYPE, + type: ML_JOB_SAVED_OBJECT_TYPE, perPage: 10000, ...(spacesEnabled === false || currentSpaceOnly === true ? {} : { namespaces: ['*'] }), searchFields, @@ -75,7 +101,7 @@ export function jobSavedObjectServiceFactory( type: jobType, }; - const id = savedObjectId(job); + const id = _jobSavedObjectId(job); try { const [existingJobObject] = await getAllJobObjectsForAllSpaces(jobType, jobId); @@ -86,7 +112,7 @@ export function jobSavedObjectServiceFactory( await _forceDeleteJob(jobType, jobId, existingJobObject.namespaces[0]); } else { // the saved object has no spaces, this is unexpected, attempt a normal delete - await savedObjectsClient.delete(ML_SAVED_OBJECT_TYPE, id, { force: true }); + await savedObjectsClient.delete(ML_JOB_SAVED_OBJECT_TYPE, id, { force: true }); } } } catch (error) { @@ -94,7 +120,7 @@ export function jobSavedObjectServiceFactory( // if not, this error will be throw which we ignore. } - await savedObjectsClient.create(ML_SAVED_OBJECT_TYPE, job, { + await savedObjectsClient.create(ML_JOB_SAVED_OBJECT_TYPE, job, { id, }); } @@ -103,15 +129,15 @@ export function jobSavedObjectServiceFactory( await isMlReady(); return await savedObjectsClient.bulkCreate( jobs.map((j) => ({ - type: ML_SAVED_OBJECT_TYPE, - id: savedObjectId(j.job), + type: ML_JOB_SAVED_OBJECT_TYPE, + id: _jobSavedObjectId(j.job), attributes: j.job, initialNamespaces: j.namespaces, })) ); } - function savedObjectId(job: JobObject) { + function _jobSavedObjectId(job: JobObject) { return `${job.type}-${job.job_id}`; } @@ -122,11 +148,11 @@ export function jobSavedObjectServiceFactory( throw new MLJobNotFound('job not found'); } - await savedObjectsClient.delete(ML_SAVED_OBJECT_TYPE, job.id, { force: true }); + await savedObjectsClient.delete(ML_JOB_SAVED_OBJECT_TYPE, job.id, { force: true }); } async function _forceDeleteJob(jobType: JobType, jobId: string, namespace: string) { - const id = savedObjectId({ + const id = _jobSavedObjectId({ job_id: jobId, datafeed_id: null, type: jobType, @@ -134,7 +160,7 @@ export function jobSavedObjectServiceFactory( // * space cannot be used in a delete call, so use undefined which // is the same as specifying the default space - await internalSavedObjectsClient.delete(ML_SAVED_OBJECT_TYPE, id, { + await internalSavedObjectsClient.delete(ML_JOB_SAVED_OBJECT_TYPE, id, { namespace: namespace === '*' ? undefined : namespace, force: true, }); @@ -193,9 +219,12 @@ export function jobSavedObjectServiceFactory( filterObject.job_id = jobId; } - const { filter, searchFields } = createSavedObjectFilter(filterObject); + const { filter, searchFields } = createSavedObjectFilter( + filterObject, + ML_JOB_SAVED_OBJECT_TYPE + ); const options: SavedObjectsFindOptions = { - type: ML_SAVED_OBJECT_TYPE, + type: ML_JOB_SAVED_OBJECT_TYPE, perPage: 10000, ...(spacesEnabled === false ? {} : { namespaces: ['*'] }), searchFields, @@ -214,7 +243,7 @@ export function jobSavedObjectServiceFactory( const jobObject = job.attributes; jobObject.datafeed_id = datafeedId; - await savedObjectsClient.update(ML_SAVED_OBJECT_TYPE, job.id, jobObject); + await savedObjectsClient.update(ML_JOB_SAVED_OBJECT_TYPE, job.id, jobObject); } async function deleteDatafeed(datafeedId: string) { @@ -226,15 +255,15 @@ export function jobSavedObjectServiceFactory( const jobObject = job.attributes; jobObject.datafeed_id = null; - await savedObjectsClient.update(ML_SAVED_OBJECT_TYPE, job.id, jobObject); + await savedObjectsClient.update(ML_JOB_SAVED_OBJECT_TYPE, job.id, jobObject); } - async function getIds(jobType: JobType, idType: keyof JobObject) { + async function _getIds(jobType: JobType, idType: keyof JobObject) { const jobs = await _getJobObjects(jobType); return jobs.map((o) => o.attributes[idType]); } - async function filterJobObjectsForSpace( + async function _filterJobObjectsForSpace( jobType: JobType, list: T[], field: keyof T, @@ -243,12 +272,12 @@ export function jobSavedObjectServiceFactory( if (list.length === 0) { return []; } - const jobIds = await getIds(jobType, key); + const jobIds = await _getIds(jobType, key); return list.filter((j) => jobIds.includes(j[field] as unknown as string)); } async function filterJobsForSpace(jobType: JobType, list: T[], field: keyof T): Promise { - return filterJobObjectsForSpace(jobType, list, field, 'job_id'); + return _filterJobObjectsForSpace(jobType, list, field, 'job_id'); } async function filterDatafeedsForSpace( @@ -256,10 +285,10 @@ export function jobSavedObjectServiceFactory( list: T[], field: keyof T ): Promise { - return filterJobObjectsForSpace(jobType, list, field, 'datafeed_id'); + return _filterJobObjectsForSpace(jobType, list, field, 'datafeed_id'); } - async function filterJobObjectIdsForSpace( + async function _filterJobObjectIdsForSpace( jobType: JobType, ids: string[], key: keyof JobObject, @@ -269,7 +298,7 @@ export function jobSavedObjectServiceFactory( return []; } - const jobIds = await getIds(jobType, key); + const jobIds = await _getIds(jobType, key); // check to see if any of the ids supplied contain a wildcard if (allowWildcards === false || ids.join().match('\\*') === null) { // wildcards are not allowed or no wildcards could be found @@ -291,14 +320,14 @@ export function jobSavedObjectServiceFactory( ids: string[], allowWildcards: boolean = false ): Promise { - return filterJobObjectIdsForSpace(jobType, ids, 'job_id', allowWildcards); + return _filterJobObjectIdsForSpace(jobType, ids, 'job_id', allowWildcards); } async function filterDatafeedIdsForSpace( ids: string[], allowWildcards: boolean = false ): Promise { - return filterJobObjectIdsForSpace('anomaly-detector', ids, 'datafeed_id', allowWildcards); + return _filterJobObjectIdsForSpace('anomaly-detector', ids, 'datafeed_id', allowWildcards); } async function updateJobsSpaces( @@ -306,27 +335,33 @@ export function jobSavedObjectServiceFactory( jobIds: string[], spacesToAdd: string[], spacesToRemove: string[] - ) { - const results: Record = {}; + ): Promise { + if (jobIds.length === 0 || (spacesToAdd.length === 0 && spacesToRemove.length === 0)) { + return {}; + } + + const results: UpdateJobsSpacesResult = {}; const jobs = await _getJobObjects(jobType); const jobObjectIdMap = new Map(); - const objectsToUpdate: Array<{ type: string; id: string }> = []; + const jobObjectsToUpdate: Array<{ type: string; id: string }> = []; + for (const jobId of jobIds) { const job = jobs.find((j) => j.attributes.job_id === jobId); if (job === undefined) { results[jobId] = { success: false, - error: createError(jobId, 'job_id'), + type: ML_JOB_SAVED_OBJECT_TYPE, + error: createJobError(jobId, 'job_id'), }; } else { jobObjectIdMap.set(job.id, jobId); - objectsToUpdate.push({ type: ML_SAVED_OBJECT_TYPE, id: job.id }); + jobObjectsToUpdate.push({ type: ML_JOB_SAVED_OBJECT_TYPE, id: job.id }); } } try { const updateResult = await savedObjectsClient.updateObjectsSpaces( - objectsToUpdate, + jobObjectsToUpdate, spacesToAdd, spacesToRemove ); @@ -335,27 +370,30 @@ export function jobSavedObjectServiceFactory( if (error) { results[jobId] = { success: false, + type: ML_JOB_SAVED_OBJECT_TYPE, error: getSavedObjectClientError(error), }; } else { results[jobId] = { success: true, + type: ML_JOB_SAVED_OBJECT_TYPE, }; } }); } catch (error) { // If the entire operation failed, return success: false for each job const clientError = getSavedObjectClientError(error); - objectsToUpdate.forEach(({ id: objectId }) => { + jobObjectsToUpdate.forEach(({ id: objectId }) => { const jobId = jobObjectIdMap.get(objectId)!; results[jobId] = { success: false, + type: ML_JOB_SAVED_OBJECT_TYPE, error: clientError, }; }); } - return results; + return { ...results }; } async function canCreateGlobalJobs(request: KibanaRequest) { @@ -366,6 +404,335 @@ export function jobSavedObjectServiceFactory( return (await authorizationCheck(request)).canCreateGlobally; } + async function getTrainedModelObject( + modelId: string, + currentSpaceOnly: boolean = true + ): Promise | undefined> { + const [modelObject] = await _getTrainedModelObjects(modelId, currentSpaceOnly); + return modelObject; + } + + async function createTrainedModel(modelId: string, job: TrainedModelJob | null) { + await _createTrainedModel(modelId, job); + } + + async function bulkCreateTrainedModel(models: TrainedModelObject[], namespaceFallback?: string) { + return await _bulkCreateTrainedModel(models, namespaceFallback); + } + + async function deleteTrainedModel(modelId: string) { + await _deleteTrainedModel(modelId); + } + + async function forceDeleteTrainedModel(modelId: string, namespace: string) { + await _forceDeleteTrainedModel(modelId, namespace); + } + + async function getAllTrainedModelObjects(currentSpaceOnly: boolean = true) { + return await _getTrainedModelObjects(undefined, currentSpaceOnly); + } + + async function _getTrainedModelObjects(modelId?: string, currentSpaceOnly: boolean = true) { + await isMlReady(); + const filterObject: TrainedModelObjectFilter = {}; + + if (modelId !== undefined) { + filterObject.model_id = modelId; + } + + const { filter, searchFields } = createSavedObjectFilter( + filterObject, + ML_TRAINED_MODEL_SAVED_OBJECT_TYPE + ); + + const options: SavedObjectsFindOptions = { + type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, + perPage: 10000, + ...(spacesEnabled === false || currentSpaceOnly === true ? {} : { namespaces: ['*'] }), + searchFields, + filter, + }; + + const models = await savedObjectsClient.find(options); + + return models.saved_objects; + } + + async function _createTrainedModel(modelId: string, job: TrainedModelJob | null) { + await isMlReady(); + + const modelObject: TrainedModelObject = { + model_id: modelId, + job, + }; + + try { + const [existingModelObject] = await getAllTrainedModelObjectsForAllSpaces([modelId]); + if (existingModelObject !== undefined) { + // a saved object for this job already exists, this may be left over from a previously deleted job + if (existingModelObject.namespaces?.length) { + // use a force delete just in case the saved object exists only in another space. + await _forceDeleteTrainedModel(modelId, existingModelObject.namespaces[0]); + } else { + // the saved object has no spaces, this is unexpected, attempt a normal delete + await savedObjectsClient.delete(ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, modelId, { + force: true, + }); + } + } + } catch (error) { + // the saved object may exist if a previous job with the same ID has been deleted. + // if not, this error will be throw which we ignore. + } + let initialNamespaces; + // if a job exists for this model, ensure the initial namespaces for the model + // are the same as the job + if (job !== null) { + const [existingJobObject] = await getAllJobObjectsForAllSpaces( + 'data-frame-analytics', + job.job_id + ); + + initialNamespaces = existingJobObject?.namespaces ?? undefined; + } + + await savedObjectsClient.create( + ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, + modelObject, + { + id: modelId, + ...(initialNamespaces ? { initialNamespaces } : {}), + } + ); + } + + async function _bulkCreateTrainedModel(models: TrainedModelObject[], namespaceFallback?: string) { + await isMlReady(); + + const namespacesPerJob = (await getAllJobObjectsForAllSpaces()).reduce((acc, cur) => { + acc[cur.attributes.job_id] = cur.namespaces; + return acc; + }, {} as Record); + + return await savedObjectsClient.bulkCreate( + models.map((m) => { + let initialNamespaces = m.job && namespacesPerJob[m.job.job_id]; + if (!initialNamespaces?.length && namespaceFallback) { + // use the namespace fallback if it is defined and no namespaces can + // be found for a related job. + // otherwise initialNamespaces will be undefined and the SO client will + // use the current space. + initialNamespaces = [namespaceFallback]; + } + return { + type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, + id: m.model_id, + attributes: m, + ...(initialNamespaces ? { initialNamespaces } : {}), + }; + }) + ); + } + + async function getAllTrainedModelObjectsForAllSpaces(modelIds?: string[]) { + await isMlReady(); + const searchFields = ['model_id']; + let filter = ''; + + if (modelIds !== undefined && modelIds.length) { + filter = modelIds + .map((m) => `${ML_TRAINED_MODEL_SAVED_OBJECT_TYPE}.attributes.model_id: "${m}"`) + .join(' OR '); + } + + const options: SavedObjectsFindOptions = { + type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, + perPage: 10000, + ...(spacesEnabled === false ? {} : { namespaces: ['*'] }), + searchFields, + filter, + }; + + return (await internalSavedObjectsClient.find(options)).saved_objects; + } + + async function _deleteTrainedModel(modelId: string) { + const [model] = await _getTrainedModelObjects(modelId); + if (model === undefined) { + throw new MLModelNotFound('trained model not found'); + } + + await savedObjectsClient.delete(ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, model.id, { force: true }); + } + + async function _forceDeleteTrainedModel(modelId: string, namespace: string) { + // * space cannot be used in a delete call, so use undefined which + // is the same as specifying the default space + await internalSavedObjectsClient.delete(ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, modelId, { + namespace: namespace === '*' ? undefined : namespace, + force: true, + }); + } + + async function filterTrainedModelsForSpace(list: T[], field: keyof T): Promise { + return _filterModelObjectsForSpace(list, field, 'model_id'); + } + + async function filterTrainedModelIdsForSpace( + ids: string[], + allowWildcards: boolean = false + ): Promise { + return _filterModelObjectIdsForSpace(ids, 'model_id', allowWildcards); + } + + async function _filterModelObjectIdsForSpace( + ids: string[], + key: keyof TrainedModelObject, + allowWildcards: boolean = false + ): Promise { + if (ids.length === 0) { + return []; + } + + const modelIds = await _getModelIds(key); + // check to see if any of the ids supplied contain a wildcard + if (allowWildcards === false || ids.join().match('\\*') === null) { + // wildcards are not allowed or no wildcards could be found + return ids.filter((id) => modelIds.includes(id)); + } + + // if any of the ids contain a wildcard, check each one. + return ids.filter((id) => { + if (id.match('\\*') === null) { + return modelIds.includes(id); + } + const regex = new RE2(id.replace('*', '.*')); + return modelIds.some((jId) => typeof jId === 'string' && regex.exec(jId)); + }); + } + + async function _filterModelObjectsForSpace( + list: T[], + field: keyof T, + key: keyof TrainedModelObject + ): Promise { + if (list.length === 0) { + return []; + } + const modelIds = await _getModelIds(key); + return list.filter((j) => modelIds.includes(j[field] as unknown as string)); + } + + async function _getModelIds(idType: keyof TrainedModelObject) { + const models = await _getTrainedModelObjects(); + return models.map((o) => o.attributes[idType]); + } + + async function findTrainedModelsObjectForJobs( + jobIds: string[], + currentSpaceOnly: boolean = true + ) { + await isMlReady(); + const { data_frame_analytics: jobs } = await client.asInternalUser.ml.getDataFrameAnalytics({ + id: jobIds.join(','), + }); + + const searches = jobs.map((job) => { + const createTime = job.create_time!; + + const filterObject = { + 'job.job_id': job.id, + 'job.create_time': createTime, + } as TrainedModelObjectFilter; + const { filter, searchFields } = createSavedObjectFilter( + filterObject, + ML_TRAINED_MODEL_SAVED_OBJECT_TYPE + ); + + const options: SavedObjectsFindOptions = { + type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, + perPage: 10000, + ...(spacesEnabled === false || currentSpaceOnly === true ? {} : { namespaces: ['*'] }), + searchFields, + filter, + }; + return savedObjectsClient.find(options); + }); + + const finedResult = await Promise.all(searches); + return finedResult.reduce((acc, cur) => { + const savedObject = cur.saved_objects[0]; + if (savedObject) { + const jobId = savedObject.attributes.job!.job_id; + acc[jobId] = savedObject; + } + return acc; + }, {} as Record>); + } + + async function updateTrainedModelsSpaces( + modelIds: string[], + spacesToAdd: string[], + spacesToRemove: string[] + ): Promise { + if (modelIds.length === 0 || (spacesToAdd.length === 0 && spacesToRemove.length === 0)) { + return {}; + } + const results: UpdateJobsSpacesResult = {}; + const models = await _getTrainedModelObjects(); + const trainedModelObjectIdMap = new Map(); + const objectsToUpdate: Array<{ type: string; id: string }> = []; + + for (const modelId of modelIds) { + const model = models.find(({ attributes }) => attributes.model_id === modelId); + if (model === undefined) { + results[modelId] = { + success: false, + type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, + error: createTrainedModelError(modelId), + }; + } else { + trainedModelObjectIdMap.set(model.id, model.attributes.model_id); + objectsToUpdate.push({ type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, id: model.id }); + } + } + try { + const updateResult = await savedObjectsClient.updateObjectsSpaces( + objectsToUpdate, + spacesToAdd, + spacesToRemove + ); + updateResult.objects.forEach(({ id: objectId, error }) => { + const model = trainedModelObjectIdMap.get(objectId)!; + if (error) { + results[model] = { + success: false, + type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, + error: getSavedObjectClientError(error), + }; + } else { + results[model] = { + success: true, + type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, + }; + } + }); + } catch (error) { + // If the entire operation failed, return success: false for each job + const clientError = getSavedObjectClientError(error); + objectsToUpdate.forEach(({ id: objectId }) => { + const modelId = trainedModelObjectIdMap.get(objectId)!; + results[modelId] = { + success: false, + type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, + error: clientError, + }; + }); + } + + return results; + } + return { getAllJobObjects, getJobObject, @@ -385,10 +752,21 @@ export function jobSavedObjectServiceFactory( bulkCreateJobs, getAllJobObjectsForAllSpaces, canCreateGlobalJobs, + getTrainedModelObject, + createTrainedModel, + bulkCreateTrainedModel, + deleteTrainedModel, + forceDeleteTrainedModel, + updateTrainedModelsSpaces, + getAllTrainedModelObjects, + getAllTrainedModelObjectsForAllSpaces, + filterTrainedModelsForSpace, + filterTrainedModelIdsForSpace, + findTrainedModelsObjectForJobs, }; } -export function createError(id: string, key: keyof JobObject) { +export function createJobError(id: string, key: keyof JobObject) { let reason = `'${id}' not found`; if (key === 'job_id') { reason = `No known job with id '${id}'`; @@ -404,12 +782,24 @@ export function createError(id: string, key: keyof JobObject) { }; } -function createSavedObjectFilter(filterObject: JobObjectFilter) { +export function createTrainedModelError(id: string) { + return { + error: { + reason: `No known trained model with id '${id}'`, + }, + status: 404, + }; +} + +function createSavedObjectFilter( + filterObject: JobObjectFilter | TrainedModelObjectFilter, + savedObjectType: string +) { const searchFields: string[] = []; const filter = Object.entries(filterObject) .map(([k, v]) => { searchFields.push(k); - return `${ML_SAVED_OBJECT_TYPE}.attributes.${k}: "${v}"`; + return `${savedObjectType}.attributes.${k}: "${v}"`; }) .join(' AND '); return { filter, searchFields }; diff --git a/x-pack/plugins/ml/server/saved_objects/sync.ts b/x-pack/plugins/ml/server/saved_objects/sync.ts index d77cdddcfa86..63f62d264a63 100644 --- a/x-pack/plugins/ml/server/saved_objects/sync.ts +++ b/x-pack/plugins/ml/server/saved_objects/sync.ts @@ -6,16 +6,17 @@ */ import Boom from '@hapi/boom'; -import { IScopedClusterClient } from 'kibana/server'; -import type { JobObject, JobSavedObjectService } from './service'; -import { +import type { IScopedClusterClient } from 'kibana/server'; +import type { JobObject, JobSavedObjectService, TrainedModelObject } from './service'; +import type { JobType, + TrainedModelType, SyncSavedObjectResponse, InitializeSavedObjectResponse, } from '../../common/types/saved_objects'; import { checksFactory } from './checks'; import type { JobStatus } from './checks'; -import { getSavedObjectClientError } from './util'; +import { getSavedObjectClientError, getJobDetailsFromTrainedModel } from './util'; export interface JobSpaceOverrides { overrides: { @@ -37,12 +38,14 @@ export function syncSavedObjectsFactory( datafeedsRemoved: {}, }; - const datafeeds = await client.asInternalUser.ml.getDatafeeds(); + const [datafeeds, models, status] = await Promise.all([ + client.asInternalUser.ml.getDatafeeds(), + client.asInternalUser.ml.getTrainedModels(), + checkStatus(), + ]); const tasks: Array<() => Promise> = []; - const status = await checkStatus(); - const adJobsById = status.jobs['anomaly-detector'].reduce((acc, j) => { acc[j.jobId] = j; return acc; @@ -51,8 +54,11 @@ export function syncSavedObjectsFactory( for (const job of status.jobs['anomaly-detector']) { if (job.checks.savedObjectExits === false) { const type = 'anomaly-detector'; + if (results.savedObjectsCreated[type] === undefined) { + results.savedObjectsCreated[type] = {}; + } if (simulate === true) { - results.savedObjectsCreated[job.jobId] = { success: true, type }; + results.savedObjectsCreated[type]![job.jobId] = { success: true }; } else { // create AD saved objects for jobs which are missing them const jobId = job.jobId; @@ -60,11 +66,11 @@ export function syncSavedObjectsFactory( tasks.push(async () => { try { await jobSavedObjectService.createAnomalyDetectionJob(jobId, datafeedId ?? undefined); - results.savedObjectsCreated[job.jobId] = { success: true, type }; + results.savedObjectsCreated[type]![job.jobId] = { success: true }; } catch (error) { - results.savedObjectsCreated[job.jobId] = { + results.savedObjectsCreated[type]![job.jobId] = { success: false, - type, + error: getSavedObjectClientError(error), }; } @@ -75,22 +81,62 @@ export function syncSavedObjectsFactory( for (const job of status.jobs['data-frame-analytics']) { if (job.checks.savedObjectExits === false) { const type = 'data-frame-analytics'; + if (results.savedObjectsCreated[type] === undefined) { + results.savedObjectsCreated[type] = {}; + } if (simulate === true) { - results.savedObjectsCreated[job.jobId] = { success: true, type }; + results.savedObjectsCreated[type]![job.jobId] = { success: true }; } else { // create DFA saved objects for jobs which are missing them const jobId = job.jobId; tasks.push(async () => { try { await jobSavedObjectService.createDataFrameAnalyticsJob(jobId); - results.savedObjectsCreated[job.jobId] = { + results.savedObjectsCreated[type]![job.jobId] = { success: true, - type, }; } catch (error) { - results.savedObjectsCreated[job.jobId] = { + results.savedObjectsCreated[type]![job.jobId] = { success: false, - type, + + error: getSavedObjectClientError(error), + }; + } + }); + } + } + } + + for (const model of status.jobs['trained-model']) { + if (model.checks.savedObjectExits === false) { + const { modelId } = model; + const type = 'trained-model'; + if (results.savedObjectsCreated[type] === undefined) { + results.savedObjectsCreated[type] = {}; + } + if (simulate === true) { + results.savedObjectsCreated[type]![modelId] = { success: true }; + } else { + // create model saved objects for models which are missing them + tasks.push(async () => { + try { + const mod = models.trained_model_configs.find((m) => m.model_id === modelId); + if (mod === undefined) { + results.savedObjectsCreated[type]![modelId] = { + success: false, + error: `trained model ${modelId} not found`, + }; + return; + } + const job = getJobDetailsFromTrainedModel(mod); + await jobSavedObjectService.createTrainedModel(modelId, job); + results.savedObjectsCreated[type]![modelId] = { + success: true, + }; + } catch (error) { + results.savedObjectsCreated[type]![modelId] = { + success: false, + error: getSavedObjectClientError(error), }; } @@ -102,8 +148,11 @@ export function syncSavedObjectsFactory( for (const job of status.savedObjects['anomaly-detector']) { if (job.checks.jobExists === false) { const type = 'anomaly-detector'; + if (results.savedObjectsDeleted[type] === undefined) { + results.savedObjectsDeleted[type] = {}; + } if (simulate === true) { - results.savedObjectsDeleted[job.jobId] = { success: true, type }; + results.savedObjectsDeleted[type]![job.jobId] = { success: true }; } else { // Delete AD saved objects for jobs which no longer exist const { jobId, namespaces } = job; @@ -114,11 +163,11 @@ export function syncSavedObjectsFactory( } else { await jobSavedObjectService.deleteAnomalyDetectionJob(jobId); } - results.savedObjectsDeleted[job.jobId] = { success: true, type }; + results.savedObjectsDeleted[type]![job.jobId] = { success: true }; } catch (error) { - results.savedObjectsDeleted[job.jobId] = { + results.savedObjectsDeleted[type]![job.jobId] = { success: false, - type, + error: getSavedObjectClientError(error), }; } @@ -129,8 +178,11 @@ export function syncSavedObjectsFactory( for (const job of status.savedObjects['data-frame-analytics']) { if (job.checks.jobExists === false) { const type = 'data-frame-analytics'; + if (results.savedObjectsDeleted[type] === undefined) { + results.savedObjectsDeleted[type] = {}; + } if (simulate === true) { - results.savedObjectsDeleted[job.jobId] = { success: true, type }; + results.savedObjectsDeleted[type]![job.jobId] = { success: true }; } else { // Delete DFA saved objects for jobs which no longer exist const { jobId, namespaces } = job; @@ -141,14 +193,47 @@ export function syncSavedObjectsFactory( } else { await jobSavedObjectService.deleteDataFrameAnalyticsJob(jobId); } - results.savedObjectsDeleted[job.jobId] = { + results.savedObjectsDeleted[type]![job.jobId] = { success: true, - type, }; } catch (error) { - results.savedObjectsDeleted[job.jobId] = { + results.savedObjectsDeleted[type]![job.jobId] = { success: false, - type, + + error: getSavedObjectClientError(error), + }; + } + }); + } + } + } + + for (const model of status.savedObjects['trained-model']) { + if (model.checks.trainedModelExists === false) { + const { modelId, namespaces } = model; + const type = 'trained-model'; + if (results.savedObjectsDeleted[type] === undefined) { + results.savedObjectsDeleted[type] = {}; + } + + if (simulate === true) { + results.savedObjectsDeleted[type]![modelId] = { success: true }; + } else { + // Delete model saved objects for models which no longer exist + tasks.push(async () => { + try { + if (namespaces !== undefined && namespaces.length) { + await jobSavedObjectService.forceDeleteTrainedModel(modelId, namespaces[0]); + } else { + await jobSavedObjectService.deleteTrainedModel(modelId); + } + results.savedObjectsDeleted[type]![modelId] = { + success: true, + }; + } catch (error) { + results.savedObjectsDeleted[type]![modelId] = { + success: false, + error: getSavedObjectClientError(error), }; } @@ -166,10 +251,13 @@ export function syncSavedObjectsFactory( adJobsById[job.jobId] && adJobsById[job.jobId].datafeedId !== job.datafeedId) ) { + if (results.datafeedsAdded[type] === undefined) { + results.datafeedsAdded[type] = {}; + } // add datafeed id for jobs where the datafeed exists but the id is missing from the saved object // or if the datafeed id in the saved object is not the same as the one attached to the job in es if (simulate === true) { - results.datafeedsAdded[job.jobId] = { success: true, type }; + results.datafeedsAdded[type]![job.jobId] = { success: true }; } else { const df = datafeeds.datafeeds.find((d) => d.job_id === job.jobId); const jobId = job.jobId; @@ -180,11 +268,11 @@ export function syncSavedObjectsFactory( if (datafeedId !== undefined) { await jobSavedObjectService.addDatafeed(datafeedId, jobId); } - results.datafeedsAdded[job.jobId] = { success: true, type }; + results.datafeedsAdded[type]![job.jobId] = { success: true }; } catch (error) { - results.datafeedsAdded[job.jobId] = { + results.datafeedsAdded[type]![job.jobId] = { success: false, - type, + error: getSavedObjectClientError(error), }; } @@ -196,19 +284,22 @@ export function syncSavedObjectsFactory( job.datafeedId !== null && job.datafeedId !== undefined ) { + if (results.datafeedsRemoved[type] === undefined) { + results.datafeedsRemoved[type] = {}; + } // remove datafeed id for jobs where the datafeed no longer exists but the id is populated in the saved object if (simulate === true) { - results.datafeedsRemoved[job.jobId] = { success: true, type }; + results.datafeedsRemoved[type]![job.jobId] = { success: true }; } else { const datafeedId = job.datafeedId; tasks.push(async () => { try { await jobSavedObjectService.deleteDatafeed(datafeedId); - results.datafeedsRemoved[job.jobId] = { success: true, type }; + results.datafeedsRemoved[type]![job.jobId] = { success: true }; } catch (error) { - results.datafeedsRemoved[job.jobId] = { + results.datafeedsRemoved[type]![job.jobId] = { success: false, - type, + error: getSavedObjectClientError(error), }; } @@ -227,6 +318,7 @@ export function syncSavedObjectsFactory( const results: InitializeSavedObjectResponse = { jobs: [], datafeeds: [], + trainedModels: [], success: true, }; const status = await checkStatus(); @@ -235,7 +327,7 @@ export function syncSavedObjectsFactory( return acc; }, {} as Record); - const jobs: Array<{ job: JobObject; namespaces: string[] }> = []; + const jobObjects: Array<{ job: JobObject; namespaces: string[] }> = []; const datafeeds: Array<{ jobId: string; datafeedId: string }> = []; const types: JobType[] = ['anomaly-detector', 'data-frame-analytics']; @@ -245,14 +337,14 @@ export function syncSavedObjectsFactory( if (simulate === true) { results.jobs.push({ id: job.jobId, type }); } else { - jobs.push({ + jobObjects.push({ job: { job_id: job.jobId, datafeed_id: job.datafeedId ?? null, type, }, // allow some jobs to be assigned to specific spaces when initializing - namespaces: spaceOverrides?.overrides[type][job.jobId] ?? ['*'], + namespaces: spaceOverrides?.overrides[type]![job.jobId] ?? ['*'], }); } } @@ -284,10 +376,40 @@ export function syncSavedObjectsFactory( } }); + const models = status.jobs['trained-model'].filter( + ({ checks }) => checks.savedObjectExits === false + ); + const modelObjects: TrainedModelObject[] = []; + + if (models.length) { + if (simulate === true) { + results.trainedModels = models.map(({ modelId }) => ({ id: modelId })); + } else { + const { trained_model_configs: trainedModelConfigs } = + await client.asInternalUser.ml.getTrainedModels({ + model_id: models.map(({ modelId }) => modelId).join(','), + }); + const jobDetails = trainedModelConfigs.reduce((acc, cur) => { + const job = getJobDetailsFromTrainedModel(cur); + if (job !== null) { + acc[cur.model_id] = job; + } + return acc; + }, {} as Record); + + models.forEach(({ modelId }) => { + modelObjects.push({ + model_id: modelId, + job: jobDetails[modelId] ? jobDetails[modelId] : null, + }); + }); + } + } + try { // create missing job saved objects - const createResults = await jobSavedObjectService.bulkCreateJobs(jobs); - createResults.saved_objects.forEach(({ attributes }) => { + const createJobsResults = await jobSavedObjectService.bulkCreateJobs(jobObjects); + createJobsResults.saved_objects.forEach(({ attributes }) => { results.jobs.push({ id: attributes.job_id, type: attributes.type, @@ -302,6 +424,17 @@ export function syncSavedObjectsFactory( type: 'anomaly-detector', }); } + + // use * space if no spaces for related jobs can be found. + const createModelsResults = await jobSavedObjectService.bulkCreateTrainedModel( + modelObjects, + '*' + ); + createModelsResults.saved_objects.forEach(({ attributes }) => { + results.trainedModels.push({ + id: attributes.model_id, + }); + }); } catch (error) { results.success = false; results.error = Boom.boomify(error); @@ -310,14 +443,17 @@ export function syncSavedObjectsFactory( return results; } - async function isSyncNeeded(jobType: JobType) { - const { jobs, datafeeds } = await initSavedObjects(true); + async function isSyncNeeded(jobType?: JobType | TrainedModelType) { + const { jobs, datafeeds, trainedModels } = await initSavedObjects(true); const missingJobs = jobs.length > 0 && (jobType === undefined || jobs.some(({ type }) => type === jobType)); + const missingModels = + trainedModels.length > 0 && (jobType === undefined || jobType === 'trained-model'); + const missingDatafeeds = datafeeds.length > 0 && jobType !== 'data-frame-analytics'; - return missingJobs || missingDatafeeds; + return missingJobs || missingModels || missingDatafeeds; } return { checkStatus, syncSavedObjects, initSavedObjects, isSyncNeeded }; diff --git a/x-pack/plugins/ml/server/saved_objects/sync_task.ts b/x-pack/plugins/ml/server/saved_objects/sync_task.ts new file mode 100644 index 000000000000..eeb86cd11d0d --- /dev/null +++ b/x-pack/plugins/ml/server/saved_objects/sync_task.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger, CoreStart, IScopedClusterClient } from 'kibana/server'; +import { + ConcreteTaskInstance, + TaskManagerSetupContract, + TaskManagerStartContract, + TaskInstance, +} from '../../../task_manager/server'; +import type { SecurityPluginSetup } from '../../../security/server'; +import { savedObjectClientsFactory } from './util'; +import { jobSavedObjectServiceFactory } from './service'; +import { syncSavedObjectsFactory } from './sync'; + +const SAVED_OBJECTS_SYNC_TASK_TYPE = 'ML:saved-objects-sync'; +const SAVED_OBJECTS_SYNC_TASK_ID = 'ML:saved-objects-sync-task'; +const SAVED_OBJECTS_SYNC_INTERVAL_DEFAULT = '1h'; + +export class SavedObjectsSyncService { + private core: CoreStart | null = null; + private log: { error: (t: string, e?: Error) => void; [l: string]: (t: string) => void }; + + constructor(logger: Logger) { + this.log = createLocalLogger(logger, `Task ${SAVED_OBJECTS_SYNC_TASK_ID}: `); + } + + public registerSyncTask( + taskManager: TaskManagerSetupContract, + security: SecurityPluginSetup | undefined, + spacesEnabled: boolean, + isMlReady: () => Promise + ) { + taskManager.registerTaskDefinitions({ + [SAVED_OBJECTS_SYNC_TASK_TYPE]: { + title: 'ML saved objet sync', + description: "This task periodically syncs ML's saved objects", + timeout: '1m', + maxAttempts: 3, + maxConcurrency: 1, + + createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => { + return { + run: async () => { + await isMlReady(); + const core = this.core; + const { state } = taskInstance; + + if (core === null || security === null || spacesEnabled === null) { + const error = 'dependencies not initialized'; + this.log.error(error); + throw new Error(error); + } + const client = core.elasticsearch.client as unknown as IScopedClusterClient; + + const { getInternalSavedObjectsClient } = savedObjectClientsFactory( + () => core.savedObjects + ); + const savedObjectsClient = getInternalSavedObjectsClient(); + if (savedObjectsClient === null) { + const error = 'Internal saved object client not initialized'; + this.log.error(error); + throw new Error(error); + } + + const jobSavedObjectService = jobSavedObjectServiceFactory( + savedObjectsClient, + savedObjectsClient, + spacesEnabled, + security?.authz, + client, + isMlReady + ); + const { initSavedObjects } = syncSavedObjectsFactory(client, jobSavedObjectService); + const { jobs, trainedModels } = await initSavedObjects(false); + const count = jobs.length + trainedModels.length; + + this.log.info( + count + ? `${count} ML saved object${count > 1 ? 's' : ''} synced` + : 'No ML saved objects in need of synchronization' + ); + + return { + state: { + runs: (state.runs ?? 0) + 1, + totalSavedObjectsSynced: (state.totalSavedObjectsSynced ?? 0) + count, + }, + }; + }, + cancel: async () => { + this.log.warn('timed out'); + }, + }; + }, + }, + }); + } + + public async scheduleSyncTask( + taskManager: TaskManagerStartContract, + core: CoreStart + ): Promise { + this.core = core; + try { + await taskManager.removeIfExists(SAVED_OBJECTS_SYNC_TASK_ID); + const taskInstance = await taskManager.ensureScheduled({ + id: SAVED_OBJECTS_SYNC_TASK_ID, + taskType: SAVED_OBJECTS_SYNC_TASK_TYPE, + schedule: { + interval: SAVED_OBJECTS_SYNC_INTERVAL_DEFAULT, + }, + params: {}, + state: { + runs: 0, + totalSavedObjectsSynced: 0, + }, + scope: ['ml'], + }); + + this.log.info(`scheduled with interval ${taskInstance.schedule?.interval}`); + + return taskInstance; + } catch (e) { + this.log.error(`Error running task: ${SAVED_OBJECTS_SYNC_TASK_ID}, `, e?.message() ?? e); + return null; + } + } +} + +function createLocalLogger(logger: Logger, preText: string) { + return { + info: (text: string) => logger.info(`${preText}${text}`), + warn: (text: string) => logger.warn(`${preText}${text}`), + error: (text: string, e?: Error) => logger.error(`${preText}${text} ${e ?? ''}`), + }; +} diff --git a/x-pack/plugins/ml/server/saved_objects/util.ts b/x-pack/plugins/ml/server/saved_objects/util.ts index 76c8b332ee52..f16f80a20390 100644 --- a/x-pack/plugins/ml/server/saved_objects/util.ts +++ b/x-pack/plugins/ml/server/saved_objects/util.ts @@ -5,9 +5,11 @@ * 2.0. */ +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { SavedObjectsServiceStart, KibanaRequest } from 'kibana/server'; import { SavedObjectsClient } from '../../../../../src/core/server'; -import { ML_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects'; +import { ML_JOB_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects'; +import type { TrainedModelJob } from './service'; export function savedObjectClientsFactory( getSavedObjectsStart: () => SavedObjectsServiceStart | null @@ -21,7 +23,7 @@ export function savedObjectClientsFactory( return null; } return savedObjectsStart.getScopedClient(request, { - includedHiddenTypes: [ML_SAVED_OBJECT_TYPE], + includedHiddenTypes: [ML_JOB_SAVED_OBJECT_TYPE], }); }, // create a saved object client which has access to all saved objects @@ -40,3 +42,18 @@ export function savedObjectClientsFactory( export function getSavedObjectClientError(error: any) { return error.isBoom && error.output?.payload ? error.output.payload : error.body ?? error; } + +export function getJobDetailsFromTrainedModel( + model: estypes.MlTrainedModelConfig | estypes.MlPutTrainedModelRequest['body'] +): TrainedModelJob | null { + // @ts-ignore types are wrong + if (model.metadata?.analytics_config === undefined) { + return null; + } + + // @ts-ignore types are wrong + const jobId: string = model.metadata.analytics_config.id; + // @ts-ignore types are wrong + const createTime: number = model.metadata.analytics_config.create_time; + return { job_id: jobId, create_time: createTime }; +} diff --git a/x-pack/plugins/ml/server/shared_services/shared_services.ts b/x-pack/plugins/ml/server/shared_services/shared_services.ts index 5cce757fc757..16dd3cf7bec9 100644 --- a/x-pack/plugins/ml/server/shared_services/shared_services.ts +++ b/x-pack/plugins/ml/server/shared_services/shared_services.ts @@ -201,13 +201,16 @@ function getRequestItemsProvider( // will not receive a real request object when being called from an alert. // instead a dummy request object will be supplied const clusterClient = getClusterClient(); - const jobSavedObjectService = jobSavedObjectServiceFactory( - savedObjectsClient, - internalSavedObjectsClient, - spaceEnabled, - authorization, - isMlReady - ); + const getSobSavedObjectService = (client: IScopedClusterClient) => { + return jobSavedObjectServiceFactory( + savedObjectsClient, + internalSavedObjectsClient, + spaceEnabled, + authorization, + client, + isMlReady + ); + }; if (clusterClient === null) { throw new MLClusterClientUninitialized(`ML's cluster client has not been initialized`); @@ -235,9 +238,11 @@ function getRequestItemsProvider( return fieldFormatRegistry; }; + let jobSavedObjectService; if (request instanceof KibanaRequest) { hasMlCapabilities = getHasMlCapabilities(request); scopedClient = clusterClient.asScoped(request); + jobSavedObjectService = getSobSavedObjectService(scopedClient); mlClient = getMlClient(scopedClient, jobSavedObjectService); } else { hasMlCapabilities = () => Promise.resolve(); @@ -246,6 +251,7 @@ function getRequestItemsProvider( asInternalUser, asCurrentUser: asInternalUser, }; + jobSavedObjectService = getSobSavedObjectService(scopedClient); mlClient = getMlClient(scopedClient, jobSavedObjectService); } diff --git a/x-pack/plugins/ml/server/types.ts b/x-pack/plugins/ml/server/types.ts index d5c67bf99a7a..8120d5c880b6 100644 --- a/x-pack/plugins/ml/server/types.ts +++ b/x-pack/plugins/ml/server/types.ts @@ -28,6 +28,7 @@ import type { FieldFormatsSetup, FieldFormatsStart, } from '../../../../src/plugins/field_formats/server'; +import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; export interface LicenseCheckResult { isAvailable: boolean; @@ -61,6 +62,7 @@ export interface PluginsSetup { alerting?: AlertingPlugin['setup']; actions?: ActionsPlugin['setup']; usageCollection?: UsageCollectionSetup; + taskManager: TaskManagerSetupContract; } export interface PluginsStart { @@ -68,6 +70,7 @@ export interface PluginsStart { dataViews: DataViewsPluginStart; fieldFormats: FieldFormatsStart; spaces?: SpacesPluginStart; + taskManager: TaskManagerStartContract; } export interface RouteInitialization { diff --git a/x-pack/plugins/monitoring/dev_docs/how_to/apm_tracing.md b/x-pack/plugins/monitoring/dev_docs/how_to/apm_tracing.md new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/x-pack/plugins/monitoring/dev_docs/how_to/cloud_setup.md b/x-pack/plugins/monitoring/dev_docs/how_to/cloud_setup.md new file mode 100644 index 000000000000..9ae4e0bd5bfa --- /dev/null +++ b/x-pack/plugins/monitoring/dev_docs/how_to/cloud_setup.md @@ -0,0 +1,50 @@ +First sign up on https://cloud.elastic.co/ and create a deployment in any convenient region, possibly one close to you. + +> **Elasticians**: Please use your work email address when signing up to avoid trial expiration. Also review the (internal) [Cloud First Testing](https://docs.elastic.dev/dev/guides/cloud-first-testing) documentation for additional features available to you. + +Once the deployment is created, enable logging and monitoring as covered in the Elasticsearch Service documentation under [Enable logging and monitoring](https://www.elastic.co/guide/en/cloud/current/ec-enable-logging-and-monitoring.html#ec-enable-logging-and-monitoring-steps). + +For testing purposes, shipping data to the same deployment you just created is fine. + +![Elasticsearch Service Console showing Logs and Metrics being configured to ship data to "this deployment"](../images/ec_logs_and_metrics_configuration.png) + +Once the plan is done you can open Stack Monitoring in the deployment's kibana. + +To connect a locally running instance of kibana to the cloud cluster, you'll need to create a user for it. You can do this via the UI, but here's a curl example for copy-pasting. + +First, set your endpoint and password as shell variables: + +```shell +ELASTICSEARCH_ENDPOINT='<<>>' +ELASTIC_PASSWORD='<<>>' +``` + +Then create a `kibana_dev` user with the same password. `kibana_system` is already in use by the kibana launched by the elasticsearch service: + +```shell +curl -X PUT ${ELASTICSEARCH_ENDPOINT}/_security/user/kibana_dev \ + -H "Content-Type: application/json" \ + -u "elastic:${ELASTIC_PASSWORD}" \ + -d @- < config/kibana.cloud.yml < diff --git a/x-pack/plugins/monitoring/readme.md b/x-pack/plugins/monitoring/readme.md new file mode 100644 index 000000000000..16cbe5ea5023 --- /dev/null +++ b/x-pack/plugins/monitoring/readme.md @@ -0,0 +1,22 @@ +# Documentation for Stack Monitoring developers + +This plugin provides the Stack Monitoring kibana application. + +## Getting started +- [Local setup](dev_docs/how_to/local_setup.md) +- [Cloud setup](dev_docs/how_to/cloud_setup.md) +- [Testing](dev_docs/how_to/testing.md) + +## Concepts +- [Architectural Overview](dev_docs/reference/architectural_overview.md) (WIP) +- [Terminology](dev_docs/reference/terminology.md) (WIP) +- [Data Collection modes](dev_docs/reference/data_collection_modes.md) (WIP) +- [Rules and Alerts](dev_docs/reference/rules_alerts.md) + +## Tooling +- [Debugging logging](dev_docs/how_to/debug_logging.md) (WIP) +- [APM tracing](dev_docs/how_to/apm_tracing.md) (WIP) + +## Troubleshooting +- [Diagnostic queries](dev_docs/runbook/diagnostic_queries.md) (WIP) +- [CPU metrics](dev_docs/runbook/cpu_metrics.md) (WIP) \ No newline at end of file diff --git a/x-pack/plugins/monitoring/server/es_client/instantiate_client.ts b/x-pack/plugins/monitoring/server/es_client/instantiate_client.ts index 096c82a9c456..605cf463bc7e 100644 --- a/x-pack/plugins/monitoring/server/es_client/instantiate_client.ts +++ b/x-pack/plugins/monitoring/server/es_client/instantiate_client.ts @@ -5,7 +5,6 @@ * 2.0. */ import { Logger, ICustomClusterClient, ElasticsearchClientConfig } from 'kibana/server'; -// @ts-ignore import { monitoringBulk } from '../kibana_monitoring/lib/monitoring_bulk'; import { monitoringEndpointDisableWatches } from './monitoring_endpoint_disable_watches'; import { MonitoringElasticsearchConfig } from '../config'; diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts index 7096647854c1..76cc9adeb43e 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts @@ -90,7 +90,6 @@ export function getSettingsCollector( ) { return usageCollection.makeStatsCollector< EmailSettingData | undefined, - false, KibanaSettingsCollectorExtraOptions >({ type: 'kibana_settings', diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.ts index cbbfe64f5e3e..0c952949c56b 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.ts @@ -18,7 +18,7 @@ export function getMonitoringUsageCollector( config: MonitoringConfig, getClient: () => IClusterClient ) { - return usageCollection.makeUsageCollector({ + return usageCollection.makeUsageCollector({ type: 'monitoring', isReady: () => true, schema: { @@ -95,13 +95,8 @@ export function getMonitoringUsageCollector( }, }, }, - extendFetchContext: { - kibanaRequest: true, - }, - fetch: async ({ kibanaRequest }) => { - const callCluster = kibanaRequest - ? getClient().asScoped(kibanaRequest).asCurrentUser - : getClient().asInternalUser; + fetch: async () => { + const callCluster = getClient().asInternalUser; const usageClusters: MonitoringClusterStackProductUsage[] = []; const availableCcs = config.ui.ccs.enabled; const clusters = await fetchClusters(callCluster); diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/lib/monitoring_bulk.js b/x-pack/plugins/monitoring/server/kibana_monitoring/lib/monitoring_bulk.ts similarity index 91% rename from x-pack/plugins/monitoring/server/kibana_monitoring/lib/monitoring_bulk.js rename to x-pack/plugins/monitoring/server/kibana_monitoring/lib/monitoring_bulk.ts index 6c57da9051b3..9e219658439e 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/lib/monitoring_bulk.js +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/lib/monitoring_bulk.ts @@ -5,7 +5,9 @@ * 2.0. */ -export function monitoringBulk(Client, _config, components) { +// TODO: Track down where this function is called by the elasticsearch client setup so we can properly type these + +export function monitoringBulk(Client: any, _config: any, components: any) { const ca = components.clientAction.factory; Client.prototype.monitoring = components.clientAction.namespaceFactory(); const monitoring = Client.prototype.monitoring.prototype; diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.ts b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.ts index c4c31f31df68..ced05dd5ea02 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.ts @@ -50,7 +50,7 @@ export async function getClustersFromRequest( start, end, codePaths, - }: { clusterUuid: string; start: number; end: number; codePaths: string[] } + }: { clusterUuid?: string; start?: number; end?: number; codePaths: string[] } ) { const { filebeatIndexPattern } = indexPatterns; @@ -96,13 +96,14 @@ export async function getClustersFromRequest( cluster.ml = { jobs: mlJobs }; } - cluster.logs = isInCodePath(codePaths, [CODE_PATH_LOGS]) - ? await getLogTypes(req, filebeatIndexPattern, { - clusterUuid: get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid), - start, - end, - }) - : []; + cluster.logs = + start && end && isInCodePath(codePaths, [CODE_PATH_LOGS]) + ? await getLogTypes(req, filebeatIndexPattern, { + clusterUuid: get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid), + start, + end, + }) + : []; } else if (!isStandaloneCluster) { // get all clusters if (!clusters || clusters.length === 0) { diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.ts b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.ts index e9b734d98b70..07cb8751d0bd 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.ts @@ -26,7 +26,7 @@ import { Globals } from '../../static_globals'; * @param {String} clusterUuid (optional) If not undefined, getClusters will filter for a single cluster * @return {Promise} A promise containing an array of clusters. */ -export function getClustersStats(req: LegacyRequest, clusterUuid: string, ccs?: string) { +export function getClustersStats(req: LegacyRequest, clusterUuid?: string, ccs?: string) { return ( fetchClusterStats(req, clusterUuid, ccs) .then((response) => handleClusterStats(response)) @@ -42,7 +42,7 @@ export function getClustersStats(req: LegacyRequest, clusterUuid: string, ccs?: * @param {String} clusterUuid (optional) - if not undefined, getClusters filters for a single clusterUuid * @return {Promise} Object representing each cluster. */ -function fetchClusterStats(req: LegacyRequest, clusterUuid: string, ccs?: string) { +function fetchClusterStats(req: LegacyRequest, clusterUuid?: string, ccs?: string) { const dataset = 'cluster_stats'; const moduleType = 'elasticsearch'; const indexPattern = getNewIndexPatterns({ diff --git a/x-pack/plugins/monitoring/server/lib/create_query.ts b/x-pack/plugins/monitoring/server/lib/create_query.ts index 051b0ed6b4f9..55b96a7d3690 100644 --- a/x-pack/plugins/monitoring/server/lib/create_query.ts +++ b/x-pack/plugins/monitoring/server/lib/create_query.ts @@ -72,7 +72,7 @@ interface CreateQueryOptions { dsDataset?: string; metricset?: string; filters?: any[]; - clusterUuid: string; + clusterUuid?: string; uuid?: string; start?: number; end?: number; diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts index 7673f1b7ff05..3bd9f6d2265d 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts @@ -17,6 +17,8 @@ import { LegacyRequest } from '../../types'; * * @param req {Object} the server route handler request object */ + +// TODO: replace LegacyRequest with current request object + plugin retrieval export async function verifyMonitoringAuth(req: LegacyRequest) { const xpackInfo = get(req.server.plugins.monitoring, 'info'); @@ -38,6 +40,8 @@ export async function verifyMonitoringAuth(req: LegacyRequest) { * @param req {Object} the server route handler request object * @return {Promise} That either resolves with no response (void) or an exception. */ + +// TODO: replace LegacyRequest with current request object + plugin retrieval async function verifyHasPrivileges(req: LegacyRequest) { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_ids.ts b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_ids.ts index 7654ed551b63..91983186218c 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_ids.ts +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_ids.ts @@ -15,7 +15,7 @@ import { Globals } from '../../static_globals'; interface GetLogstashPipelineIdsParams { req: LegacyRequest; - clusterUuid: string; + clusterUuid?: string; size: number; logstashUuid?: string; ccs?: string; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.js b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts similarity index 100% rename from x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.js rename to x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.js b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts similarity index 72% rename from x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.js rename to x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts index 84bea7ba2e8c..450872049a3d 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts @@ -7,17 +7,20 @@ import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; import { handleError } from '../../../../lib/errors'; +import { LegacyRequest, LegacyServer } from '../../../../types'; /* * API for checking read privilege on Monitoring Data * Used for the "Access Denied" page as something to auto-retry with. */ -export function checkAccessRoute(server) { + +// TODO: Replace this LegacyServer call with the "new platform" core Kibana route method +export function checkAccessRoute(server: LegacyServer) { server.route({ method: 'GET', path: '/api/monitoring/v1/check_access', - handler: async (req) => { - const response = {}; + handler: async (req: LegacyRequest) => { + const response: { has_access?: boolean } = {}; try { await verifyMonitoringAuth(req); response.has_access = true; // response data is ignored diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.js b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts similarity index 100% rename from x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.js rename to x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.js b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts similarity index 81% rename from x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.js rename to x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts index 2a1ec03f93db..81acd0e53f31 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts @@ -6,16 +6,19 @@ */ import { schema } from '@kbn/config-schema'; +import { LegacyRequest, LegacyServer } from '../../../../types'; import { getClustersFromRequest } from '../../../../lib/cluster/get_clusters_from_request'; import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; import { handleError } from '../../../../lib/errors'; import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; -export function clustersRoute(server) { +export function clustersRoute(server: LegacyServer) { /* * Monitoring Home * Route Init (for checking license and compatibility for multi-cluster monitoring */ + + // TODO switch from the LegacyServer route() method to the "new platform" route methods server.route({ method: 'POST', path: '/api/monitoring/v1/clusters', @@ -30,7 +33,7 @@ export function clustersRoute(server) { }), }, }, - handler: async (req) => { + handler: async (req: LegacyRequest) => { let clusters = []; const config = server.config; @@ -43,7 +46,7 @@ export function clustersRoute(server) { filebeatIndexPattern: config.ui.logs.index, }); clusters = await getClustersFromRequest(req, indexPatterns, { - codePaths: req.payload.codePaths, + codePaths: req.payload.codePaths as string[], // TODO remove this cast when we can properly type req by using the right route handler }); } catch (err) { throw handleError(err, req); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.js b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts similarity index 100% rename from x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.js rename to x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_telemetry_collection.ts b/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_telemetry_collection.ts index bce6f57d6f95..344b04fb4780 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_telemetry_collection.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_telemetry_collection.ts @@ -34,13 +34,9 @@ export function registerMonitoringTelemetryCollection( getClient: () => IClusterClient, maxBucketSize: number ) { - const monitoringStatsCollector = usageCollection.makeStatsCollector< - MonitoringTelemetryUsage, - true - >({ + const monitoringStatsCollector = usageCollection.makeStatsCollector({ type: 'monitoringTelemetry', isReady: () => true, - extendFetchContext: { kibanaRequest: true }, schema: { stats: { type: 'array', @@ -137,13 +133,13 @@ export function registerMonitoringTelemetryCollection( }, }, }, - fetch: async ({ kibanaRequest, esClient }) => { + fetch: async () => { const timestamp = Date.now(); // Collect the telemetry from the monitoring indices for this moment. // NOTE: Usually, the monitoring indices index stats for each product every 10s (by default). // However, some data may be delayed up-to 24h because monitoring only collects extended Kibana stats in that interval // to avoid overloading of the system when retrieving data from the collectors (that delay is dealt with in the Kibana Stats getter inside the `getAllStats` method). // By 8.x, we expect to stop collecting the Kibana extended stats and keep only the monitoring-related metrics. - const callCluster = kibanaRequest ? esClient : getClient().asInternalUser; + const callCluster = getClient().asInternalUser; const clusterDetails = await getClusterUuids(callCluster, timestamp, maxBucketSize); const [licenses, stats] = await Promise.all([ getLicenses(clusterDetails, callCluster, maxBucketSize), diff --git a/x-pack/plugins/monitoring_collection/README.md b/x-pack/plugins/monitoring_collection/README.md new file mode 100644 index 000000000000..1f2d2984af88 --- /dev/null +++ b/x-pack/plugins/monitoring_collection/README.md @@ -0,0 +1,5 @@ +# Monitoring Collection + +## Plugin + +This plugin allows for other plugins to add data to Kibana stack monitoring documents. \ No newline at end of file diff --git a/x-pack/plugins/monitoring_collection/jest.config.js b/x-pack/plugins/monitoring_collection/jest.config.js new file mode 100644 index 000000000000..d2bb0617857d --- /dev/null +++ b/x-pack/plugins/monitoring_collection/jest.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/monitoring_collection'], + coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/monitoring_collection', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/plugins/monitoring_collection/{common,public,server}/**/*.{js,ts,tsx}', + ], +}; diff --git a/x-pack/plugins/monitoring_collection/kibana.json b/x-pack/plugins/monitoring_collection/kibana.json new file mode 100644 index 000000000000..d88b7e87861e --- /dev/null +++ b/x-pack/plugins/monitoring_collection/kibana.json @@ -0,0 +1,15 @@ +{ + "id": "monitoringCollection", + "version": "8.0.0", + "kibanaVersion": "kibana", + "owner": { + "name": "Stack Monitoring", + "githubTeam": "stack-monitoring-ui" + }, + "configPath": ["monitoring_collection"], + "requiredPlugins": [], + "optionalPlugins": [ + ], + "server": true, + "ui": false +} diff --git a/x-pack/plugins/monitoring_collection/server/config.ts b/x-pack/plugins/monitoring_collection/server/config.ts new file mode 100644 index 000000000000..275d2f31e505 --- /dev/null +++ b/x-pack/plugins/monitoring_collection/server/config.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), +}); + +export type MonitoringCollectionConfig = ReturnType; +export function createConfig(config: TypeOf) { + return config; +} diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/index.tsx b/x-pack/plugins/monitoring_collection/server/constants.ts similarity index 82% rename from x-pack/plugins/timelines/public/components/actions/timeline/index.tsx rename to x-pack/plugins/monitoring_collection/server/constants.ts index c5a69fd5e574..90f3ce67ffae 100644 --- a/x-pack/plugins/timelines/public/components/actions/timeline/index.tsx +++ b/x-pack/plugins/monitoring_collection/server/constants.ts @@ -4,5 +4,4 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -export * from './cases'; +export const TYPE_ALLOWLIST = ['rules', 'actions']; diff --git a/x-pack/plugins/monitoring_collection/server/index.ts b/x-pack/plugins/monitoring_collection/server/index.ts new file mode 100644 index 000000000000..51264a4d8781 --- /dev/null +++ b/x-pack/plugins/monitoring_collection/server/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { PluginInitializerContext, PluginConfigDescriptor } from '../../../../src/core/server'; +import { MonitoringCollectionPlugin } from './plugin'; +import { configSchema } from './config'; + +export type { MonitoringCollectionConfig } from './config'; + +export type { MonitoringCollectionSetup, MetricResult } from './plugin'; + +export const plugin = (initContext: PluginInitializerContext) => + new MonitoringCollectionPlugin(initContext); +export const config: PluginConfigDescriptor> = { + schema: configSchema, +}; diff --git a/x-pack/plugins/monitoring_collection/server/lib/get_es_cluster_uuid.test.ts b/x-pack/plugins/monitoring_collection/server/lib/get_es_cluster_uuid.test.ts new file mode 100644 index 000000000000..536ecdc076e5 --- /dev/null +++ b/x-pack/plugins/monitoring_collection/server/lib/get_es_cluster_uuid.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { getESClusterUuid } from '.'; + +describe('getESClusterUuid', () => { + it('should return the result of the es client call', async () => { + const esClientMock = elasticsearchClientMock.createScopedClusterClient(); + // @ts-ignore + esClientMock.asCurrentUser.info.mockImplementation(() => { + return { cluster_uuid: '1abc' }; + }); + const clusterUuid = await getESClusterUuid(esClientMock); + expect(esClientMock.asCurrentUser.info).toHaveBeenCalledWith({ filter_path: 'cluster_uuid' }); + expect(clusterUuid).toBe('1abc'); + }); + + it('should fail gracefully if an error is thrown at the ES level', async () => { + const esClientMock = elasticsearchClientMock.createScopedClusterClient(); + // @ts-ignore + esClientMock.asCurrentUser.info.mockImplementation(() => { + return undefined; + }); + const clusterUuid = await getESClusterUuid(esClientMock); + expect(esClientMock.asCurrentUser.info).toHaveBeenCalledWith({ filter_path: 'cluster_uuid' }); + expect(clusterUuid).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/monitoring_collection/server/lib/get_es_cluster_uuid.ts b/x-pack/plugins/monitoring_collection/server/lib/get_es_cluster_uuid.ts new file mode 100644 index 000000000000..cca82537cd9e --- /dev/null +++ b/x-pack/plugins/monitoring_collection/server/lib/get_es_cluster_uuid.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 { IScopedClusterClient } from '../../../../../src/core/server'; + +export async function getESClusterUuid(client: IScopedClusterClient) { + const response = await client.asCurrentUser.info({ + filter_path: 'cluster_uuid', + }); + return response?.cluster_uuid; +} diff --git a/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.test.ts b/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.test.ts new file mode 100644 index 000000000000..7697cfda6d22 --- /dev/null +++ b/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ServiceStatusLevels } from '../../../../../src/core/server'; +import { getKibanaStats } from '.'; + +describe('getKibanaStats', () => { + const config = { + allowAnonymous: true, + kibanaIndex: '.kibana', + kibanaVersion: '8.0.0', + uuid: 'abc123', + server: { + name: 'server', + hostname: 'host', + port: 123, + }, + }; + + it('should return the stats', async () => { + const getStatus = () => ({ + level: ServiceStatusLevels.available, + summary: 'Service is working', + }); + const stats = await getKibanaStats({ config, getStatus }); + expect(stats).toStrictEqual({ + uuid: config.uuid, + name: config.server.name, + index: config.kibanaIndex, + host: config.server.hostname, + locale: 'en', + transport_address: `${config.server.hostname}:${config.server.port}`, + version: '8.0.0', + snapshot: false, + status: 'green', + }); + }); + + it('should handle a non green status', async () => { + const getStatus = () => ({ + level: ServiceStatusLevels.critical, + summary: 'Service is NOT working', + }); + const stats = await getKibanaStats({ config, getStatus }); + expect(stats).toStrictEqual({ + uuid: config.uuid, + name: config.server.name, + index: config.kibanaIndex, + host: config.server.hostname, + locale: 'en', + transport_address: `${config.server.hostname}:${config.server.port}`, + version: '8.0.0', + snapshot: false, + status: 'red', + }); + }); +}); diff --git a/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.ts b/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.ts new file mode 100644 index 000000000000..7d3011deb447 --- /dev/null +++ b/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { ServiceStatus, ServiceStatusLevels } from '../../../../../src/core/server'; + +const SNAPSHOT_REGEX = /-snapshot/i; + +const ServiceStatusToLegacyState: Record = { + [ServiceStatusLevels.critical.toString()]: 'red', + [ServiceStatusLevels.unavailable.toString()]: 'red', + [ServiceStatusLevels.degraded.toString()]: 'yellow', + [ServiceStatusLevels.available.toString()]: 'green', +}; + +export function getKibanaStats({ + config, + getStatus, +}: { + config: { + kibanaIndex: string; + kibanaVersion: string; + uuid: string; + server: { + name: string; + hostname: string; + port: number; + }; + }; + getStatus: () => ServiceStatus; +}) { + const status = getStatus(); + return { + uuid: config.uuid, + name: config.server.name, + index: config.kibanaIndex, + host: config.server.hostname, + locale: i18n.getLocale(), + transport_address: `${config.server.hostname}:${config.server.port}`, + version: config.kibanaVersion.replace(SNAPSHOT_REGEX, ''), + snapshot: SNAPSHOT_REGEX.test(config.kibanaVersion), + status: ServiceStatusToLegacyState[status.level.toString()], + }; +} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/index.ts b/x-pack/plugins/monitoring_collection/server/lib/index.ts similarity index 69% rename from x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/index.ts rename to x-pack/plugins/monitoring_collection/server/lib/index.ts index ae6861787044..0c39a62ab359 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/index.ts +++ b/x-pack/plugins/monitoring_collection/server/lib/index.ts @@ -5,4 +5,5 @@ * 2.0. */ -export { PolicyEventFiltersFlyout } from './policy_event_filters_flyout'; +export { getKibanaStats } from './get_kibana_stats'; +export { getESClusterUuid } from './get_es_cluster_uuid'; diff --git a/x-pack/plugins/monitoring_collection/server/plugin.test.ts b/x-pack/plugins/monitoring_collection/server/plugin.test.ts new file mode 100644 index 000000000000..ebdb33c78322 --- /dev/null +++ b/x-pack/plugins/monitoring_collection/server/plugin.test.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { coreMock } from '../../../../src/core/server/mocks'; +import { MonitoringCollectionPlugin } from './plugin'; + +describe('monitoring_collection plugin', () => { + describe('setup()', () => { + let context: ReturnType; + let plugin: MonitoringCollectionPlugin; + let coreSetup: ReturnType; + + beforeEach(() => { + context = coreMock.createPluginInitializerContext(); + plugin = new MonitoringCollectionPlugin(context); + coreSetup = coreMock.createSetup(); + coreSetup.getStartServices = jest.fn().mockResolvedValue([ + { + application: {}, + }, + { triggersActionsUi: {} }, + ]); + }); + + it('should allow registering a collector and getting data from it', async () => { + const { registerMetric } = plugin.setup(coreSetup); + registerMetric<{ name: string }>({ + type: 'actions', + schema: { + name: { + type: 'text', + }, + }, + fetch: async () => { + return [ + { + name: 'foo', + }, + ]; + }, + }); + + const metrics = await plugin.getMetric('actions'); + expect(metrics).toStrictEqual([{ name: 'foo' }]); + }); + + it('should allow registering multiple ollectors and getting data from it', async () => { + const { registerMetric } = plugin.setup(coreSetup); + registerMetric<{ name: string }>({ + type: 'actions', + schema: { + name: { + type: 'text', + }, + }, + fetch: async () => { + return [ + { + name: 'foo', + }, + ]; + }, + }); + registerMetric<{ name: string }>({ + type: 'rules', + schema: { + name: { + type: 'text', + }, + }, + fetch: async () => { + return [ + { + name: 'foo', + }, + { + name: 'bar', + }, + { + name: 'foobar', + }, + ]; + }, + }); + + const metrics = await Promise.all([plugin.getMetric('actions'), plugin.getMetric('rules')]); + expect(metrics).toStrictEqual([ + [{ name: 'foo' }], + [{ name: 'foo' }, { name: 'bar' }, { name: 'foobar' }], + ]); + }); + + it('should NOT allow registering a collector that is not in the allowlist', async () => { + const logger = context.logger.get(); + const { registerMetric } = plugin.setup(coreSetup); + registerMetric<{ name: string }>({ + type: 'test', + schema: { + name: { + type: 'text', + }, + }, + fetch: async () => { + return [ + { + name: 'foo', + }, + ]; + }, + }); + const metrics = await plugin.getMetric('test'); + expect((logger.warn as jest.Mock).mock.calls.length).toBe(2); + expect((logger.warn as jest.Mock).mock.calls[0][0]).toBe( + `Skipping registration of metric type 'test'. This type is not supported in the allowlist.` + ); + expect((logger.warn as jest.Mock).mock.calls[1][0]).toBe( + `Call to 'getMetric' failed because type 'test' does not exist.` + ); + expect(metrics).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/monitoring_collection/server/plugin.ts b/x-pack/plugins/monitoring_collection/server/plugin.ts new file mode 100644 index 000000000000..a86a7c51b485 --- /dev/null +++ b/x-pack/plugins/monitoring_collection/server/plugin.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { JsonObject } from '@kbn/utility-types'; +import { CoreSetup, Plugin, PluginInitializerContext, Logger } from 'kibana/server'; +import { registerDynamicRoute } from './routes'; +import { MakeSchemaFrom } from '../../../../src/plugins/usage_collection/server'; +import { ServiceStatus } from '../../../../src/core/server'; +import { TYPE_ALLOWLIST } from './constants'; + +export interface MonitoringCollectionSetup { + registerMetric: (metric: Metric) => void; +} + +export type MetricResult = T & JsonObject; + +export interface Metric { + type: string; + schema: MakeSchemaFrom; + fetch: () => Promise | Array>>; +} + +export class MonitoringCollectionPlugin implements Plugin { + private readonly initializerContext: PluginInitializerContext; + private readonly logger: Logger; + + private metrics: Record> = {}; + + constructor(initializerContext: PluginInitializerContext) { + this.initializerContext = initializerContext; + this.logger = initializerContext.logger.get(); + } + + async getMetric(type: string) { + if (this.metrics.hasOwnProperty(type)) { + return await this.metrics[type].fetch(); + } + this.logger.warn(`Call to 'getMetric' failed because type '${type}' does not exist.`); + return undefined; + } + + setup(core: CoreSetup) { + const router = core.http.createRouter(); + const kibanaIndex = core.savedObjects.getKibanaIndex(); + + let status: ServiceStatus; + core.status.overall$.subscribe((newStatus) => { + status = newStatus; + }); + + registerDynamicRoute({ + router, + config: { + kibanaIndex, + kibanaVersion: this.initializerContext.env.packageInfo.version, + server: core.http.getServerInfo(), + uuid: this.initializerContext.env.instanceUuid, + }, + getStatus: () => status, + getMetric: async (type: string) => { + return await this.getMetric(type); + }, + }); + + return { + registerMetric: (metric: Metric) => { + if (this.metrics.hasOwnProperty(metric.type)) { + this.logger.warn( + `Skipping registration of metric type '${metric.type}'. This type has already been registered.` + ); + return; + } + if (!TYPE_ALLOWLIST.includes(metric.type)) { + this.logger.warn( + `Skipping registration of metric type '${metric.type}'. This type is not supported in the allowlist.` + ); + return; + } + this.metrics[metric.type] = metric; + }, + }; + } + + start() {} + + stop() {} +} diff --git a/x-pack/plugins/monitoring_collection/server/routes/dynamic_route.test.ts b/x-pack/plugins/monitoring_collection/server/routes/dynamic_route.test.ts new file mode 100644 index 000000000000..88f0f5b7dc8f --- /dev/null +++ b/x-pack/plugins/monitoring_collection/server/routes/dynamic_route.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { registerDynamicRoute } from './dynamic_route'; +import { + KibanaRequest, + KibanaResponseFactory, + ServiceStatusLevels, +} from '../../../../../src/core/server'; +import { httpServerMock, httpServiceMock } from 'src/core/server/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +jest.mock('../lib', () => ({ + getESClusterUuid: () => 'clusterA', + getKibanaStats: () => ({ name: 'myKibana' }), +})); + +describe('dynamic route', () => { + const kibanaStatsConfig = { + allowAnonymous: true, + kibanaIndex: '.kibana', + kibanaVersion: '8.0.0', + uuid: 'abc123', + server: { + name: 'server', + hostname: 'host', + port: 123, + }, + }; + + const getStatus = () => ({ + level: ServiceStatusLevels.available, + summary: 'Service is working', + }); + + it('returns for a valid type', async () => { + const router = httpServiceMock.createRouter(); + + const getMetric = async () => { + return { foo: 1 }; + }; + registerDynamicRoute({ + router, + config: kibanaStatsConfig, + getStatus, + getMetric, + }); + + const [_, handler] = router.get.mock.calls[0]; + + const esClientMock = elasticsearchClientMock.createScopedClusterClient(); + const context = { + core: { + elasticsearch: { + client: esClientMock, + }, + }, + }; + const req = { params: { type: 'test' } } as KibanaRequest; + const factory: jest.Mocked = httpServerMock.createResponseFactory(); + + await handler(context, req, factory); + + expect(factory.ok).toHaveBeenCalledWith({ + body: { + cluster_uuid: 'clusterA', + kibana: { name: 'myKibana' }, + test: { + foo: 1, + }, + }, + }); + }); + + it('returns the an empty object if there is no data', async () => { + const router = httpServiceMock.createRouter(); + + const getMetric = async () => { + return {}; + }; + registerDynamicRoute({ router, config: kibanaStatsConfig, getStatus, getMetric }); + + const [_, handler] = router.get.mock.calls[0]; + + const esClientMock = elasticsearchClientMock.createScopedClusterClient(); + const context = { + core: { + elasticsearch: { + client: esClientMock, + }, + }, + }; + const req = { params: { type: 'test' } } as KibanaRequest; + const factory: jest.Mocked = httpServerMock.createResponseFactory(); + + await handler(context, req, factory); + + expect(factory.ok).toHaveBeenCalledWith({ + body: { + cluster_uuid: 'clusterA', + kibana: { name: 'myKibana' }, + test: {}, + }, + }); + }); +}); diff --git a/x-pack/plugins/monitoring_collection/server/routes/dynamic_route.ts b/x-pack/plugins/monitoring_collection/server/routes/dynamic_route.ts new file mode 100644 index 000000000000..d884d8efc15a --- /dev/null +++ b/x-pack/plugins/monitoring_collection/server/routes/dynamic_route.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { JsonObject } from '@kbn/utility-types'; +import { schema } from '@kbn/config-schema'; +import { IRouter, ServiceStatus } from '../../../../../src/core/server'; +import { getESClusterUuid, getKibanaStats } from '../lib'; +import { MetricResult } from '../plugin'; + +export function registerDynamicRoute({ + router, + config, + getStatus, + getMetric, +}: { + router: IRouter; + config: { + kibanaIndex: string; + kibanaVersion: string; + uuid: string; + server: { + name: string; + hostname: string; + port: number; + }; + }; + getStatus: () => ServiceStatus; + getMetric: ( + type: string + ) => Promise> | MetricResult | undefined>; +}) { + router.get( + { + path: `/api/monitoring_collection/{type}`, + options: { + authRequired: true, + tags: ['api'], // ensures that unauthenticated calls receive a 401 rather than a 302 redirect to login page + }, + validate: { + params: schema.object({ + type: schema.string(), + }), + }, + }, + async (context, req, res) => { + const type = req.params.type; + const [data, clusterUuid, kibana] = await Promise.all([ + getMetric(type), + getESClusterUuid(context.core.elasticsearch.client), + getKibanaStats({ config, getStatus }), + ]); + + return res.ok({ + body: { + [type]: data, + cluster_uuid: clusterUuid, + kibana, + }, + }); + } + ); +} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/index.ts b/x-pack/plugins/monitoring_collection/server/routes/index.ts similarity index 81% rename from x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/index.ts rename to x-pack/plugins/monitoring_collection/server/routes/index.ts index 1360b7ba60e3..eb96ce19f764 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/index.ts +++ b/x-pack/plugins/monitoring_collection/server/routes/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { PolicyTrustedAppsLayout } from './layout'; +export { registerDynamicRoute } from './dynamic_route'; diff --git a/x-pack/plugins/monitoring_collection/tsconfig.json b/x-pack/plugins/monitoring_collection/tsconfig.json new file mode 100644 index 000000000000..41f781cb8cb9 --- /dev/null +++ b/x-pack/plugins/monitoring_collection/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "server/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../monitoring/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/observability/common/ui_settings_keys.ts b/x-pack/plugins/observability/common/ui_settings_keys.ts index 2557217b2211..b1328aec7420 100644 --- a/x-pack/plugins/observability/common/ui_settings_keys.ts +++ b/x-pack/plugins/observability/common/ui_settings_keys.ts @@ -10,3 +10,4 @@ export const maxSuggestions = 'observability:maxSuggestions'; export const enableComparisonByDefault = 'observability:enableComparisonByDefault'; export const enableInfrastructureView = 'observability:enableInfrastructureView'; export const defaultApmServiceEnvironment = 'observability:apmDefaultServiceEnvironment'; +export const enableServiceGroups = 'observability:enableServiceGroups'; diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index d28667d147b2..e2250962c671 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -58,6 +58,7 @@ describe('renderApp', () => { alertingExperience: { enabled: true }, cases: { enabled: true }, overviewNext: { enabled: false }, + rules: { enabled: false }, }, }; diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx index 5e45eda0d317..c5459633bd8e 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx @@ -47,6 +47,7 @@ describe('APMSection', () => { alertingExperience: { enabled: true }, cases: { enabled: true }, overviewNext: { enabled: false }, + rules: { enabled: false }, }, }, observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(), diff --git a/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx index d1597b9a9402..e464e545adad 100644 --- a/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx @@ -47,6 +47,7 @@ describe('UXSection', () => { alertingExperience: { enabled: true }, cases: { enabled: true }, overviewNext: { enabled: false }, + rules: { enabled: false }, }, }, plugins: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.test.tsx index 097ea89826c3..dab2a82e7c7c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.test.tsx @@ -7,14 +7,14 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { mockAppIndexPattern, mockIndexPattern, mockUxSeries, render } from '../rtl_helpers'; +import { mockAppDataView, mockDataView, mockUxSeries, render } from '../rtl_helpers'; import { FilterLabel } from './filter_label'; import * as useSeriesHook from '../hooks/use_series_filters'; import { buildFilterLabel } from '../../filter_value_label/filter_value_label'; // FLAKY: https://github.com/elastic/kibana/issues/115324 describe.skip('FilterLabel', function () { - mockAppIndexPattern(); + mockAppDataView(); const invertFilter = jest.fn(); jest.spyOn(useSeriesHook, 'useSeriesFilters').mockReturnValue({ @@ -30,7 +30,7 @@ describe.skip('FilterLabel', function () { negate={false} seriesId={0} removeFilter={jest.fn()} - indexPattern={mockIndexPattern} + dataView={mockDataView} series={mockUxSeries} /> ); @@ -55,7 +55,7 @@ describe.skip('FilterLabel', function () { negate={false} seriesId={0} removeFilter={removeFilter} - indexPattern={mockIndexPattern} + dataView={mockDataView} series={mockUxSeries} /> ); @@ -79,7 +79,7 @@ describe.skip('FilterLabel', function () { negate={false} seriesId={0} removeFilter={removeFilter} - indexPattern={mockIndexPattern} + dataView={mockDataView} series={mockUxSeries} /> ); @@ -106,7 +106,7 @@ describe.skip('FilterLabel', function () { negate={true} seriesId={0} removeFilter={jest.fn()} - indexPattern={mockIndexPattern} + dataView={mockDataView} series={mockUxSeries} /> ); @@ -126,7 +126,7 @@ describe.skip('FilterLabel', function () { buildFilterLabel({ field: 'user_agent.name', label: 'Browser family', - indexPattern: mockIndexPattern, + dataView: mockDataView, value: 'Firefox', negate: false, }) diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.tsx index c6254a85de9a..077e9be4d634 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { IndexPattern } from '../../../../../../../../src/plugins/data/public'; +import type { DataView } from '../../../../../../../../src/plugins/data_views/common'; import { useSeriesFilters } from '../hooks/use_series_filters'; import { FilterValueLabel } from '../../filter_value_label/filter_value_label'; import { SeriesUrl } from '../types'; @@ -19,7 +19,7 @@ interface Props { series: SeriesUrl; negate: boolean; definitionFilter?: boolean; - indexPattern: IndexPattern; + dataView: DataView; removeFilter: (field: string, value: string | string[], notVal: boolean) => void; } @@ -30,15 +30,15 @@ export function FilterLabel({ field, value, negate, - indexPattern, + dataView, removeFilter, definitionFilter, }: Props) { const { invertFilter } = useSeriesFilters({ seriesId, series }); - return indexPattern ? ( + return dataView ? ( { if (!definitionFilter) invertFilter(val); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts index 93b3203ad671..3c3a634af010 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts @@ -6,12 +6,12 @@ */ import { AppDataType, ReportViewType, SeriesConfig } from '../types'; -import { IndexPattern } from '../../../../../../../../src/plugins/data/common'; +import type { DataView } from '../../../../../../../../src/plugins/data_views/common'; import { ReportConfigMap } from '../contexts/exploratory_view_config'; interface Props { reportType: ReportViewType; - indexPattern: IndexPattern; + dataView: DataView; dataType: AppDataType; reportConfigMap: ReportConfigMap; } @@ -19,13 +19,13 @@ interface Props { export const getDefaultConfigs = ({ reportType, dataType, - indexPattern, + dataView, reportConfigMap, }: Props): SeriesConfig => { let configResult: SeriesConfig | undefined; reportConfigMap[dataType]?.some((fn) => { - const config = fn({ indexPattern }); + const config = fn({ dataView }); if (config.reportType === reportType) { configResult = config; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/infra_metrics/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/infra_metrics/kpi_over_time_config.ts index 5111be8f9e39..56538d252fe3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/infra_metrics/kpi_over_time_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/infra_metrics/kpi_over_time_config.ts @@ -20,7 +20,7 @@ import { SYSTEM_MEMORY_USAGE, } from '../constants/labels'; -export function getMetricsKPIConfig({ indexPattern }: ConfigProps): SeriesConfig { +export function getMetricsKPIConfig({ dataView }: ConfigProps): SeriesConfig { return { reportType: ReportTypes.KPI, defaultSeriesType: 'area', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts index 430cf84c077c..e2b85fdccc53 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -6,7 +6,7 @@ */ import { LayerConfig, LensAttributes } from './lens_attributes'; -import { mockAppIndexPattern, mockIndexPattern } from '../rtl_helpers'; +import { mockAppDataView, mockDataView } from '../rtl_helpers'; import { getDefaultConfigs } from './default_configs'; import { sampleAttribute } from './test_data/sample_attribute'; @@ -21,16 +21,16 @@ import { RECORDS_FIELD, REPORT_METRIC_FIELD, PERCENTILE_RANKS, ReportTypes } fro import { obsvReportConfigMap } from '../obsv_exploratory_view'; describe('Lens Attribute', () => { - mockAppIndexPattern(); + mockAppDataView(); const reportViewConfig = getDefaultConfigs({ reportType: 'data-distribution', dataType: 'ux', - indexPattern: mockIndexPattern, + dataView: mockDataView, reportConfigMap: obsvReportConfigMap, }); - reportViewConfig.baseFilters?.push(...buildExistsFilter('transaction.type', mockIndexPattern)); + reportViewConfig.baseFilters?.push(...buildExistsFilter('transaction.type', mockDataView)); let lnsAttr: LensAttributes; @@ -38,7 +38,7 @@ describe('Lens Attribute', () => { seriesConfig: reportViewConfig, seriesType: 'line', operationType: 'count', - indexPattern: mockIndexPattern, + indexPattern: mockDataView, reportDefinitions: {}, time: { from: 'now-15m', to: 'now' }, color: 'green', @@ -58,7 +58,7 @@ describe('Lens Attribute', () => { const seriesConfigKpi = getDefaultConfigs({ reportType: ReportTypes.KPI, dataType: 'ux', - indexPattern: mockIndexPattern, + dataView: mockDataView, reportConfigMap: obsvReportConfigMap, }); @@ -67,7 +67,7 @@ describe('Lens Attribute', () => { seriesConfig: seriesConfigKpi, seriesType: 'line', operationType: 'count', - indexPattern: mockIndexPattern, + indexPattern: mockDataView, reportDefinitions: { 'service.name': ['elastic-co'] }, time: { from: 'now-15m', to: 'now' }, color: 'green', @@ -83,7 +83,7 @@ describe('Lens Attribute', () => { const seriesConfigKpi = getDefaultConfigs({ reportType: ReportTypes.KPI, dataType: 'ux', - indexPattern: mockIndexPattern, + dataView: mockDataView, reportConfigMap: obsvReportConfigMap, }); @@ -95,7 +95,7 @@ describe('Lens Attribute', () => { from: 'now-1h', to: 'now', }, - indexPattern: mockIndexPattern, + indexPattern: mockDataView, name: 'ux-series-1', breakdown: 'percentile', reportDefinitions: {}, @@ -200,7 +200,7 @@ describe('Lens Attribute', () => { seriesConfig: reportViewConfig, seriesType: 'line', operationType: 'count', - indexPattern: mockIndexPattern, + indexPattern: mockDataView, reportDefinitions: { 'performance.metric': [LCP_FIELD] }, time: { from: 'now-15m', to: 'now' }, color: 'green', @@ -493,7 +493,7 @@ describe('Lens Attribute', () => { seriesConfig: reportViewConfig, seriesType: 'line', operationType: 'count', - indexPattern: mockIndexPattern, + indexPattern: mockDataView, reportDefinitions: { 'performance.metric': [LCP_FIELD] }, breakdown: USER_AGENT_NAME, time: { from: 'now-15m', to: 'now' }, @@ -507,7 +507,7 @@ describe('Lens Attribute', () => { lnsAttr.getBreakdownColumn({ sourceField: USER_AGENT_NAME, layerId: 'layer0', - indexPattern: mockIndexPattern, + indexPattern: mockDataView, labels: layerConfig.seriesConfig.labels, }); @@ -676,14 +676,14 @@ describe('Lens Attribute', () => { describe('Layer Filters', function () { it('should return expected filters', function () { reportViewConfig.baseFilters?.push( - ...buildPhrasesFilter('service.name', ['elastic', 'kibana'], mockIndexPattern) + ...buildPhrasesFilter('service.name', ['elastic', 'kibana'], mockDataView) ); const layerConfig1: LayerConfig = { seriesConfig: reportViewConfig, seriesType: 'line', operationType: 'count', - indexPattern: mockIndexPattern, + indexPattern: mockDataView, reportDefinitions: { 'performance.metric': [LCP_FIELD] }, time: { from: 'now-15m', to: 'now' }, color: 'green', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index 1058973a4432..7f4e1543ce29 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -30,7 +30,7 @@ import { CardinalityIndexPatternColumn, } from '../../../../../../lens/public'; import { urlFiltersToKueryString } from '../utils/stringify_kueries'; -import { IndexPattern } from '../../../../../../../../src/plugins/data/common'; +import type { DataView } from '../../../../../../../../src/plugins/data_views/common'; import { FILTER_RECORDS, USE_BREAK_DOWN_COLUMN, @@ -91,7 +91,7 @@ export interface LayerConfig { operationType?: OperationType; reportDefinitions: URLReportDefinition; time: { to: string; from: string }; - indexPattern: IndexPattern; + indexPattern: DataView; // TODO: Figure out if this can be renamed or if it's a Lens requirement selectedMetricField: string; color: string; name: string; @@ -150,7 +150,7 @@ export class LensAttributes { sourceField: string; layerId: string; labels: Record; - indexPattern: IndexPattern; + indexPattern: DataView; }): TermsIndexPatternColumn { const fieldMeta = indexPattern.getFieldByName(sourceField); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts index b66709d0e228..ae534704976e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts @@ -18,7 +18,7 @@ import { SERVICE_NAME } from '../constants/elasticsearch_fieldnames'; import { MOBILE_APP, NUMBER_OF_DEVICES } from '../constants/labels'; import { MobileFields } from './mobile_fields'; -export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps): SeriesConfig { +export function getMobileDeviceDistributionConfig({ dataView }: ConfigProps): SeriesConfig { return { reportType: ReportTypes.DEVICE_DISTRIBUTION, defaultSeriesType: 'bar', @@ -36,8 +36,8 @@ export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps) filterFields: [...Object.keys(MobileFields), LABEL_FIELDS_FILTER], breakdownFields: Object.keys(MobileFields), baseFilters: [ - ...buildPhraseFilter('agent.name', 'iOS/swift', indexPattern), - ...buildPhraseFilter('processor.event', 'transaction', indexPattern), + ...buildPhraseFilter('agent.name', 'iOS/swift', dataView), + ...buildPhraseFilter('processor.event', 'transaction', dataView), ], labels: { ...FieldLabels, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts index f2f36f7a22ab..cf3fd0f3d2aa 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts @@ -26,7 +26,7 @@ import { import { CPU_USAGE, SYSTEM_MEMORY_USAGE, MOBILE_APP, RESPONSE_LATENCY } from '../constants/labels'; import { MobileFields } from './mobile_fields'; -export function getMobileKPIDistributionConfig({ indexPattern }: ConfigProps): SeriesConfig { +export function getMobileKPIDistributionConfig({ dataView }: ConfigProps): SeriesConfig { return { reportType: ReportTypes.DISTRIBUTION, defaultSeriesType: 'bar', @@ -43,7 +43,7 @@ export function getMobileKPIDistributionConfig({ indexPattern }: ConfigProps): S filterFields: [...Object.keys(MobileFields), LABEL_FIELDS_FILTER], breakdownFields: Object.keys(MobileFields), baseFilters: [ - ...buildPhrasesFilter('agent.name', ['iOS/swift', 'open-telemetry/swift'], indexPattern), + ...buildPhrasesFilter('agent.name', ['iOS/swift', 'open-telemetry/swift'], dataView), ], labels: { ...FieldLabels, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts index 28dbe74c2b70..4d57ca45eae6 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts @@ -32,7 +32,7 @@ import { } from '../constants/labels'; import { MobileFields } from './mobile_fields'; -export function getMobileKPIConfig({ indexPattern }: ConfigProps): SeriesConfig { +export function getMobileKPIConfig({ dataView }: ConfigProps): SeriesConfig { return { reportType: ReportTypes.KPI, defaultSeriesType: 'line', @@ -50,7 +50,7 @@ export function getMobileKPIConfig({ indexPattern }: ConfigProps): SeriesConfig filterFields: [...Object.keys(MobileFields), LABEL_FIELDS_FILTER], breakdownFields: Object.keys(MobileFields), baseFilters: [ - ...buildPhrasesFilter('agent.name', ['iOS/swift', 'open-telemetry/swift'], indexPattern), + ...buildPhrasesFilter('agent.name', ['iOS/swift', 'open-telemetry/swift'], dataView), ], labels: { ...FieldLabels, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/mobile_kpi_config.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/mobile_kpi_config.test.ts index b6f9a4f31134..2a7af16c1b9e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/mobile_kpi_config.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/mobile_kpi_config.test.ts @@ -5,16 +5,16 @@ * 2.0. */ -import { mockAppIndexPattern, mockIndexPattern } from '../../rtl_helpers'; +import { mockAppDataView, mockDataView } from '../../rtl_helpers'; import { LensAttributes } from '../lens_attributes'; import { METRIC_SYSTEM_MEMORY_USAGE, SERVICE_NAME } from '../constants/elasticsearch_fieldnames'; import { obsvReportConfigMap } from '../../obsv_exploratory_view'; import { testMobileKPIAttr } from '../test_data/mobile_test_attribute'; import { getLayerConfigs } from '../../hooks/use_lens_attributes'; -import { IndexPatternState } from '../../hooks/use_app_index_pattern'; +import { DataViewState } from '../../hooks/use_app_data_view'; describe('Mobile kpi config test', function () { - mockAppIndexPattern(); + mockAppDataView(); let lnsAttr: LensAttributes; @@ -31,7 +31,7 @@ describe('Mobile kpi config test', function () { ], 'kpi-over-time', {} as any, - { mobile: mockIndexPattern } as IndexPatternState, + { mobile: mockDataView } as DataViewState, obsvReportConfigMap ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts index 0602e37f61a2..a72bfeeb0d80 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { mockAppIndexPattern, mockIndexPattern } from '../../rtl_helpers'; +import { mockAppDataView, mockDataView } from '../../rtl_helpers'; import { getDefaultConfigs } from '../default_configs'; import { LayerConfig, LensAttributes } from '../lens_attributes'; import { sampleAttributeCoreWebVital } from '../test_data/sample_attribute_cwv'; @@ -13,12 +13,12 @@ import { LCP_FIELD, SERVICE_NAME, USER_AGENT_OS } from '../constants/elasticsear import { obsvReportConfigMap } from '../../obsv_exploratory_view'; describe('Core web vital config test', function () { - mockAppIndexPattern(); + mockAppDataView(); const seriesConfig = getDefaultConfigs({ reportType: 'core-web-vitals', dataType: 'ux', - indexPattern: mockIndexPattern, + dataView: mockDataView, reportConfigMap: obsvReportConfigMap, }); @@ -29,7 +29,7 @@ describe('Core web vital config test', function () { color: 'green', name: 'test-series', breakdown: USER_AGENT_OS, - indexPattern: mockIndexPattern, + indexPattern: mockDataView, time: { from: 'now-15m', to: 'now' }, reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] }, selectedMetricField: LCP_FIELD, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts index e5113211e0a6..0583ab390a0e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts @@ -35,7 +35,7 @@ import { } from '../constants/elasticsearch_fieldnames'; import { CLS_LABEL, FID_LABEL, LCP_LABEL } from '../constants/labels'; -export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): SeriesConfig { +export function getCoreWebVitalsConfig({ dataView }: ConfigProps): SeriesConfig { const statusPallete = euiPaletteForStatus(3); return { @@ -87,8 +87,8 @@ export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): SeriesCon URL_FULL, ], baseFilters: [ - ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern), - ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern), + ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', dataView), + ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', dataView), ], labels: { ...FieldLabels, [SERVICE_NAME]: 'Web Application' }, definitionFields: [SERVICE_NAME, SERVICE_ENVIRONMENT], diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts index 7796b381423b..97b6d2ddf719 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts @@ -45,7 +45,7 @@ import { WEB_APPLICATION_LABEL, } from '../constants/labels'; -export function getRumDistributionConfig({ indexPattern }: ConfigProps): SeriesConfig { +export function getRumDistributionConfig({ dataView }: ConfigProps): SeriesConfig { return { reportType: ReportTypes.DISTRIBUTION, defaultSeriesType: 'line', @@ -90,8 +90,8 @@ export function getRumDistributionConfig({ indexPattern }: ConfigProps): SeriesC { label: CLS_LABEL, id: CLS_FIELD, field: CLS_FIELD }, ], baseFilters: [ - ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern), - ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern), + ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', dataView), + ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', dataView), ], labels: { ...FieldLabels, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts index e78a15ed66ea..4981c5c53155 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts @@ -48,7 +48,7 @@ import { WEB_APPLICATION_LABEL, } from '../constants/labels'; -export function getKPITrendsLensConfig({ indexPattern }: ConfigProps): SeriesConfig { +export function getKPITrendsLensConfig({ dataView }: ConfigProps): SeriesConfig { return { defaultSeriesType: 'bar_stacked', seriesTypes: [], @@ -83,8 +83,8 @@ export function getKPITrendsLensConfig({ indexPattern }: ConfigProps): SeriesCon LABEL_FIELDS_BREAKDOWN, ], baseFilters: [ - ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern), - ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern), + ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', dataView), + ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', dataView), ], labels: { ...FieldLabels, [SERVICE_NAME]: WEB_APPLICATION_LABEL }, definitionFields: [SERVICE_NAME, SERVICE_ENVIRONMENT], diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts index fb44da8e4327..ead75d79582c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts @@ -31,10 +31,7 @@ import { } from '../constants/field_names/synthetics'; import { buildExistsFilter } from '../utils'; -export function getSyntheticsDistributionConfig({ - series, - indexPattern, -}: ConfigProps): SeriesConfig { +export function getSyntheticsDistributionConfig({ series, dataView }: ConfigProps): SeriesConfig { return { reportType: ReportTypes.DISTRIBUTION, defaultSeriesType: series?.seriesType || 'line', @@ -61,7 +58,7 @@ export function getSyntheticsDistributionConfig({ baseFilters: [], definitionFields: [ { field: 'monitor.name', nested: 'synthetics.step.name.keyword', singleSelection: true }, - { field: 'url.full', filters: buildExistsFilter('summary.up', indexPattern) }, + { field: 'url.full', filters: buildExistsFilter('summary.up', dataView) }, ], metricOptions: [ { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts index 63bd7e0cf3e8..217d34facbf0 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts @@ -51,7 +51,7 @@ export const isStepLevelMetric = (metric?: string) => { SYNTHETICS_DOCUMENT_ONLOAD, ].includes(metric); }; -export function getSyntheticsKPIConfig({ indexPattern }: ConfigProps): SeriesConfig { +export function getSyntheticsKPIConfig({ dataView }: ConfigProps): SeriesConfig { return { reportType: ReportTypes.KPI, defaultSeriesType: 'bar_stacked', @@ -78,7 +78,7 @@ export function getSyntheticsKPIConfig({ indexPattern }: ConfigProps): SeriesCon palette: { type: 'palette', name: 'status' }, definitionFields: [ { field: 'monitor.name', nested: SYNTHETICS_STEP_NAME, singleSelection: true }, - { field: 'url.full', filters: buildExistsFilter('summary.up', indexPattern) }, + { field: 'url.full', filters: buildExistsFilter('summary.up', dataView) }, ], metricOptions: [ { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/test_index_pattern.json b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/test_data_view.json similarity index 100% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/test_index_pattern.json rename to x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/test_data_view.json diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts index 29f751258e02..e2cd05087188 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts @@ -15,7 +15,7 @@ import { } from '@kbn/es-query'; import type { ReportViewType, SeriesUrl, UrlFilter } from '../types'; import type { AllSeries, AllShortSeries } from '../hooks/use_series_storage'; -import { IndexPattern } from '../../../../../../../../src/plugins/data/common'; +import type { DataView } from '../../../../../../../../src/plugins/data_views/common'; import { URL_KEYS } from './constants/url_constants'; import { PersistableFilter } from '../../../../../../lens/common'; @@ -78,17 +78,17 @@ export function createExploratoryViewUrl( ); } -export function buildPhraseFilter(field: string, value: string, indexPattern: IndexPattern) { - const fieldMeta = indexPattern?.fields.find((fieldT) => fieldT.name === field); +export function buildPhraseFilter(field: string, value: string, dataView: DataView) { + const fieldMeta = dataView?.fields.find((fieldT) => fieldT.name === field); if (fieldMeta) { - return [esBuildPhraseFilter(fieldMeta, value, indexPattern)]; + return [esBuildPhraseFilter(fieldMeta, value, dataView)]; } return []; } -export function getQueryFilter(field: string, value: string[], indexPattern: IndexPattern) { - const fieldMeta = indexPattern?.fields.find((fieldT) => fieldT.name === field); - if (fieldMeta && indexPattern.id) { +export function getQueryFilter(field: string, value: string[], dataView: DataView) { + const fieldMeta = dataView?.fields.find((fieldT) => fieldT.name === field); + if (fieldMeta && dataView.id) { return value.map((val) => buildQueryFilter( { @@ -97,7 +97,7 @@ export function getQueryFilter(field: string, value: string[], indexPattern: Ind query: `*${val}*`, }, }, - indexPattern.id!, + dataView.id!, '' ) ); @@ -106,21 +106,21 @@ export function getQueryFilter(field: string, value: string[], indexPattern: Ind return []; } -export function buildPhrasesFilter(field: string, value: string[], indexPattern: IndexPattern) { - const fieldMeta = indexPattern?.fields.find((fieldT) => fieldT.name === field); +export function buildPhrasesFilter(field: string, value: string[], dataView: DataView) { + const fieldMeta = dataView?.fields.find((fieldT) => fieldT.name === field); if (fieldMeta) { if (value.length === 1) { - return [esBuildPhraseFilter(fieldMeta, value[0], indexPattern)]; + return [esBuildPhraseFilter(fieldMeta, value[0], dataView)]; } - return [esBuildPhrasesFilter(fieldMeta, value, indexPattern)]; + return [esBuildPhrasesFilter(fieldMeta, value, dataView)]; } return []; } -export function buildExistsFilter(field: string, indexPattern: IndexPattern) { - const fieldMeta = indexPattern?.fields.find((fieldT) => fieldT.name === field); +export function buildExistsFilter(field: string, dataView: DataView) { + const fieldMeta = dataView?.fields.find((fieldT) => fieldT.name === field); if (fieldMeta) { - return [esBuildExistsFilter(fieldMeta, indexPattern)]; + return [esBuildExistsFilter(fieldMeta, dataView)]; } return []; } @@ -130,34 +130,34 @@ type FiltersType = Array; export function urlFilterToPersistedFilter({ urlFilters, initFilters, - indexPattern, + dataView, }: { urlFilters: UrlFilter[]; initFilters?: FiltersType; - indexPattern: IndexPattern; + dataView: DataView; }) { const parsedFilters: FiltersType = initFilters ? [...initFilters] : []; urlFilters.forEach( ({ field, values = [], notValues = [], wildcards = [], notWildcards = ([] = []) }) => { if (values.length > 0) { - const filter = buildPhrasesFilter(field, values, indexPattern); + const filter = buildPhrasesFilter(field, values, dataView); parsedFilters.push(...filter); } if (notValues.length > 0) { - const filter = buildPhrasesFilter(field, notValues, indexPattern)[0]; + const filter = buildPhrasesFilter(field, notValues, dataView)[0]; filter.meta.negate = true; parsedFilters.push(filter); } if (wildcards.length > 0) { - const filter = getQueryFilter(field, wildcards, indexPattern); + const filter = getQueryFilter(field, wildcards, dataView); parsedFilters.push(...filter); } if (notWildcards.length > 0) { - const filter = getQueryFilter(field, notWildcards, indexPattern)[0]; + const filter = getQueryFilter(field, notWildcards, dataView)[0]; filter.meta.negate = true; parsedFilters.push(filter); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/contexts/exploratory_view_config.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/contexts/exploratory_view_config.tsx index b7734e675f39..c464e4a53685 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/contexts/exploratory_view_config.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/contexts/exploratory_view_config.tsx @@ -18,21 +18,21 @@ interface ExploratoryViewContextValue { reportType: ReportViewType | typeof SELECT_REPORT_TYPE; label: string; }>; - indexPatterns: Record; + dataViews: Record; reportConfigMap: ReportConfigMap; setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; theme$: AppMountParameters['theme$']; } export const ExploratoryViewContext = createContext({ - indexPatterns: {}, + dataViews: {}, } as ExploratoryViewContextValue); export function ExploratoryViewContextProvider({ children, reportTypes, dataTypes, - indexPatterns, + dataViews, reportConfigMap, setHeaderActionMenu, theme$, @@ -40,7 +40,7 @@ export function ExploratoryViewContextProvider({ const value = { reportTypes, dataTypes, - indexPatterns, + dataViews, reportConfigMap, setHeaderActionMenu, theme$, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.test.tsx index a21eeca9dcb4..fbaed83c3054 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import Embeddable from './embeddable'; import { LensPublicStart } from '../../../../../../lens/public'; -import { IndexPatternState } from '../hooks/use_app_index_pattern'; +import { DataViewState } from '../hooks/use_app_data_view'; import { render } from '../rtl_helpers'; import { AddToCaseAction } from '../header/add_to_case_action'; import { ActionTypes } from './use_actions'; @@ -77,7 +77,7 @@ const mockTimeRange = { }; const mockOwner = 'securitySolution'; const mockAppId = 'securitySolutionUI'; -const mockIndexPatterns = {} as IndexPatternState; +const mockDataViews = {} as DataViewState; const mockReportType = 'kpi-over-time'; const mockTitle = 'mockTitle'; const mockLens = { @@ -110,7 +110,7 @@ describe('Embeddable', () => { caseOwner={mockOwner} customLensAttrs={mockLensAttrs} customTimeRange={mockTimeRange} - indexPatterns={mockIndexPatterns} + indexPatterns={mockDataViews} lens={mockLens} reportType={mockReportType} title={mockTitle} @@ -128,7 +128,7 @@ describe('Embeddable', () => { caseOwner={mockOwner} customLensAttrs={mockLensAttrs} customTimeRange={mockTimeRange} - indexPatterns={mockIndexPatterns} + indexPatterns={mockDataViews} lens={mockLens} reportType={mockReportType} withActions={mockActions} @@ -146,7 +146,7 @@ describe('Embeddable', () => { caseOwner={mockOwner} customLensAttrs={mockLensAttrs} customTimeRange={mockTimeRange} - indexPatterns={mockIndexPatterns} + indexPatterns={mockDataViews} lens={mockLens} reportType={mockReportType} withActions={mockActions} @@ -181,7 +181,7 @@ describe('Embeddable', () => { caseOwner={mockOwner} customLensAttrs={mockLensAttrs} customTimeRange={mockTimeRange} - indexPatterns={mockIndexPatterns} + indexPatterns={mockDataViews} isSingleMetric={true} lens={mockLens} reportType={mockReportType} @@ -213,7 +213,7 @@ describe('Embeddable', () => { caseOwner={mockOwner} customLensAttrs={mockLensAttrs} customTimeRange={mockTimeRange} - indexPatterns={mockIndexPatterns} + indexPatterns={mockDataViews} isSingleMetric={true} lens={mockLens} reportType={mockReportType} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.tsx index 026f7ab04d68..84ce40f0fe6c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.tsx @@ -14,7 +14,7 @@ import { AppDataType, ReportViewType } from '../types'; import { getLayerConfigs } from '../hooks/use_lens_attributes'; import { LensEmbeddableInput, LensPublicStart, XYState } from '../../../../../../lens/public'; import { OperationTypeComponent } from '../series_editor/columns/operation_type_select'; -import { IndexPatternState } from '../hooks/use_app_index_pattern'; +import { DataViewState } from '../hooks/use_app_data_view'; import { ReportConfigMap } from '../contexts/exploratory_view_config'; import { obsvReportConfigMap } from '../obsv_exploratory_view'; import { ActionTypes, useActions } from './use_actions'; @@ -46,7 +46,7 @@ export interface ExploratoryEmbeddableProps { export interface ExploratoryEmbeddableComponentProps extends ExploratoryEmbeddableProps { lens: LensPublicStart; - indexPatterns: IndexPatternState; + indexPatterns: DataViewState; } // eslint-disable-next-line import/no-default-export diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/index.tsx index 521e7f746fdc..6e9a2c26580d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/index.tsx @@ -11,7 +11,7 @@ import { CoreStart } from 'kibana/public'; import type { ExploratoryEmbeddableProps, ExploratoryEmbeddableComponentProps } from './embeddable'; import { ObservabilityDataViews } from '../../../../utils/observability_data_views'; import { ObservabilityPublicPluginsStart } from '../../../../plugin'; -import type { IndexPatternState } from '../hooks/use_app_index_pattern'; +import type { DataViewState } from '../hooks/use_app_data_view'; import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; import type { AppDataType } from '../types'; @@ -30,7 +30,7 @@ export function getExploratoryViewEmbeddable( plugins: ObservabilityPublicPluginsStart ) { return (props: ExploratoryEmbeddableProps) => { - const [indexPatterns, setIndexPatterns] = useState({} as IndexPatternState); + const [indexPatterns, setIndexPatterns] = useState({} as DataViewState); const [loading, setLoading] = useState(false); const series = props.attributes && props.attributes[0]; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx index b1a1b55b7ed1..5273cc3643cb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { screen } from '@testing-library/dom'; -import { render, mockAppIndexPattern } from './rtl_helpers'; +import { render, mockAppDataView } from './rtl_helpers'; import { ExploratoryView } from './exploratory_view'; import * as obsvDataViews from '../../../utils/observability_data_views/observability_data_views'; import * as pluginHook from '../../../hooks/use_plugin_context'; @@ -19,7 +19,7 @@ jest.spyOn(pluginHook, 'usePluginContext').mockReturnValue({ }, } as any); describe('ExploratoryView', () => { - mockAppIndexPattern(); + mockAppDataView(); beforeEach(() => { const indexPattern = createStubIndexPattern({ diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx index a383bc37880a..4c48ab292b21 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx @@ -22,7 +22,7 @@ import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { useSeriesStorage } from './hooks/use_series_storage'; import { useLensAttributes } from './hooks/use_lens_attributes'; import { TypedLensByValueInput } from '../../../../../lens/public'; -import { useAppIndexPatternContext } from './hooks/use_app_index_pattern'; +import { useAppDataViewContext } from './hooks/use_app_data_view'; import { SeriesViews } from './views/series_views'; import { LensEmbeddable } from './lens_embeddable'; import { EmptyView } from './components/empty_view'; @@ -52,7 +52,7 @@ export function ExploratoryView({ null ); - const { loadIndexPattern, loading } = useAppIndexPatternContext(); + const { loadDataView, loading } = useAppDataViewContext(); const { firstSeries, allSeries, lastRefresh, reportType, setLastRefresh } = useSeriesStorage(); @@ -68,11 +68,11 @@ export function ExploratoryView({ useEffect(() => { allSeries.forEach((seriesT) => { - loadIndexPattern({ + loadDataView({ dataType: seriesT.dataType, }); }); - }, [allSeries, loadIndexPattern]); + }, [allSeries, loadDataView]); useEffect(() => { setLensAttributes(lensAttributesT); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx index f3cc41d50676..05b2b5859d27 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx @@ -102,8 +102,8 @@ describe('AddToCaseAction', function () { ); fireEvent.click(await findByText('Add to case')); - expect(core?.cases?.getAllCasesSelectorModal).toHaveBeenCalledTimes(1); - expect(core?.cases?.getAllCasesSelectorModal).toHaveBeenCalledWith( + expect(core?.cases?.ui.getAllCasesSelectorModal).toHaveBeenCalledTimes(1); + expect(core?.cases?.ui.getAllCasesSelectorModal).toHaveBeenCalledWith( expect.objectContaining({ owner: ['observability'], userCanCrud: true, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx index f91b74d6ed93..3cde63c227b3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx @@ -114,7 +114,7 @@ export function AddToCaseAction({ )} {isCasesOpen && lensAttributes && - cases.getAllCasesSelectorModal(getAllCasesSelectorModalProps)} + cases.ui.getAllCasesSelectorModal(getAllCasesSelectorModalProps)} ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_index_pattern.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_data_view.tsx similarity index 60% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_index_pattern.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_data_view.tsx index 7a370bc10d86..e92b0878ba3e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_index_pattern.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_data_view.tsx @@ -7,7 +7,7 @@ import React, { createContext, useContext, Context, useState, useCallback, useMemo } from 'react'; import { HttpFetchError } from 'kibana/public'; -import { IndexPattern } from '../../../../../../../../src/plugins/data/common'; +import type { DataView } from '../../../../../../../../src/plugins/data_views/common'; import { AppDataType } from '../types'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../../plugin'; @@ -17,40 +17,38 @@ import { useExploratoryView } from '../contexts/exploratory_view_config'; import { DataViewInsufficientAccessError } from '../../../../../../../../src/plugins/data_views/common'; import { getApmDataViewTitle } from '../utils/utils'; -export interface IndexPatternContext { +export interface DataViewContext { loading: boolean; - indexPatterns: IndexPatternState; - indexPatternErrors: IndexPatternErrors; + dataViews: DataViewState; + dataViewErrors: DataViewErrors; hasAppData: HasAppDataState; - loadIndexPattern: (params: { dataType: AppDataType }) => void; + loadDataView: (params: { dataType: AppDataType }) => void; } -export const IndexPatternContext = createContext>({}); +export const DataViewContext = createContext>({}); interface ProviderProps { children: JSX.Element; } type HasAppDataState = Record; -export type IndexPatternState = Record; -export type IndexPatternErrors = Record; +export type DataViewState = Record; +export type DataViewErrors = Record; type LoadingState = Record; -export function IndexPatternContextProvider({ children }: ProviderProps) { +export function DataViewContextProvider({ children }: ProviderProps) { const [loading, setLoading] = useState({} as LoadingState); - const [indexPatterns, setIndexPatterns] = useState({} as IndexPatternState); - const [indexPatternErrors, setIndexPatternErrors] = useState( - {} as IndexPatternErrors - ); + const [dataViews, setDataViews] = useState({} as DataViewState); + const [dataViewErrors, setDataViewErrors] = useState({} as DataViewErrors); const [hasAppData, setHasAppData] = useState({} as HasAppDataState); const { - services: { dataViews }, + services: { dataViews: dataViewsService }, } = useKibana(); - const { indexPatterns: indexPatternsList } = useExploratoryView(); + const { dataViews: dataViewsList } = useExploratoryView(); - const loadIndexPattern: IndexPatternContext['loadIndexPattern'] = useCallback( + const loadDataView: DataViewContext['loadDataView'] = useCallback( async ({ dataType }) => { if (typeof hasAppData[dataType] === 'undefined' && !loading[dataType]) { setLoading((prevState) => ({ ...prevState, [dataType]: true })); @@ -58,8 +56,8 @@ export function IndexPatternContextProvider({ children }: ProviderProps) { try { let hasDataT = false; let indices: string | undefined = ''; - if (indexPatternsList[dataType]) { - indices = indexPatternsList[dataType]; + if (dataViewsList[dataType]) { + indices = dataViewsList[dataType]; hasDataT = true; } switch (dataType) { @@ -84,10 +82,10 @@ export function IndexPatternContextProvider({ children }: ProviderProps) { setHasAppData((prevState) => ({ ...prevState, [dataType]: hasDataT })); if (hasDataT && indices) { - const obsvIndexP = new ObservabilityDataViews(dataViews); - const indPattern = await obsvIndexP.getDataView(dataType, indices); + const obsvDataV = new ObservabilityDataViews(dataViewsService); + const dataV = await obsvDataV.getDataView(dataType, indices); - setIndexPatterns((prevState) => ({ ...prevState, [dataType]: indPattern })); + setDataViews((prevState) => ({ ...prevState, [dataType]: dataV })); } setLoading((prevState) => ({ ...prevState, [dataType]: false })); } catch (e) { @@ -95,48 +93,48 @@ export function IndexPatternContextProvider({ children }: ProviderProps) { e instanceof DataViewInsufficientAccessError || (e as HttpFetchError).body === 'Forbidden' ) { - setIndexPatternErrors((prevState) => ({ ...prevState, [dataType]: e })); + setDataViewErrors((prevState) => ({ ...prevState, [dataType]: e })); } setLoading((prevState) => ({ ...prevState, [dataType]: false })); } } }, - [dataViews, hasAppData, indexPatternsList, loading] + [dataViewsService, hasAppData, dataViewsList, loading] ); return ( - loadingT), }} > {children} - + ); } -export const useAppIndexPatternContext = (dataType?: AppDataType) => { - const { loading, hasAppData, loadIndexPattern, indexPatterns, indexPatternErrors } = useContext( - IndexPatternContext as unknown as Context +export const useAppDataViewContext = (dataType?: AppDataType) => { + const { loading, hasAppData, loadDataView, dataViews, dataViewErrors } = useContext( + DataViewContext as unknown as Context ); - if (dataType && !indexPatterns?.[dataType] && !loading) { - loadIndexPattern({ dataType }); + if (dataType && !dataViews?.[dataType] && !loading) { + loadDataView({ dataType }); } return useMemo(() => { return { hasAppData, loading, - indexPatterns, - indexPatternErrors, - indexPattern: dataType ? indexPatterns?.[dataType] : undefined, + dataViews, + dataViewErrors, + dataView: dataType ? dataViews?.[dataType] : undefined, hasData: dataType ? hasAppData?.[dataType] : undefined, - loadIndexPattern, + loadDataView, }; - }, [dataType, hasAppData, indexPatternErrors, indexPatterns, loadIndexPattern, loading]); + }, [dataType, hasAppData, dataViewErrors, dataViews, loadDataView, loading]); }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_discover_link.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_discover_link.tsx index eac76cc238ad..d3a9d16be8c0 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_discover_link.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_discover_link.tsx @@ -9,7 +9,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Filter } from '@kbn/es-query'; import { useKibana } from '../../../../utils/kibana_react'; import { SeriesConfig, SeriesUrl } from '../types'; -import { useAppIndexPatternContext } from './use_app_index_pattern'; +import { useAppDataViewContext } from './use_app_data_view'; import { buildExistsFilter, urlFilterToPersistedFilter } from '../configurations/utils'; import { getFiltersFromDefs } from './use_lens_attributes'; import { RECORDS_FIELD, RECORDS_PERCENTAGE_FIELD } from '../configurations/constants'; @@ -25,21 +25,21 @@ export const useDiscoverLink = ({ series, seriesConfig }: UseDiscoverLink) => { application: { navigateToUrl }, } = kServices; - const { indexPatterns } = useAppIndexPatternContext(); + const { dataViews } = useAppDataViewContext(); const locator = kServices.discover?.locator; const [discoverUrl, setDiscoverUrl] = useState(''); useEffect(() => { - const indexPattern = indexPatterns?.[series.dataType]; + const dataView = dataViews?.[series.dataType]; - if (indexPattern) { + if (dataView) { const definitions = series.reportDefinitions ?? {}; const urlFilters = (series.filters ?? []).concat(getFiltersFromDefs(definitions)); const filters = urlFilterToPersistedFilter({ - indexPattern, + dataView, urlFilters, initFilters: seriesConfig?.baseFilters, }) as Filter[]; @@ -51,7 +51,7 @@ export const useDiscoverLink = ({ series, seriesConfig }: UseDiscoverLink) => { selectedMetricField !== RECORDS_FIELD && selectedMetricField !== RECORDS_PERCENTAGE_FIELD ) { - filters.push(buildExistsFilter(selectedMetricField, indexPattern)[0]); + filters.push(buildExistsFilter(selectedMetricField, dataView)[0]); } const getDiscoverUrl = async () => { @@ -59,14 +59,14 @@ export const useDiscoverLink = ({ series, seriesConfig }: UseDiscoverLink) => { const newUrl = await locator.getUrl({ filters, - indexPatternId: indexPattern?.id, + indexPatternId: dataView?.id, }); setDiscoverUrl(newUrl); }; getDiscoverUrl(); } }, [ - indexPatterns, + dataViews, series.dataType, series.filters, series.reportDefinitions, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.test.tsx index 05b86277470a..d50e2134546c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.test.tsx @@ -11,11 +11,11 @@ import { allSeriesKey, reportTypeKey, UrlStorageContextProvider } from './use_se import { renderHook } from '@testing-library/react-hooks'; import { useLensAttributes } from './use_lens_attributes'; import { ReportTypes } from '../configurations/constants'; -import { mockIndexPattern } from '../rtl_helpers'; +import { mockDataView } from '../rtl_helpers'; import { createKbnUrlStateStorage } from '../../../../../../../../src/plugins/kibana_utils/public'; import { TRANSACTION_DURATION } from '../configurations/constants/elasticsearch_fieldnames'; import * as lensAttributes from '../configurations/lens_attributes'; -import * as indexPattern from './use_app_index_pattern'; +import * as useAppDataViewHook from './use_app_data_view'; import * as theme from '../../../../hooks/use_theme'; import { dataTypes, obsvReportConfigMap, reportTypesList } from '../obsv_exploratory_view'; import { ExploratoryViewContextProvider } from '../contexts/exploratory_view_config'; @@ -35,14 +35,14 @@ const mockSingleSeries = [ describe('useExpViewTimeRange', function () { const storage = createKbnUrlStateStorage({ useHash: false }); // @ts-ignore - jest.spyOn(indexPattern, 'useAppIndexPatternContext').mockReturnValue({ - indexPatterns: { - ux: mockIndexPattern, - apm: mockIndexPattern, - mobile: mockIndexPattern, - infra_logs: mockIndexPattern, - infra_metrics: mockIndexPattern, - synthetics: mockIndexPattern, + jest.spyOn(useAppDataViewHook, 'useAppDataViewContext').mockReturnValue({ + dataViews: { + ux: mockDataView, + apm: mockDataView, + mobile: mockDataView, + infra_logs: mockDataView, + infra_metrics: mockDataView, + synthetics: mockDataView, }, }); jest.spyOn(theme, 'useTheme').mockReturnValue({ @@ -58,7 +58,7 @@ describe('useExpViewTimeRange', function () { { - const indexPattern = indexPatterns?.[series?.dataType]; + const dataView = dataViews?.[series?.dataType]; if ( - indexPattern && + dataView && !isEmpty(series.reportDefinitions) && !series.hidden && series.selectedMetricField ) { const seriesConfig = getDefaultConfigs({ reportType, - indexPattern, + dataView, dataType: series.dataType, reportConfigMap, }); @@ -70,7 +70,7 @@ export function getLayerConfigs( layerConfigs.push({ filters, - indexPattern, + indexPattern: dataView, seriesConfig, time: series.time, name: series.name, @@ -90,7 +90,7 @@ export function getLayerConfigs( export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null => { const { storage, allSeries, lastRefresh, reportType } = useSeriesStorage(); - const { indexPatterns } = useAppIndexPatternContext(); + const { dataViews } = useAppDataViewContext(); const { reportConfigMap } = useExploratoryView(); @@ -101,14 +101,14 @@ export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null const allSeriesT: AllSeries = convertAllShortSeries(storage.get(allSeriesKey) ?? []); const reportTypeT: ReportViewType = storage.get(reportTypeKey) as ReportViewType; - if (isEmpty(indexPatterns) || isEmpty(allSeriesT) || !reportTypeT) { + if (isEmpty(dataViews) || isEmpty(allSeriesT) || !reportTypeT) { return null; } const layerConfigs = getLayerConfigs( allSeriesT, reportTypeT, theme, - indexPatterns, + dataViews, reportConfigMap ); @@ -121,5 +121,5 @@ export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null return lensAttributes.getJSON(); // we also want to check the state on allSeries changes // eslint-disable-next-line react-hooks/exhaustive-deps - }, [indexPatterns, reportType, storage, theme, lastRefresh, allSeries]); + }, [dataViews, reportType, storage, theme, lastRefresh, allSeries]); }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx index 4fc5293e0372..94954ff5522d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx @@ -12,7 +12,7 @@ import { ExploratoryView } from './exploratory_view'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { useBreadcrumbs } from '../../../hooks/use_breadcrumbs'; -import { IndexPatternContextProvider } from './hooks/use_app_index_pattern'; +import { DataViewContextProvider } from './hooks/use_app_data_view'; import { createKbnUrlStateStorage, withNotifyOnErrors, @@ -73,11 +73,11 @@ export function ExploratoryViewPage({ return ( - + - + ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx index 79c37d5bb0b7..a8deb7643267 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx @@ -101,7 +101,7 @@ export function ObservabilityExploratoryView() { >({ core, kibanaProps, }: MockKibanaProviderProps) { - const indexPattern = mockIndexPattern; + const dataView = mockDataView; setIndexPatterns({ - ...[indexPattern], - get: async () => indexPattern, - } as unknown as IndexPatternsContract); + ...[dataView], + get: async () => dataView, + } as unknown as DataViewsContract); return ( - {children} + {children} @@ -211,7 +214,7 @@ export function render( { return { spy, onRefreshTimeRange }; }; -export const mockAppIndexPattern = (props?: Partial) => { - const loadIndexPattern = jest.fn(); - const spy = jest.spyOn(useAppIndexPatternHook, 'useAppIndexPatternContext').mockReturnValue({ - indexPattern: mockIndexPattern, +export const mockAppDataView = (props?: Partial) => { + const loadDataView = jest.fn(); + const spy = jest.spyOn(useAppDataViewHook, 'useAppDataViewContext').mockReturnValue({ + dataView: mockDataView, hasData: true, loading: false, hasAppData: { ux: true } as any, - loadIndexPattern, - indexPatterns: { ux: mockIndexPattern } as unknown as Record, - indexPatternErrors: {} as any, + loadDataView, + dataViews: { ux: mockDataView } as unknown as Record, + dataViewErrors: {} as any, ...(props || {}), }); - return { spy, loadIndexPattern }; + return { spy, loadDataView }; }; export const mockUseValuesList = (values?: ListItem[]) => { @@ -369,12 +372,12 @@ export const mockHistory = { }, }; -export const mockIndexPattern = createStubIndexPattern({ +export const mockDataView = createStubDataView({ spec: { id: 'apm-*', title: 'apm-*', timeFieldName: '@timestamp', - fields: JSON.parse(indexPatternData.attributes.fields), + fields: JSON.parse(dataViewData.attributes.fields), }, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.test.tsx index e213a4123812..37a554ba334d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { Breakdowns } from './breakdowns'; -import { mockIndexPattern, mockUxSeries, render } from '../../rtl_helpers'; +import { mockDataView, mockUxSeries, render } from '../../rtl_helpers'; import { getDefaultConfigs } from '../../configurations/default_configs'; import { RECORDS_FIELD } from '../../configurations/constants'; import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; @@ -17,7 +17,7 @@ import { obsvReportConfigMap } from '../../obsv_exploratory_view'; describe('Breakdowns', function () { const dataViewSeries = getDefaultConfigs({ reportType: 'data-distribution', - indexPattern: mockIndexPattern, + dataView: mockDataView, dataType: 'ux', reportConfigMap: obsvReportConfigMap, }); @@ -62,7 +62,7 @@ describe('Breakdowns', function () { it('does not show percentile breakdown for records metrics', function () { const kpiConfig = getDefaultConfigs({ reportType: 'kpi-over-time', - indexPattern: mockIndexPattern, + dataView: mockDataView, dataType: 'ux', reportConfigMap: obsvReportConfigMap, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/label_breakdown.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/label_breakdown.tsx index a5723ccb5264..d85c7fcaad72 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/label_breakdown.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/label_breakdown.tsx @@ -9,7 +9,7 @@ import { EuiComboBox, EuiFlexItem } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { SeriesConfig, SeriesUrl } from '../../types'; -import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; import { useSeriesStorage } from '../../hooks/use_series_storage'; import { LABEL_FIELDS_BREAKDOWN } from '../../configurations/constants'; @@ -19,9 +19,9 @@ interface Props { seriesConfig?: SeriesConfig; } export function LabelsBreakdown({ series, seriesId }: Props) { - const { indexPattern } = useAppIndexPatternContext(series.dataType); + const { dataView } = useAppDataViewContext(series.dataType); - const labelFields = indexPattern?.fields.filter((field) => field.name.startsWith('labels.')); + const labelFields = dataView?.fields.filter((field) => field.name.startsWith('labels.')); const { setSeries } = useSeriesStorage(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx index 1afe1f979b27..9856cdd52723 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { mockAppIndexPattern, mockUxSeries, render } from '../../rtl_helpers'; +import { mockAppDataView, mockUxSeries, render } from '../../rtl_helpers'; import { DataTypesSelect } from './data_type_select'; import { DataTypes } from '../../configurations/constants'; import { DataTypesLabels } from '../../obsv_exploratory_view'; @@ -15,7 +15,7 @@ import { DataTypesLabels } from '../../obsv_exploratory_view'; describe('DataTypeSelect', function () { const seriesId = 0; - mockAppIndexPattern(); + mockAppDataView(); it('should render properly', function () { render(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx index b01010e4b81f..51728d628199 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx @@ -14,7 +14,7 @@ import { DateRangePicker } from '../../components/date_range_picker'; import { SeriesDatePicker } from '../../components/series_date_picker'; import { AppDataType, SeriesUrl } from '../../types'; import { ReportTypes } from '../../configurations/constants'; -import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; import { SyntheticsAddData } from '../../../add_data_buttons/synthetics_add_data'; import { MobileAddData } from '../../../add_data_buttons/mobile_add_data'; import { UXAddData } from '../../../add_data_buttons/ux_add_data'; @@ -36,7 +36,7 @@ const AddDataComponents: Record = { export function DatePickerCol({ seriesId, series }: Props) { const { reportType } = useSeriesStorage(); - const { hasAppData } = useAppIndexPatternContext(); + const { hasAppData } = useAppDataViewContext(); if (!series.dataType) { return null; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx index 0a5ac137a787..d821e65afae0 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import { FilterExpanded } from './filter_expanded'; -import { mockUxSeries, mockAppIndexPattern, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockUxSeries, mockAppDataView, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; describe('FilterExpanded', function () { @@ -18,7 +18,7 @@ describe('FilterExpanded', function () { it('render', async () => { const initSeries = { filters }; - mockAppIndexPattern(); + mockAppDataView(); render( ) : ( button diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx index 8e64f4bcea68..83319dc48d7d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { isEmpty } from 'lodash'; import { i18n } from '@kbn/i18n'; import { EuiBadge } from '@elastic/eui'; -import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; import { SeriesConfig, SeriesUrl } from '../../types'; interface Props { @@ -18,7 +18,7 @@ interface Props { } export function IncompleteBadge({ seriesConfig, series }: Props) { - const { loading } = useAppIndexPatternContext(); + const { loading } = useAppDataViewContext(); if (!seriesConfig) { return null; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.test.tsx index 3ec2af4a8c9d..980c02a0ab15 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.test.tsx @@ -9,8 +9,8 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { getDefaultConfigs } from '../../configurations/default_configs'; import { - mockAppIndexPattern, - mockIndexPattern, + mockAppDataView, + mockDataView, mockUseValuesList, mockUxSeries, render, @@ -19,12 +19,12 @@ import { ReportDefinitionCol } from './report_definition_col'; import { obsvReportConfigMap } from '../../obsv_exploratory_view'; describe('Series Builder ReportDefinitionCol', function () { - mockAppIndexPattern(); + mockAppDataView(); const seriesId = 0; const seriesConfig = getDefaultConfigs({ reportType: 'data-distribution', - indexPattern: mockIndexPattern, + dataView: mockDataView, dataType: 'ux', reportConfigMap: obsvReportConfigMap, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx index 2808dfae8352..b518993a5194 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx @@ -9,7 +9,7 @@ import React, { useMemo } from 'react'; import { isEmpty } from 'lodash'; import { ExistsFilter, PhraseFilter } from '@kbn/es-query'; import FieldValueSuggestions from '../../../field_value_suggestions'; -import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; import { ESFilter } from '../../../../../../../../../src/core/types/elasticsearch'; import { PersistableFilter } from '../../../../../../../lens/common'; import { buildPhrasesFilter } from '../../configurations/utils'; @@ -36,7 +36,7 @@ export function ReportDefinitionField({ onChange, filters, }: Props) { - const { indexPattern } = useAppIndexPatternContext(series.dataType); + const { dataView } = useAppDataViewContext(series.dataType); const field = typeof fieldProp === 'string' ? fieldProp : fieldProp.field; @@ -62,10 +62,10 @@ export function ReportDefinitionField({ definitionFields.forEach((fieldObj) => { const fieldT = typeof fieldObj === 'string' ? fieldObj : fieldObj.field; - if (indexPattern && selectedReportDefinitions?.[fieldT] && fieldT !== field) { + if (dataView && selectedReportDefinitions?.[fieldT] && fieldT !== field) { const values = selectedReportDefinitions?.[fieldT]; if (!values.includes(ALL_VALUES_SELECTED)) { - const valueFilter = buildPhrasesFilter(fieldT, values, indexPattern)[0]; + const valueFilter = buildPhrasesFilter(fieldT, values, dataView)[0]; if (valueFilter.query) { filtersN.push(valueFilter.query); } @@ -78,7 +78,7 @@ export function ReportDefinitionField({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(selectedReportDefinitions), JSON.stringify(baseFilters)]); - if (!indexPattern) { + if (!dataView) { return null; } @@ -86,7 +86,7 @@ export function ReportDefinitionField({ onChange(field, val)} filters={queryFilters} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.test.tsx index 9fcf4b14353b..ef0f7c47d3f6 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.test.tsx @@ -7,18 +7,18 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; -import { mockAppIndexPattern, mockIndexPattern, mockUxSeries, render } from '../../rtl_helpers'; +import { mockAppDataView, mockDataView, mockUxSeries, render } from '../../rtl_helpers'; import { SelectedFilters } from './selected_filters'; import { getDefaultConfigs } from '../../configurations/default_configs'; import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; import { obsvReportConfigMap } from '../../obsv_exploratory_view'; describe('SelectedFilters', function () { - mockAppIndexPattern(); + mockAppDataView(); const dataViewSeries = getDefaultConfigs({ reportType: 'data-distribution', - indexPattern: mockIndexPattern, + dataView: mockDataView, dataType: 'ux', reportConfigMap: obsvReportConfigMap, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.tsx index 5bba0b9dfb3c..241a9ad13f98 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.tsx @@ -10,7 +10,7 @@ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/e import { i18n } from '@kbn/i18n'; import { FilterLabel } from '../../components/filter_label'; import { SeriesConfig, SeriesUrl, UrlFilter } from '../../types'; -import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; import { useSeriesFilters } from '../../hooks/use_series_filters'; import { useSeriesStorage } from '../../hooks/use_series_storage'; @@ -28,16 +28,16 @@ export function SelectedFilters({ seriesId, series, seriesConfig }: Props) { const { removeFilter, replaceFilter } = useSeriesFilters({ seriesId, series }); - const { indexPattern } = useAppIndexPatternContext(series.dataType); + const { dataView } = useAppDataViewContext(series.dataType); - if (filters.length === 0 || !indexPattern) { + if (filters.length === 0 || !dataView) { return null; } const btnProps = { seriesId, series, - indexPattern, + dataView, }; return ( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx index b3430da2d35e..129fdf1cf25e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx @@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n'; import { useSeriesStorage } from '../../hooks/use_series_storage'; import { SeriesConfig, SeriesUrl } from '../../types'; import { useDiscoverLink } from '../../hooks/use_discover_link'; -import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; interface Props { seriesId: number; @@ -34,9 +34,9 @@ export function SeriesActions({ seriesId, series, seriesConfig, onEditClick }: P const { href: discoverHref } = useDiscoverLink({ series, seriesConfig }); - const { indexPatterns } = useAppIndexPatternContext(); + const { dataViews } = useAppDataViewContext(); - const indexPattern = indexPatterns?.[series.dataType]; + const dataView = dataViews?.[series.dataType]; const deleteDisabled = seriesId === 0 && allSeries.length > 1; const copySeries = () => { @@ -109,7 +109,7 @@ export function SeriesActions({ seriesId, series, seriesConfig, onEditClick }: P icon="discoverApp" href={discoverHref} aria-label={VIEW_SAMPLE_DOCUMENTS_LABEL} - disabled={!series.dataType || !series.selectedMetricField || !indexPattern} + disabled={!series.dataType || !series.selectedMetricField || !dataView} target="_blank" > {VIEW_SAMPLE_DOCUMENTS_LABEL} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/components/labels_filter.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/components/labels_filter.tsx index 6abe2e8f2a7d..2284b06433e6 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/components/labels_filter.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/components/labels_filter.tsx @@ -19,7 +19,7 @@ import { EuiSelectable } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FilterProps } from '../columns/filter_expanded'; -import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; import { FilterValuesList } from './filter_values_list'; import { useFilterValues } from '../use_filter_values'; @@ -28,9 +28,9 @@ export function LabelsFieldFilter(props: FilterProps) { const [query, setQuery] = useState(''); - const { indexPattern } = useAppIndexPatternContext(series.dataType); + const { dataView } = useAppDataViewContext(series.dataType); - const labelFields = indexPattern?.fields.filter((field) => field.name.startsWith('labels.')); + const labelFields = dataView?.fields.filter((field) => field.name.startsWith('labels.')); const [isPopoverOpen, setPopover] = useState(false); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.test.tsx index afb1043e9caa..5d70e42808ea 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { ExpandedSeriesRow } from './expanded_series_row'; -import { mockIndexPattern, mockUxSeries, render } from '../rtl_helpers'; +import { mockDataView, mockUxSeries, render } from '../rtl_helpers'; import { getDefaultConfigs } from '../configurations/default_configs'; import { PERCENTILE } from '../configurations/constants'; import { obsvReportConfigMap } from '../obsv_exploratory_view'; @@ -17,7 +17,7 @@ describe('ExpandedSeriesRow', function () { const dataViewSeries = getDefaultConfigs({ reportConfigMap: obsvReportConfigMap, reportType: 'kpi-over-time', - indexPattern: mockIndexPattern, + dataView: mockDataView, dataType: 'ux', }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/report_metric_options.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/report_metric_options.test.tsx index 9d83113f2b52..63725346ba18 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/report_metric_options.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/report_metric_options.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { mockAppIndexPattern, mockIndexPattern, mockUxSeries, render } from '../rtl_helpers'; +import { mockAppDataView, mockDataView, mockUxSeries, render } from '../rtl_helpers'; import { getDefaultConfigs } from '../configurations/default_configs'; import { PERCENTILE } from '../configurations/constants'; import { ReportMetricOptions } from './report_metric_options'; @@ -18,7 +18,7 @@ describe('ReportMetricOptions', function () { const dataViewSeries = getDefaultConfigs({ dataType: 'ux', reportType: 'kpi-over-time', - indexPattern: mockIndexPattern, + dataView: mockDataView, reportConfigMap: obsvReportConfigMap, }); @@ -31,7 +31,7 @@ describe('ReportMetricOptions', function () { }); it('should display loading if index pattern is not available and is loading', async function () { - mockAppIndexPattern({ loading: true, indexPatterns: undefined }); + mockAppDataView({ loading: true, dataViews: undefined }); const { container } = render( { setSeries(seriesId, { @@ -52,14 +52,14 @@ export function ReportMetricOptions({ seriesId, series, seriesConfig }: Props) { return null; } - const indexPattern = indexPatterns?.[series.dataType]; - const indexPatternError = indexPatternErrors?.[series.dataType]; + const dataView = dataViews?.[series.dataType]; + const dataViewError = dataViewErrors?.[series.dataType]; const options = (metricOptions ?? []).map(({ label, field, id }) => { let disabled = false; if (field !== RECORDS_FIELD && field !== RECORDS_PERCENTAGE_FIELD && field) { - disabled = !Boolean(indexPattern?.getFieldByName(field)); + disabled = !Boolean(dataView?.getFieldByName(field)); } return { disabled, @@ -85,19 +85,19 @@ export function ReportMetricOptions({ seriesId, series, seriesConfig }: Props) { }; }); - if (indexPatternError && !indexPattern && !loading) { + if (dataViewError && !dataView && !loading) { // TODO: Add a link to docs to explain how to add index patterns return ( - {indexPatternError.body?.error === 'Forbidden' || - indexPatternError.name === 'DataViewInsufficientAccessError' + {dataViewError.body?.error === 'Forbidden' || + dataViewError.name === 'DataViewInsufficientAccessError' ? NO_PERMISSIONS - : indexPatternError.body.message} + : dataViewError.body.message} ); } - if (!indexPattern && !loading) { + if (!dataView && !loading) { return {NO_DATA_AVAILABLE}; } @@ -111,7 +111,7 @@ export function ReportMetricOptions({ seriesId, series, seriesConfig }: Props) { onClick={() => setShowOptions((prevState) => !prevState)} fill size="s" - isLoading={!indexPattern && loading} + isLoading={!dataView && loading} buttonRef={focusButton} > {SELECT_REPORT_METRIC_LABEL} @@ -133,7 +133,7 @@ export function ReportMetricOptions({ seriesId, series, seriesConfig }: Props) { )} {series.selectedMetricField && - (indexPattern ? ( + (dataView ? ( ; export const getSeriesToEdit = ({ - indexPatterns, + dataViews, allSeries, reportType, reportConfigMap, }: { allSeries: SeriesContextValue['allSeries']; - indexPatterns: IndexPatternState; + dataViews: DataViewState; reportType: ReportViewType; reportConfigMap: ReportConfigMap; }): BuilderItem[] => { const getDataViewSeries = (dataType: AppDataType) => { - if (indexPatterns?.[dataType]) { + if (dataViews?.[dataType]) { return getDefaultConfigs({ dataType, reportType, reportConfigMap, - indexPattern: indexPatterns[dataType], + dataView: dataViews[dataType], }); } }; @@ -61,7 +61,7 @@ export const SeriesEditor = React.memo(function () { const { getSeries, allSeries, reportType } = useSeriesStorage(); - const { loading, indexPatterns } = useAppIndexPatternContext(); + const { loading, dataViews } = useAppDataViewContext(); const { reportConfigMap } = useExploratoryView(); @@ -88,7 +88,7 @@ export const SeriesEditor = React.memo(function () { const newEditorItems = getSeriesToEdit({ reportType, allSeries, - indexPatterns, + dataViews, reportConfigMap, }); @@ -108,7 +108,7 @@ export const SeriesEditor = React.memo(function () { setItemIdToExpandedRowMap((prevState) => { return { ...prevState, ...newExpandRows }; }); - }, [allSeries, getSeries, indexPatterns, loading, reportConfigMap, reportType]); + }, [allSeries, getSeries, dataViews, loading, reportConfigMap, reportType]); const toggleDetails = (item: BuilderItem) => { const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/use_filter_values.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/use_filter_values.ts index 3a273cc6adc9..848bd6b44ea1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/use_filter_values.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/use_filter_values.ts @@ -8,7 +8,7 @@ import { ExistsFilter, isExistsFilter } from '@kbn/es-query'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { useValuesList } from '../../../../hooks/use_values_list'; import { FilterProps } from './columns/filter_expanded'; -import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; +import { useAppDataViewContext } from '../hooks/use_app_data_view'; import { ESFilter } from '../../../../../../../../src/core/types/elasticsearch'; import { PersistableFilter } from '../../../../../../lens/common'; @@ -16,7 +16,7 @@ export function useFilterValues( { field, series, baseFilters, label }: FilterProps, query?: string ) { - const { indexPatterns } = useAppIndexPatternContext(series.dataType); + const { dataViews } = useAppDataViewContext(series.dataType); const queryFilters: ESFilter[] = []; @@ -36,6 +36,6 @@ export function useFilterValues( time: series.time, keepHistory: true, filters: queryFilters, - indexPatternTitle: indexPatterns[series.dataType]?.title, + dataViewTitle: dataViews[series.dataType]?.title, }); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts index acd49fc25588..9fa565e4eae3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts @@ -17,7 +17,7 @@ import { } from '../../../../../lens/public'; import { PersistableFilter } from '../../../../../lens/common'; -import { IndexPattern } from '../../../../../../../src/plugins/data/public'; +import type { DataView } from '../../../../../../../src/plugins/data_views/common'; export const ReportViewTypes = { dist: 'data-distribution', @@ -102,7 +102,7 @@ export interface UrlFilter { } export interface ConfigProps { - indexPattern: IndexPattern; + dataView: DataView; series?: SeriesUrl; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/views/add_series_button.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/views/add_series_button.tsx index 4c1bc1d7fe3b..7199a2c47ade 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/views/add_series_button.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/views/add_series_button.tsx @@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n'; import { SeriesUrl, BuilderItem } from '../types'; import { getSeriesToEdit } from '../series_editor/series_editor'; import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; -import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; +import { useAppDataViewContext } from '../hooks/use_app_data_view'; import { DEFAULT_TIME, ReportTypes } from '../configurations/constants'; import { useExploratoryView } from '../contexts/exploratory_view_config'; @@ -21,13 +21,13 @@ export function AddSeriesButton() { const addSeriesButtonRef = useRef(null); const { getSeries, allSeries, setSeries, reportType } = useSeriesStorage(); - const { loading, indexPatterns } = useAppIndexPatternContext(); + const { loading, dataViews } = useAppDataViewContext(); const { reportConfigMap } = useExploratoryView(); useEffect(() => { - setEditorItems(getSeriesToEdit({ allSeries, indexPatterns, reportType, reportConfigMap })); - }, [allSeries, getSeries, indexPatterns, loading, reportConfigMap, reportType]); + setEditorItems(getSeriesToEdit({ allSeries, dataViews, reportType, reportConfigMap })); + }, [allSeries, getSeries, dataViews, loading, reportConfigMap, reportType]); const addSeries = () => { const prevSeries = allSeries?.[0]; diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx index f2a30f8bee35..2cd8487c5704 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx @@ -15,7 +15,7 @@ export function FieldValueSuggestions({ fullWidth, sourceField, label, - indexPatternTitle, + dataViewTitle, selectedValue, excludedValue, filters, @@ -41,7 +41,7 @@ export function FieldValueSuggestions({ const [query, setQuery] = useState(''); const { values, loading } = useValuesList({ - indexPatternTitle, + dataViewTitle, query, sourceField, filters, diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts index 95b24aa69b1e..1ee477e05fbc 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts @@ -33,7 +33,7 @@ interface CommonProps { } export type FieldValueSuggestionsProps = CommonProps & { - indexPatternTitle?: string; + dataViewTitle?: string; sourceField: string; asCombobox?: boolean; onChange: (val?: string[], excludedValue?: string[]) => void; diff --git a/x-pack/plugins/observability/public/components/shared/filter_value_label/filter_value_label.tsx b/x-pack/plugins/observability/public/components/shared/filter_value_label/filter_value_label.tsx index bd3e1d6c8f50..f7cfe7b848fc 100644 --- a/x-pack/plugins/observability/public/components/shared/filter_value_label/filter_value_label.tsx +++ b/x-pack/plugins/observability/public/components/shared/filter_value_label/filter_value_label.tsx @@ -8,28 +8,29 @@ import React from 'react'; import { injectI18n } from '@kbn/i18n-react'; import { Filter, buildPhrasesFilter, buildPhraseFilter } from '@kbn/es-query'; -import { FilterItem, IndexPattern } from '../../../../../../../src/plugins/data/public'; +import { FilterItem } from '../../../../../../../src/plugins/data/public'; +import type { DataView } from '../../../../../../../src/plugins/data_views/common'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; export function buildFilterLabel({ field, value, label, - indexPattern, + dataView, negate, }: { label: string; value: string | string[]; negate: boolean; field: string; - indexPattern: IndexPattern; + dataView: DataView; }) { - const indexField = indexPattern.getFieldByName(field)!; + const indexField = dataView.getFieldByName(field)!; const filter = value instanceof Array && value.length > 1 - ? buildPhrasesFilter(indexField, value, indexPattern) - : buildPhraseFilter(indexField, value as string, indexPattern); + ? buildPhrasesFilter(indexField, value, dataView) + : buildPhraseFilter(indexField, value as string, dataView); filter.meta.type = value instanceof Array && value.length > 1 ? 'phrases' : 'phrase'; @@ -49,7 +50,7 @@ export interface FilterValueLabelProps { negate: boolean; removeFilter: (field: string, value: string | string[], notVal: boolean) => void; invertFilter: (val: { field: string; value: string | string[]; negate: boolean }) => void; - indexPattern: IndexPattern; + dataView: DataView; allowExclusion?: boolean; } export function FilterValueLabel({ @@ -57,22 +58,22 @@ export function FilterValueLabel({ field, value, negate, - indexPattern, + dataView, invertFilter, removeFilter, allowExclusion = true, }: FilterValueLabelProps) { const FilterItemI18n = injectI18n(FilterItem); - const filter = buildFilterLabel({ field, value, label, indexPattern, negate }); + const filter = buildFilterLabel({ field, value, label, dataView, negate }); const { services: { uiSettings }, } = useKibana(); - return indexPattern ? ( + return dataView ? ( { diff --git a/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx b/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx index 36b69782a424..ce27bba1445e 100644 --- a/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx +++ b/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx @@ -71,11 +71,13 @@ export function ObservabilityPageTemplate({ const isSelected = entry.app === currentAppId && - matchPath(currentPath, { - path: entry.path, - exact: !!entry.matchFullPath, - strict: !entry.ignoreTrailingSlash, - }) != null; + (entry.matchPath + ? entry.matchPath(currentPath) + : matchPath(currentPath, { + path: entry.path, + exact: !!entry.matchFullPath, + strict: !entry.ignoreTrailingSlash, + }) != null); const badgeLocalStorageId = `observability.nav_item_badge_visible_${entry.app}${entry.path}`; return { id: `${sectionIndex}.${entryIndex}`, diff --git a/x-pack/plugins/observability/public/hooks/create_use_rules_link.ts b/x-pack/plugins/observability/public/hooks/create_use_rules_link.ts new file mode 100644 index 000000000000..f7995a938c61 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/create_use_rules_link.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 { Options, useLinkProps } from './use_link_props'; + +export function createUseRulesLink(isNewRuleManagementEnabled = false) { + return function (options: Options = {}) { + const linkProps = isNewRuleManagementEnabled + ? { + app: 'observability', + pathname: '/rules', + } + : { + app: 'management', + pathname: '/insightsAndAlerting/triggersActions/rules', + }; + return useLinkProps(linkProps, options); + }; +} diff --git a/x-pack/plugins/observability/public/hooks/use_rules_link.ts b/x-pack/plugins/observability/public/hooks/use_rules_link.ts deleted file mode 100644 index af9f1967d81f..000000000000 --- a/x-pack/plugins/observability/public/hooks/use_rules_link.ts +++ /dev/null @@ -1,20 +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 { useLinkProps, Options, LinkProps } from './use_link_props'; - -export function useRulesLink(options?: Options): LinkProps { - const manageRulesLinkProps = useLinkProps( - { - app: 'management', - pathname: '/insightsAndAlerting/triggersActions/alerts', - }, - options - ); - - return manageRulesLinkProps; -} diff --git a/x-pack/plugins/observability/public/hooks/use_time_range.test.ts b/x-pack/plugins/observability/public/hooks/use_time_range.test.ts index bbf3096e5510..246aa42820b5 100644 --- a/x-pack/plugins/observability/public/hooks/use_time_range.test.ts +++ b/x-pack/plugins/observability/public/hooks/use_time_range.test.ts @@ -29,6 +29,7 @@ describe('useTimeRange', () => { alertingExperience: { enabled: true }, cases: { enabled: true }, overviewNext: { enabled: false }, + rules: { enabled: false }, }, }, plugins: { @@ -78,6 +79,7 @@ describe('useTimeRange', () => { alertingExperience: { enabled: true }, cases: { enabled: true }, overviewNext: { enabled: false }, + rules: { enabled: false }, }, }, plugins: { diff --git a/x-pack/plugins/observability/public/hooks/use_values_list.ts b/x-pack/plugins/observability/public/hooks/use_values_list.ts index e2268f7b8524..5ab9b41ca6f9 100644 --- a/x-pack/plugins/observability/public/hooks/use_values_list.ts +++ b/x-pack/plugins/observability/public/hooks/use_values_list.ts @@ -17,7 +17,7 @@ export interface Props { sourceField: string; label: string; query?: string; - indexPatternTitle?: string; + dataViewTitle?: string; filters?: ESFilter[]; time?: { from: string; to: string }; keepHistory?: boolean; @@ -59,7 +59,7 @@ const getIncludeClause = (sourceField: string, query?: string) => { export const useValuesList = ({ sourceField, - indexPatternTitle, + dataViewTitle, query = '', filters, time, @@ -91,7 +91,7 @@ export const useValuesList = ({ const { data, loading } = useEsSearch( createEsParams({ - index: indexPatternTitle!, + index: dataViewTitle!, body: { query: { bool: { @@ -135,7 +135,7 @@ export const useValuesList = ({ }, }, }), - [debouncedQuery, from, to, JSON.stringify(filters), indexPatternTitle, sourceField], + [debouncedQuery, from, to, JSON.stringify(filters), dataViewTitle, sourceField], { name: `get${label.replace(/\s/g, '')}ValuesList` } ); diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 0ef02fda3fa4..80a2704b5e76 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -27,13 +27,14 @@ export { enableInspectEsQueries, enableComparisonByDefault, enableInfrastructureView, - defaultApmServiceEnvironment, + enableServiceGroups, } from '../common/ui_settings_keys'; export { uptimeOverviewLocatorID } from '../common'; export interface ConfigSchema { unsafe: { alertingExperience: { enabled: boolean }; + rules: { enabled: boolean }; cases: { enabled: boolean }; overviewNext: { enabled: boolean }; }; @@ -82,7 +83,7 @@ export * from './typings'; export { useChartTheme } from './hooks/use_chart_theme'; export { useBreadcrumbs } from './hooks/use_breadcrumbs'; export { useTheme } from './hooks/use_theme'; -export { useRulesLink } from './hooks/use_rules_link'; +export { createUseRulesLink } from './hooks/create_use_rules_link'; export { useLinkProps, shouldHandleLinkEvent } from './hooks/use_link_props'; export type { LinkDescriptor } from './hooks/use_link_props'; @@ -91,7 +92,7 @@ export { getApmTraceUrl } from './utils/get_apm_trace_url'; export { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils'; export { ALL_VALUES_SELECTED } from './components/shared/field_value_suggestions/field_value_combobox'; export type { AllSeries } from './components/shared/exploratory_view/hooks/use_series_storage'; -export type { SeriesUrl, ReportViewType } from './components/shared/exploratory_view/types'; +export type { SeriesUrl } from './components/shared/exploratory_view/types'; export type { ObservabilityRuleTypeFormatter, @@ -100,7 +101,6 @@ export type { } from './rules/create_observability_rule_type_registry'; export { createObservabilityRuleTypeRegistryMock } from './rules/observability_rule_type_registry_mock'; export type { ExploratoryEmbeddableProps } from './components/shared/exploratory_view/embeddable/embeddable'; -export type { ActionTypes } from './components/shared/exploratory_view/embeddable/use_actions'; export type { AddInspectorRequest } from './context/inspector/inspector_context'; export { InspectorContextProvider } from './context/inspector/inspector_context'; 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 89e5c937bacb..174dea627eaf 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 @@ -45,6 +45,7 @@ interface RuleStatsState { muted: number; error: number; } + export interface TopAlert { fields: ParsedTechnicalFields & ParsedExperimentalFields; start: number; @@ -69,7 +70,7 @@ const ALERT_STATUS_REGEX = new RegExp( ); function AlertsPage() { - const { core, plugins, ObservabilityPageTemplate } = usePluginContext(); + const { core, plugins, ObservabilityPageTemplate, config } = usePluginContext(); const [alertFilterStatus, setAlertFilterStatus] = useState('' as AlertStatusFilterButton); const { prepend } = core.http.basePath; const refetch = useRef<() => void>(); @@ -137,9 +138,9 @@ function AlertsPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // In a future milestone we'll have a page dedicated to rule management in - // observability. For now link to the settings page. - const manageRulesHref = prepend('/app/management/insightsAndAlerting/triggersActions/alerts'); + const manageRulesHref = config.unsafe.rules.enabled + ? prepend('/app/observability/rules') + : prepend('/app/management/insightsAndAlerting/triggersActions/rules'); const dynamicIndexPatternsAsyncState = useAsync(async (): Promise => { if (indexNames.length === 0) { @@ -214,7 +215,7 @@ function AlertsPage() { const hasData = hasAnyData === true || (isAllRequestsComplete === false ? undefined : false); const kibana = useKibana(); - const CasesContext = kibana.services.cases.getCasesContext(); + const CasesContext = kibana.services.cases.ui.getCasesContext(); const userPermissions = useGetUserCasesPermissions(); if (!hasAnyData && !isAllRequestsComplete) { 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 3bac9a445f3f..03eaef8f6c8a 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 @@ -65,6 +65,9 @@ import { parseAlert } from '../../components/parse_alert'; import { CoreStart } from '../../../../../../../../src/core/public'; import { translations, paths } from '../../../../config'; import { addDisplayNames } from './add_display_names'; +import { CaseAttachments, CasesUiStart } from '../../../../../../cases/public'; +import { CommentType } from '../../../../../../cases/common'; +import { ADD_TO_EXISTING_CASE, ADD_TO_NEW_CASE } from './translations'; const ALERT_TABLE_STATE_STORAGE_KEY = 'xpack.observability.alert.tableState'; @@ -146,9 +149,9 @@ function ObservabilityActions({ const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); const [openActionsPopoverId, setActionsPopover] = useState(null); const { - timelines, + cases, application: {}, - } = useKibana().services; + } = useKibana().services; const parseObservabilityAlert = useMemo( () => parseAlert(observabilityRuleTypeRegistry), @@ -158,10 +161,6 @@ function ObservabilityActions({ const alert = parseObservabilityAlert(dataFieldEs); const { prepend } = core.http.basePath; - const afterCaseSelection = useCallback(() => { - setActionsPopover(null); - }, []); - const closeActionsPopover = useCallback(() => { setActionsPopover(null); }, []); @@ -171,35 +170,59 @@ function ObservabilityActions({ }, []); const casePermissions = useGetUserCasesPermissions(); - const event = useMemo(() => { - return { - data, - _id: eventId, - ecs: ecsData, - }; - }, [data, eventId, ecsData]); - const ruleId = alert.fields['kibana.alert.rule.uuid'] ?? null; const linkToRule = ruleId ? prepend(paths.management.ruleDetails(ruleId)) : null; + const caseAttachments: CaseAttachments = useMemo(() => { + return ecsData?._id + ? [ + { + alertId: ecsData?._id ?? '', + index: ecsData?._index ?? '', + owner: observabilityFeatureId, + type: CommentType.alert, + rule: cases.helpers.getRuleIdFromEvent({ ecs: ecsData, data: data ?? [] }), + }, + ] + : []; + }, [ecsData, cases.helpers, data]); + + const createCaseFlyout = cases.hooks.getUseCasesAddToNewCaseFlyout({ + attachments: caseAttachments, + }); + + const selectCaseModal = cases.hooks.getUseCasesAddToExistingCaseModal({ + attachments: caseAttachments, + }); + + const handleAddToNewCaseClick = useCallback(() => { + createCaseFlyout.open(); + closeActionsPopover(); + }, [createCaseFlyout, closeActionsPopover]); + + const handleAddToExistingCaseClick = useCallback(() => { + selectCaseModal.open(); + closeActionsPopover(); + }, [closeActionsPopover, selectCaseModal]); + const actionsMenuItems = useMemo(() => { return [ ...(casePermissions?.crud ? [ - timelines.getAddToExistingCaseButton({ - event, - casePermissions, - appId: observabilityAppId, - owner: observabilityFeatureId, - onClose: afterCaseSelection, - }), - timelines.getAddToNewCaseButton({ - event, - casePermissions, - appId: observabilityAppId, - owner: observabilityFeatureId, - onClose: afterCaseSelection, - }), + + {ADD_TO_EXISTING_CASE} + , + + {ADD_TO_NEW_CASE} + , ] : []), @@ -215,7 +238,7 @@ function ObservabilityActions({ ] : []), ]; - }, [afterCaseSelection, casePermissions, timelines, event, linkToRule]); + }, [casePermissions?.crud, handleAddToExistingCaseClick, handleAddToNewCaseClick, linkToRule]); const actionsToolTip = actionsMenuItems.length <= 0 diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/translations.ts b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/translations.ts new file mode 100644 index 000000000000..c72dc4c94375 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/translations.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 { i18n } from '@kbn/i18n'; + +export const ADD_TO_EXISTING_CASE = i18n.translate( + 'xpack.observability.detectionEngine.alerts.actions.addToCase', + { + defaultMessage: 'Add to existing case', + } +); + +export const ADD_TO_NEW_CASE = i18n.translate( + 'xpack.observability.detectionEngine.alerts.actions.addToNewCase', + { + defaultMessage: 'Add to new case', + } +); diff --git a/x-pack/plugins/observability/public/pages/cases/cases.tsx b/x-pack/plugins/observability/public/pages/cases/cases.tsx index 4b9810421ba5..0898f4aa8d07 100644 --- a/x-pack/plugins/observability/public/pages/cases/cases.tsx +++ b/x-pack/plugins/observability/public/pages/cases/cases.tsx @@ -19,7 +19,7 @@ interface CasesProps { } export const Cases = React.memo(({ userCanCrud }) => { const { - cases: casesUi, + cases, application: { getUrlForApp, navigateToApp }, } = useKibana().services; const { observabilityRuleTypeRegistry } = usePluginContext(); @@ -42,7 +42,7 @@ export const Cases = React.memo(({ userCanCrud }) => { /> )} - {casesUi.getCases({ + {cases.ui.getCases({ basePath: CASES_PATH, userCanCrud, owner: [CASES_OWNER], diff --git a/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx b/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx index 4d4ef5b81484..12e866d9cecb 100644 --- a/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx +++ b/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx @@ -8,6 +8,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiHorizontalRule } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useMemo, useRef, useCallback } from 'react'; +import { observabilityFeatureId } from '../../../common'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { useTrackPageview } from '../..'; import { EmptySections } from '../../components/app/empty_sections'; import { ObservabilityHeaderMenu } from '../../components/app/header'; @@ -28,6 +30,8 @@ import { DataSections } from './data_sections'; import { LoadingObservability } from './loading_observability'; import { AlertsTableTGrid } from '../alerts/containers/alerts_table_t_grid/alerts_table_t_grid'; import { SectionContainer } from '../../components/app/section'; +import { ObservabilityAppServices } from '../../application/types'; +import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions'; interface Props { routeParams: RouteParams<'/overview'>; } @@ -85,6 +89,10 @@ export function OverviewPage({ routeParams }: Props) { return refetch.current && refetch.current(); }, []); + const kibana = useKibana(); + const CasesContext = kibana.services.cases.ui.getCasesContext(); + const userPermissions = useGetUserCasesPermissions(); + if (hasAnyData === undefined) { return ; } @@ -130,12 +138,18 @@ export function OverviewPage({ routeParams }: Props) { })} hasError={false} > - + + +
    diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index 6955f5aee909..bc9f15bbf420 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -80,6 +80,7 @@ const withCore = makeDecorator({ alertingExperience: { enabled: true }, cases: { enabled: true }, overviewNext: { enabled: false }, + rules: { enabled: false }, }, }, core: options as CoreStart, diff --git a/x-pack/plugins/observability/public/pages/rules/index.tsx b/x-pack/plugins/observability/public/pages/rules/index.tsx new file mode 100644 index 000000000000..5728ac1e41ed --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/index.tsx @@ -0,0 +1,261 @@ +/* + * Copyright 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 { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiText, + EuiBadge, + EuiPopover, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiHorizontalRule, + EuiAutoRefreshButton, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { usePluginContext } from '../../hooks/use_plugin_context'; +import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; + +import { useKibana } from '../../utils/kibana_react'; + +const DEFAULT_SEARCH_PAGE_SIZE: number = 25; + +interface RuleState { + data: []; + totalItemsCount: number; +} + +interface Pagination { + index: number; + size: number; +} + +export function RulesPage() { + const { core, ObservabilityPageTemplate } = usePluginContext(); + const { docLinks } = useKibana().services; + const { + http, + notifications: { toasts }, + } = core; + const [rules, setRules] = useState({ data: [], totalItemsCount: 0 }); + const [page, setPage] = useState({ index: 0, size: DEFAULT_SEARCH_PAGE_SIZE }); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + async function loadObservabilityRules() { + const { loadRules } = await import('../../../../triggers_actions_ui/public'); + try { + const response = await loadRules({ + http, + page: { index: 0, size: DEFAULT_SEARCH_PAGE_SIZE }, + typesFilter: [ + 'xpack.uptime.alerts.monitorStatus', + 'xpack.uptime.alerts.tls', + 'xpack.uptime.alerts.tlsCertificate', + 'xpack.uptime.alerts.durationAnomaly', + 'apm.error_rate', + 'apm.transaction_error_rate', + 'apm.transaction_duration', + 'apm.transaction_duration_anomaly', + 'metrics.alert.inventory.threshold', + 'metrics.alert.threshold', + 'logs.alert.document.count', + ], + }); + setRules({ + data: response.data as any, + totalItemsCount: response.total, + }); + } catch (_e) { + toasts.addDanger({ + title: i18n.translate('xpack.observability.rules.loadError', { + defaultMessage: 'Unable to load rules', + }), + }); + } + } + + enum RuleStatus { + enabled = 'enabled', + disabled = 'disabled', + } + + const statuses = Object.values(RuleStatus); + const togglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); + const popOverButton = ( + + Enabled + + ); + + const panelItems = statuses.map((status) => ( + + {status} + + )); + + useEffect(() => { + loadObservabilityRules(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useBreadcrumbs([ + { + text: i18n.translate('xpack.observability.breadcrumbs.rulesLinkText', { + defaultMessage: 'Rules', + }), + }, + ]); + + const rulesTableColumns = [ + { + field: 'name', + name: i18n.translate('xpack.observability.rules.rulesTable.columns.nameTitle', { + defaultMessage: 'Rule Name', + }), + }, + { + field: 'executionStatus.lastExecutionDate', + name: i18n.translate('xpack.observability.rules.rulesTable.columns.lastRunTitle', { + defaultMessage: 'Last run', + }), + render: (date: Date) => { + if (date) { + return ( + <> + + + + {moment(date).fromNow()} + + + + + ); + } + }, + }, + { + field: 'executionStatus.status', + name: i18n.translate('xpack.observability.rules.rulesTable.columns.lastResponseTitle', { + defaultMessage: 'Last response', + }), + }, + { + field: 'enabled', + name: i18n.translate('xpack.observability.rules.rulesTable.columns.statusTitle', { + defaultMessage: 'Status', + }), + render: (_enabled: boolean) => { + return ( + + + + ); + }, + }, + { + field: '*', + name: i18n.translate('xpack.observability.rules.rulesTable.columns.actionsTitle', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: 'Edit', + isPrimary: true, + description: 'Edit this rule', + icon: 'pencil', + type: 'icon', + onClick: () => {}, + 'data-test-subj': 'action-edit', + }, + ], + }, + ]; + return ( + {i18n.translate('xpack.observability.rulesTitle', { defaultMessage: 'Rules' })} + ), + rightSideItems: [ + + + , + ], + }} + > + + + + + + + + {}} + shortHand + /> + + + + + + { + setPage(changedPage); + }} + selection={{ + selectable: () => true, + onSelectionChange: (selectedItems) => {}, + }} + /> + + + + ); +} diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 38f300af9aa1..3d2505ed8051 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -46,6 +46,7 @@ import { createNavigationRegistry, NavigationEntry } from './services/navigation import { updateGlobalNavigation } from './update_global_navigation'; import { getExploratoryViewEmbeddable } from './components/shared/exploratory_view/embeddable'; import { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils'; +import { createUseRulesLink } from './hooks/create_use_rules_link'; export type ObservabilityPublicSetup = ReturnType; @@ -92,11 +93,20 @@ export class Plugin path: '/alerts', navLinkStatus: AppNavLinkStatus.hidden, }, + { + id: 'rules', + title: i18n.translate('xpack.observability.rulesLinkTitle', { + defaultMessage: 'Rules', + }), + order: 8002, + path: '/rules', + navLinkStatus: AppNavLinkStatus.hidden, + }, getCasesDeepLinks({ basePath: casesPath, extend: { [CasesDeepLinkId.cases]: { - order: 8002, + order: 8003, navLinkStatus: AppNavLinkStatus.hidden, }, [CasesDeepLinkId.casesCreate]: { @@ -242,6 +252,7 @@ export class Plugin navigation: { registerSections: this.navigationRegistry.registerSections, }, + useRulesLink: createUseRulesLink(config.unsafe.rules.enabled), }; } @@ -270,6 +281,7 @@ export class Plugin }, createExploratoryViewUrl, ExploratoryViewEmbeddable: getExploratoryViewEmbeddable(coreStart, pluginsStart), + useRulesLink: createUseRulesLink(config.unsafe.rules.enabled), }; } } diff --git a/x-pack/plugins/observability/public/routes/index.tsx b/x-pack/plugins/observability/public/routes/index.tsx index 5f85ccd3af7b..d895f55152ef 100644 --- a/x-pack/plugins/observability/public/routes/index.tsx +++ b/x-pack/plugins/observability/public/routes/index.tsx @@ -15,6 +15,7 @@ import { LandingPage } from '../pages/landing'; 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'; export type RouteParams = DecodeParams; @@ -87,4 +88,11 @@ export const routes = { }, exact: true, }, + '/rules': { + handler: () => { + return ; + }, + params: {}, + exact: true, + }, }; diff --git a/x-pack/plugins/observability/public/services/navigation_registry.ts b/x-pack/plugins/observability/public/services/navigation_registry.ts index 6efa1e014c0c..706a8850c178 100644 --- a/x-pack/plugins/observability/public/services/navigation_registry.ts +++ b/x-pack/plugins/observability/public/services/navigation_registry.ts @@ -32,6 +32,8 @@ export interface NavigationEntry { onClick?: (event: React.MouseEvent) => void; // shows NEW badge besides the navigation label, which will automatically disappear when menu item is clicked. isNewFeature?: boolean; + // override default path matching logic to determine if nav entry is selected + matchPath?: (path: string) => boolean; } export interface NavigationRegistry { diff --git a/x-pack/plugins/observability/public/update_global_navigation.tsx b/x-pack/plugins/observability/public/update_global_navigation.tsx index 2de282432c15..3e720ac170e8 100644 --- a/x-pack/plugins/observability/public/update_global_navigation.tsx +++ b/x-pack/plugins/observability/public/update_global_navigation.tsx @@ -48,6 +48,14 @@ export function updateGlobalNavigation({ ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden, }; + case 'rules': + return { + ...link, + navLinkStatus: + config.unsafe.rules.enabled && someVisible + ? AppNavLinkStatus.visible + : AppNavLinkStatus.hidden, + }; default: return link; } diff --git a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.test.ts b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.test.ts index 0a24f35a498c..eb7df98da599 100644 --- a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.test.ts +++ b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.test.ts @@ -6,7 +6,7 @@ */ import { dataViewList, ObservabilityDataViews } from './observability_data_views'; -import { mockCore, mockIndexPattern } from '../../components/shared/exploratory_view/rtl_helpers'; +import { mockCore, mockDataView } from '../../components/shared/exploratory_view/rtl_helpers'; import { SavedObjectNotFound } from '../../../../../../src/plugins/kibana_utils/public'; const fieldFormats = { @@ -116,11 +116,11 @@ describe('ObservabilityIndexPatterns', function () { }); it('should validate field formats', async function () { - mockIndexPattern.getFormatterForField = jest.fn().mockReturnValue({ params: () => {} }); + mockDataView.getFormatterForField = jest.fn().mockReturnValue({ params: () => {} }); const obsv = new ObservabilityDataViews(dataViews!); - await obsv.validateFieldFormats('ux', mockIndexPattern); + await obsv.validateFieldFormats('ux', mockDataView); expect(dataViews?.updateSavedObject).toHaveBeenCalledTimes(1); expect(dataViews?.updateSavedObject).toHaveBeenCalledWith( diff --git a/x-pack/plugins/observability/public/utils/test_helper.tsx b/x-pack/plugins/observability/public/utils/test_helper.tsx index a3ec446e5c30..214b32ca9efa 100644 --- a/x-pack/plugins/observability/public/utils/test_helper.tsx +++ b/x-pack/plugins/observability/public/utils/test_helper.tsx @@ -39,6 +39,7 @@ const config = { alertingExperience: { enabled: true }, cases: { enabled: true }, overviewNext: { enabled: false }, + rules: { enabled: false }, }, }; diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts index 80da043d26d7..8c81d7d8f9f1 100644 --- a/x-pack/plugins/observability/server/index.ts +++ b/x-pack/plugins/observability/server/index.ts @@ -33,6 +33,7 @@ export const config: PluginConfigDescriptor = { }), unsafe: schema.object({ alertingExperience: schema.object({ enabled: schema.boolean({ defaultValue: true }) }), + rules: schema.object({ enabled: schema.boolean({ defaultValue: false }) }), cases: schema.object({ enabled: schema.boolean({ defaultValue: true }) }), overviewNext: schema.object({ enabled: schema.boolean({ defaultValue: false }) }), }), diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index 866c8fe5432d..db6bc3041fe3 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -15,8 +15,16 @@ import { maxSuggestions, enableInfrastructureView, defaultApmServiceEnvironment, + enableServiceGroups, } from '../common/ui_settings_keys'; +const technicalPreviewLabel = i18n.translate( + 'xpack.observability.uiSettings.technicalPreviewLabel', + { + defaultMessage: 'technical preview', + } +); + /** * uiSettings definitions for Observability. */ @@ -59,7 +67,7 @@ export const uiSettings: Record[${technicalPreviewLabel}]` }, + }), + schema: schema.boolean(), + requiresPageReload: true, + }, }; diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts b/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts index a674eb4d9682..4c72a871b5b5 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts @@ -77,13 +77,30 @@ describe('SuperUser - Packs', () => { findAndClickButton('Add query'); cy.contains('Attach next query'); inputQuery('select * from uptime'); + findFormFieldByRowsLabelAndType('ID', SAVED_QUERY_ID); + cy.contains('ID must be unique').should('exist'); findFormFieldByRowsLabelAndType('ID', NEW_QUERY_NAME); + cy.contains('ID must be unique').should('not.exist'); cy.react('EuiFlyoutFooter').react('EuiButton').contains('Save').click(); cy.react('EuiTableRow').contains(NEW_QUERY_NAME); findAndClickButton('Update pack'); cy.contains('Save and deploy changes'); findAndClickButton('Save and deploy changes'); }); + + it('should trigger validation when saved query is being chosen', () => { + preparePack(PACK_NAME, SAVED_QUERY_ID); + findAndClickButton('Edit'); + findAndClickButton('Add query'); + cy.contains('Attach next query'); + cy.contains('ID must be unique').should('not.exist'); + cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) + .click() + .type(SAVED_QUERY_ID); + cy.react('List').first().click(); + cy.contains('ID must be unique').should('exist'); + cy.react('EuiFlyoutFooter').react('EuiButtonEmpty').contains('Cancel').click(); + }); // THIS TESTS TAKES TOO LONG FOR NOW - LET ME THINK IT THROUGH it.skip('to click the icon and visit discover', () => { preparePack(PACK_NAME, SAVED_QUERY_ID); diff --git a/x-pack/plugins/osquery/cypress/support/commands.ts b/x-pack/plugins/osquery/cypress/support/commands.ts deleted file mode 100644 index a0f3744f992b..000000000000 --- a/x-pack/plugins/osquery/cypress/support/commands.ts +++ /dev/null @@ -1,36 +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. - */ - -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) - -Cypress.Commands.add('getBySel', (selector, ...args) => - cy.get(`[data-test-subj=${selector}]`, ...args) -); diff --git a/x-pack/plugins/osquery/cypress/support/index.ts b/x-pack/plugins/osquery/cypress/support/index.ts index 5fe2342cc5c7..15e2b2516cbe 100644 --- a/x-pack/plugins/osquery/cypress/support/index.ts +++ b/x-pack/plugins/osquery/cypress/support/index.ts @@ -22,27 +22,25 @@ // https://on.cypress.io/configuration // *********************************************************** +// force ESM in this module +export {}; + // eslint-disable-next-line import/no-extraneous-dependencies import 'cypress-react-selector'; -// Import commands.js using ES2015 syntax: -import './commands'; // import './coverage'; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { interface Chainable { - getBySel: typeof cy.get; + getBySel(...args: Parameters): Chainable>; } } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function getBySel(selector: string, ...args: any[]) { - return cy.get(`[data-test-subj=${selector}]`, ...args); -} - -Cypress.Commands.add('getBySel', getBySel); +Cypress.Commands.add('getBySel', (selector, ...args) => + cy.get(`[data-test-subj="${selector}"]`, ...args) +); // Alternatively you can use CommonJS syntax: // require('./commands') diff --git a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx index bb63d733f36c..c0f3a33e8d42 100644 --- a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx +++ b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx @@ -590,7 +590,7 @@ export const ECSMappingEditorForm = forwardRef ({ key: { type: FIELD_TYPES.COMBO_BOX, - fieldsToValidateOnChange: ['result.value'], + fieldsToValidateOnChange: ['result.value', 'key'], validations: [ { validator: getEcsFieldValidator(editForm), @@ -638,7 +638,7 @@ export const ECSMappingEditorForm = forwardRef { validate(); - validateFields(['result.value']); + validateFields(['result.value', 'key']); const { data, isValid } = await submit(); if (isValid) { diff --git a/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx b/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx index c5000c104458..8ddd2d14bf14 100644 --- a/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx +++ b/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx @@ -53,9 +53,12 @@ const QueryFlyoutComponent: React.FC = ({ defaultValue, handleSubmit: async (payload, isValid) => { const ecsFieldValue = await ecsFieldRef?.current?.validate(); + const isEcsFieldValueValid = + ecsFieldValue && + Object.values(ecsFieldValue).every((field) => !isEmpty(Object.values(field)[0])); return new Promise((resolve) => { - if (isValid && ecsFieldValue) { + if (isValid && isEcsFieldValueValid) { onSave({ ...payload, ...(isEmpty(ecsFieldValue) ? {} : { ecs_mapping: ecsFieldValue }), @@ -67,7 +70,7 @@ const QueryFlyoutComponent: React.FC = ({ }, }); - const { submit, setFieldValue, reset, isSubmitting } = form; + const { submit, setFieldValue, reset, isSubmitting, validate } = form; const [{ query }] = useFormData({ form, @@ -102,8 +105,10 @@ const QueryFlyoutComponent: React.FC = ({ setFieldValue('ecs_mapping', savedQuery.ecs_mapping); } } + + validate(); }, - [setFieldValue, reset] + [reset, validate, setFieldValue] ); /* Avoids accidental closing of the flyout when the user clicks outside of the flyout */ const maskProps = useMemo(() => ({ onClick: () => ({}) }), []); diff --git a/x-pack/plugins/osquery/public/saved_queries/form/playground_flyout.tsx b/x-pack/plugins/osquery/public/saved_queries/form/playground_flyout.tsx index f0b9fc1e935c..60f1dff40086 100644 --- a/x-pack/plugins/osquery/public/saved_queries/form/playground_flyout.tsx +++ b/x-pack/plugins/osquery/public/saved_queries/form/playground_flyout.tsx @@ -27,7 +27,7 @@ interface PlaygroundFlyoutProps { const PlaygroundFlyoutComponent: React.FC = ({ enabled, onClose }) => { // eslint-disable-next-line @typescript-eslint/naming-convention - const [{ query, ecs_mapping, savedQueryId }] = useFormData({ + const [{ query, ecs_mapping, id }] = useFormData({ watch: ['query', 'ecs_mapping', 'savedQueryId'], }); @@ -45,11 +45,11 @@ const PlaygroundFlyoutComponent: React.FC = ({ enabled, o diff --git a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts index a4672e46dcce..37c08d712e3f 100644 --- a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts +++ b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts @@ -39,13 +39,16 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon }, async (context, request, response) => { const esClient = context.core.elasticsearch.client.asInternalUser; - const soClient = context.core.savedObjects.client; const internalSavedObjectsClient = await getInternalSavedObjectsClient( osqueryContext.getStartServices ); const { agentSelection } = request.body as { agentSelection: AgentSelection }; - const selectedAgents = await parseAgentSelection(soClient, osqueryContext, agentSelection); + const selectedAgents = await parseAgentSelection( + internalSavedObjectsClient, + osqueryContext, + agentSelection + ); incrementCount(internalSavedObjectsClient, 'live_query'); if (!selectedAgents.length) { incrementCount(internalSavedObjectsClient, 'live_query', 'errors'); diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx index cc726f29e3a7..a2f72ae0f810 100644 --- a/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx @@ -69,6 +69,7 @@ export const ContextTab: FunctionComponent = () => { itemLayoutAlign="top" hasDividers fullWidth + data-test-subj="painlessContextDropDown" /> diff --git a/x-pack/plugins/reporting/common/errors/errors.test.ts b/x-pack/plugins/reporting/common/errors/errors.test.ts index e0cc86a40d37..37210373f4d6 100644 --- a/x-pack/plugins/reporting/common/errors/errors.test.ts +++ b/x-pack/plugins/reporting/common/errors/errors.test.ts @@ -5,17 +5,23 @@ * 2.0. */ -import { AuthenticationExpiredError } from '.'; +import * as errors from '.'; describe('ReportingError', () => { it('provides error code when stringified', () => { - expect(new AuthenticationExpiredError() + '').toBe( - `ReportingError(code: authentication_expired)` + expect(new errors.AuthenticationExpiredError() + '').toBe( + `ReportingError(code: authentication_expired_error)` ); }); it('provides details if there are any and error code when stringified', () => { - expect(new AuthenticationExpiredError('some details') + '').toBe( - `ReportingError(code: authentication_expired) "some details"` + expect(new errors.AuthenticationExpiredError('some details') + '').toBe( + `ReportingError(code: authentication_expired_error) "some details"` ); }); + it('has the expected code structure', () => { + const { ReportingError: _, ...nonAbstractErrors } = errors; + Object.values(nonAbstractErrors).forEach((Ctor) => { + expect(new Ctor().code).toMatch(/^[a-z_]+_error$/); + }); + }); }); diff --git a/x-pack/plugins/reporting/common/errors/index.ts b/x-pack/plugins/reporting/common/errors/index.ts index 6064eca33ed7..d5032c206a18 100644 --- a/x-pack/plugins/reporting/common/errors/index.ts +++ b/x-pack/plugins/reporting/common/errors/index.ts @@ -9,6 +9,12 @@ import { i18n } from '@kbn/i18n'; export abstract class ReportingError extends Error { + /** + * A string that uniquely brands an error type. This is used to power telemetry + * about reporting failures. + * + * @note Convention for codes: lower-case, snake-case and end in `_error`. + */ public abstract code: string; constructor(public details?: string) { @@ -32,7 +38,7 @@ export abstract class ReportingError extends Error { * access token expired. */ export class AuthenticationExpiredError extends ReportingError { - code = 'authentication_expired'; + code = 'authentication_expired_error'; } export class QueueTimeoutError extends ReportingError { @@ -54,12 +60,39 @@ export class PdfWorkerOutOfMemoryError extends ReportingError { 'Cannot generate PDF due to low memory. Consider making a smaller PDF before retrying this report.', }); + /** + * No need to provide extra details, we know exactly what happened and can provide + * a nicely formatted message + */ public override get message(): string { return this.details; } } -// TODO: Add ReportingError for Kibana stopping unexpectedly -// TODO: Add ReportingError for missing Chromium dependencies -// TODO: Add ReportingError for missing Chromium dependencies -// TODO: Add ReportingError for Chromium not starting for an unknown reason +export class BrowserCouldNotLaunchError extends ReportingError { + code = 'browser_could_not_launch_error'; + + details = i18n.translate('xpack.reporting.common.browserCouldNotLaunchErrorMessage', { + defaultMessage: 'Cannot generate screenshots because the browser did not launch.', + }); + + /** + * For this error message we expect that users will use the diagnostics + * functionality in reporting to debug further. + */ + public override get message() { + return this.details; + } +} + +export class BrowserUnexpectedlyClosedError extends ReportingError { + code = 'browser_unexpectedly_closed_error'; +} + +export class BrowserScreenshotError extends ReportingError { + code = 'browser_screenshot_error'; +} + +export class KibanaShuttingDownError extends ReportingError { + code = 'kibana_shutting_down_error'; +} diff --git a/x-pack/plugins/reporting/common/errors/map_to_reporting_error.test.ts b/x-pack/plugins/reporting/common/errors/map_to_reporting_error.test.ts new file mode 100644 index 000000000000..5f2082d41377 --- /dev/null +++ b/x-pack/plugins/reporting/common/errors/map_to_reporting_error.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mapToReportingError } from './map_to_reporting_error'; +import { errors } from '../../../screenshotting/common'; +import { + UnknownError, + BrowserCouldNotLaunchError, + BrowserUnexpectedlyClosedError, + BrowserScreenshotError, +} from '.'; + +describe('mapToReportingError', () => { + test('Non-Error values', () => { + [null, undefined, '', 0, false, true, () => {}, {}, []].forEach((v) => { + expect(mapToReportingError(v)).toBeInstanceOf(UnknownError); + }); + }); + + test('Screenshotting error', () => { + expect(mapToReportingError(new errors.BrowserClosedUnexpectedly())).toBeInstanceOf( + BrowserUnexpectedlyClosedError + ); + expect(mapToReportingError(new errors.FailedToCaptureScreenshot())).toBeInstanceOf( + BrowserScreenshotError + ); + expect(mapToReportingError(new errors.FailedToSpawnBrowserError())).toBeInstanceOf( + BrowserCouldNotLaunchError + ); + }); +}); diff --git a/x-pack/plugins/reporting/common/errors/map_to_reporting_error.ts b/x-pack/plugins/reporting/common/errors/map_to_reporting_error.ts new file mode 100644 index 000000000000..ff24e684901f --- /dev/null +++ b/x-pack/plugins/reporting/common/errors/map_to_reporting_error.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 { errors } from '../../../screenshotting/common'; +import { + UnknownError, + ReportingError, + BrowserCouldNotLaunchError, + BrowserUnexpectedlyClosedError, + BrowserScreenshotError, +} from '.'; + +export function mapToReportingError(error: unknown): ReportingError { + if (error instanceof ReportingError) { + return error; + } + switch (true) { + case error instanceof errors.BrowserClosedUnexpectedly: + return new BrowserUnexpectedlyClosedError((error as Error).message); + case error instanceof errors.FailedToCaptureScreenshot: + return new BrowserScreenshotError((error as Error).message); + case error instanceof errors.FailedToSpawnBrowserError: + return new BrowserCouldNotLaunchError(); + } + return new UnknownError(); +} diff --git a/x-pack/plugins/reporting/public/notifier/general_error.tsx b/x-pack/plugins/reporting/public/notifier/general_error.tsx index 66fff4d00cee..4a66be603300 100644 --- a/x-pack/plugins/reporting/public/notifier/general_error.tsx +++ b/x-pack/plugins/reporting/public/notifier/general_error.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment } from 'react'; +import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { ThemeServiceStart, ToastInput } from 'src/core/public'; @@ -17,7 +17,7 @@ export const getGeneralErrorToast = ( theme: ThemeServiceStart ): ToastInput => ({ text: toMountPoint( - + <> {err.toString()} @@ -28,7 +28,7 @@ export const getGeneralErrorToast = ( id="xpack.reporting.publicNotifier.error.tryRefresh" defaultMessage="Try refreshing the page." /> - , + , { theme$: theme.theme$ } ), iconType: undefined, diff --git a/x-pack/plugins/reporting/public/notifier/job_failure.tsx b/x-pack/plugins/reporting/public/notifier/job_failure.tsx index 0f858333dae1..fff496a90cf6 100644 --- a/x-pack/plugins/reporting/public/notifier/job_failure.tsx +++ b/x-pack/plugins/reporting/public/notifier/job_failure.tsx @@ -8,7 +8,7 @@ import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { Fragment } from 'react'; +import React from 'react'; import { ThemeServiceStart, ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import type { JobSummary, ManagementLinkFn } from '../../common/types'; @@ -29,7 +29,7 @@ export const getFailureToast = ( { theme$: theme.theme$ } ), text: toMountPoint( - + <>

    -
    , + , { theme$: theme.theme$ } ), iconType: undefined, diff --git a/x-pack/plugins/reporting/public/notifier/job_success.tsx b/x-pack/plugins/reporting/public/notifier/job_success.tsx index 76170a8a6c2c..b5ff57b1c21c 100644 --- a/x-pack/plugins/reporting/public/notifier/job_success.tsx +++ b/x-pack/plugins/reporting/public/notifier/job_success.tsx @@ -6,7 +6,7 @@ */ import { FormattedMessage } from '@kbn/i18n-react'; -import React, { Fragment } from 'react'; +import React from 'react'; import { ThemeServiceStart, ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../common/types'; @@ -29,12 +29,12 @@ export const getSuccessToast = ( ), color: 'success', text: toMountPoint( - + <>

    -
    , + , { theme$: theme.theme$ } ), 'data-test-subj': 'completeReportSuccess', diff --git a/x-pack/plugins/reporting/public/notifier/job_warning.tsx b/x-pack/plugins/reporting/public/notifier/job_warning.tsx index 2ac10216f3f1..463bbd964273 100644 --- a/x-pack/plugins/reporting/public/notifier/job_warning.tsx +++ b/x-pack/plugins/reporting/public/notifier/job_warning.tsx @@ -6,7 +6,7 @@ */ import { FormattedMessage } from '@kbn/i18n-react'; -import React, { Fragment } from 'react'; +import React from 'react'; import { ThemeServiceStart, ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../common/types'; @@ -28,18 +28,12 @@ export const getWarningToast = ( { theme$: theme.theme$ } ), text: toMountPoint( - -

    - -

    + <>

    -
    , + , { theme$: theme.theme$ } ), 'data-test-subj': 'completeReportWarning', diff --git a/x-pack/plugins/reporting/public/notifier/job_warning_formulas.tsx b/x-pack/plugins/reporting/public/notifier/job_warning_formulas.tsx index ae1d61b7bd4b..8fcf0144006d 100644 --- a/x-pack/plugins/reporting/public/notifier/job_warning_formulas.tsx +++ b/x-pack/plugins/reporting/public/notifier/job_warning_formulas.tsx @@ -6,7 +6,7 @@ */ import { FormattedMessage } from '@kbn/i18n-react'; -import React, { Fragment } from 'react'; +import React from 'react'; import { ThemeServiceStart, ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../common/types'; @@ -28,7 +28,7 @@ export const getWarningFormulasToast = ( { theme$: theme.theme$ } ), text: toMountPoint( - + <>

    -
    , + , { theme$: theme.theme$ } ), 'data-test-subj': 'completeReportCsvFormulasWarning', diff --git a/x-pack/plugins/reporting/public/notifier/job_warning_max_size.tsx b/x-pack/plugins/reporting/public/notifier/job_warning_max_size.tsx index 6e3a68a9beee..ccc133249505 100644 --- a/x-pack/plugins/reporting/public/notifier/job_warning_max_size.tsx +++ b/x-pack/plugins/reporting/public/notifier/job_warning_max_size.tsx @@ -6,7 +6,7 @@ */ import { FormattedMessage } from '@kbn/i18n-react'; -import React, { Fragment } from 'react'; +import React from 'react'; import { ThemeServiceStart, ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../common/types'; @@ -28,7 +28,7 @@ export const getWarningMaxSizeToast = ( { theme$: theme.theme$ } ), text: toMountPoint( - + <>

    -
    , + , { theme$: theme.theme$ } ), 'data-test-subj': 'completeReportMaxSizeWarning', diff --git a/x-pack/plugins/reporting/public/notifier/report_link.tsx b/x-pack/plugins/reporting/public/notifier/report_link.tsx index 9c3518163e5f..d8f00777b04b 100644 --- a/x-pack/plugins/reporting/public/notifier/report_link.tsx +++ b/x-pack/plugins/reporting/public/notifier/report_link.tsx @@ -21,7 +21,7 @@ export const ReportLink = ({ getUrl }: Props) => (
    ), diff --git a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx index 1925bf377a37..d5f5a4483715 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx @@ -287,13 +287,13 @@ class ReportingPanelContentUi extends Component { text: toMountPoint( ), diff --git a/x-pack/plugins/reporting/server/config/config.ts b/x-pack/plugins/reporting/server/config/config.ts index 00c57053653f..269a66503a74 100644 --- a/x-pack/plugins/reporting/server/config/config.ts +++ b/x-pack/plugins/reporting/server/config/config.ts @@ -7,8 +7,7 @@ import { get } from 'lodash'; import { first } from 'rxjs/operators'; -import { CoreSetup, PluginInitializerContext } from 'src/core/server'; -import { LevelLogger } from '../lib'; +import type { CoreSetup, Logger, PluginInitializerContext } from 'kibana/server'; import { createConfig$ } from './create_config'; import { ReportingConfigType } from './schema'; @@ -63,13 +62,13 @@ export interface ReportingConfig extends Config { * @internal * @param {PluginInitializerContext} initContext * @param {CoreSetup} core - * @param {LevelLogger} logger + * @param {Logger} logger * @returns {Promise} */ export const buildConfig = async ( initContext: PluginInitializerContext, core: CoreSetup, - logger: LevelLogger + logger: Logger ): Promise => { const config$ = initContext.config.create(); const { http } = core; diff --git a/x-pack/plugins/reporting/server/config/create_config.test.ts b/x-pack/plugins/reporting/server/config/create_config.test.ts index fd8180bd46a0..498281c56424 100644 --- a/x-pack/plugins/reporting/server/config/create_config.test.ts +++ b/x-pack/plugins/reporting/server/config/create_config.test.ts @@ -6,11 +6,10 @@ */ import * as Rx from 'rxjs'; -import { CoreSetup, HttpServerInfo, PluginInitializerContext } from 'src/core/server'; -import { coreMock } from 'src/core/server/mocks'; -import { LevelLogger } from '../lib/level_logger'; -import { createMockConfigSchema, createMockLevelLogger } from '../test_helpers'; -import { ReportingConfigType } from './'; +import type { CoreSetup, HttpServerInfo, Logger, PluginInitializerContext } from 'kibana/server'; +import { coreMock, loggingSystemMock } from 'src/core/server/mocks'; +import { createMockConfigSchema } from '../test_helpers'; +import type { ReportingConfigType } from './'; import { createConfig$ } from './create_config'; const createMockConfig = ( @@ -20,14 +19,14 @@ const createMockConfig = ( describe('Reporting server createConfig$', () => { let mockCoreSetup: CoreSetup; let mockInitContext: PluginInitializerContext; - let mockLogger: jest.Mocked; + let mockLogger: jest.Mocked; beforeEach(() => { mockCoreSetup = coreMock.createSetup(); mockInitContext = coreMock.createPluginInitializerContext( createMockConfigSchema({ kibanaServer: {} }) ); - mockLogger = createMockLevelLogger(); + mockLogger = loggingSystemMock.createLogger(); }); afterEach(() => { @@ -77,6 +76,16 @@ describe('Reporting server createConfig$', () => { expect(result).toMatchInlineSnapshot(` Object { + "capture": Object { + "loadDelay": 1, + "maxAttempts": 1, + "timeouts": Object { + "openUrl": 100, + "renderComplete": 100, + "waitForElements": 100, + }, + "zoom": 1, + }, "csv": Object {}, "encryptionKey": "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii", "index": ".reporting", diff --git a/x-pack/plugins/reporting/server/config/create_config.ts b/x-pack/plugins/reporting/server/config/create_config.ts index 2ac225ec4576..ff8d00c30d4f 100644 --- a/x-pack/plugins/reporting/server/config/create_config.ts +++ b/x-pack/plugins/reporting/server/config/create_config.ts @@ -7,11 +7,10 @@ import crypto from 'crypto'; import ipaddr from 'ipaddr.js'; +import type { CoreSetup, Logger } from 'kibana/server'; import { sum } from 'lodash'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { CoreSetup } from 'src/core/server'; -import { LevelLogger } from '../lib'; import { ReportingConfigType } from './schema'; /* @@ -22,9 +21,9 @@ import { ReportingConfigType } from './schema'; export function createConfig$( core: CoreSetup, config$: Observable, - parentLogger: LevelLogger + parentLogger: Logger ) { - const logger = parentLogger.clone(['config']); + const logger = parentLogger.get('config'); return config$.pipe( map((config) => { // encryption key diff --git a/x-pack/plugins/reporting/server/core.ts b/x-pack/plugins/reporting/server/core.ts index 745542c358a6..ee50b99e5b3b 100644 --- a/x-pack/plugins/reporting/server/core.ts +++ b/x-pack/plugins/reporting/server/core.ts @@ -11,6 +11,7 @@ import { filter, first, map, switchMap, take } from 'rxjs/operators'; import type { BasePath, IClusterClient, + Logger, PackageInfo, PluginInitializerContext, SavedObjectsClientContract, @@ -32,7 +33,7 @@ import { REPORTING_REDIRECT_LOCATOR_STORE_KEY } from '../common/constants'; import { durationToNumber } from '../common/schema_utils'; import type { ReportingConfig, ReportingSetup } from './'; import { ReportingConfigType } from './config'; -import { checkLicense, getExportTypesRegistry, LevelLogger } from './lib'; +import { checkLicense, getExportTypesRegistry } from './lib'; import { reportingEventLoggerFactory } from './lib/event_logger/logger'; import type { IReport, ReportingStore } from './lib/store'; import { ExecuteReportTask, MonitorReportsTask, ReportTaskParams } from './lib/tasks'; @@ -45,7 +46,7 @@ export interface ReportingInternalSetup { security?: SecurityPluginSetup; spaces?: SpacesPluginSetup; taskManager: TaskManagerSetupContract; - logger: LevelLogger; + logger: Logger; status: StatusServiceSetup; } @@ -57,7 +58,7 @@ export interface ReportingInternalStart { data: DataPluginStart; fieldFormats: FieldFormatsStart; licensing: LicensingPluginStart; - logger: LevelLogger; + logger: Logger; screenshotting: ScreenshottingStart; security?: SecurityPluginStart; taskManager: TaskManagerStartContract; @@ -81,7 +82,9 @@ export class ReportingCore { public getContract: () => ReportingSetup; - constructor(private logger: LevelLogger, context: PluginInitializerContext) { + private kibanaShuttingDown$ = new Rx.ReplaySubject(1); + + constructor(private logger: Logger, context: PluginInitializerContext) { this.packageInfo = context.env.packageInfo; const syncConfig = context.config.get(); this.deprecatedAllowedRoles = syncConfig.roles.enabled ? syncConfig.roles.allow : false; @@ -128,6 +131,14 @@ export class ReportingCore { await Promise.all([executeTask.init(taskManager), monitorTask.init(taskManager)]); } + public pluginStop() { + this.kibanaShuttingDown$.next(); + } + + public getKibanaShutdown$(): Rx.Observable { + return this.kibanaShuttingDown$.pipe(take(1)); + } + private async assertKibanaIsAvailable(): Promise { const { status } = this.getPluginSetupDeps(); diff --git a/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.test.ts b/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.test.ts index b5258d91485f..56a1c39e75aa 100644 --- a/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.test.ts @@ -5,11 +5,11 @@ * 2.0. */ +import { loggingSystemMock } from 'src/core/server/mocks'; import { cryptoFactory } from '../../lib'; -import { createMockLevelLogger } from '../../test_helpers'; import { decryptJobHeaders } from './'; -const logger = createMockLevelLogger(); +const logger = loggingSystemMock.createLogger(); const encryptHeaders = async (encryptionKey: string, headers: Record) => { const crypto = cryptoFactory(encryptionKey); diff --git a/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.ts b/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.ts index f126d1edbfce..3dfcfe362abd 100644 --- a/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.ts +++ b/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.ts @@ -6,12 +6,13 @@ */ import { i18n } from '@kbn/i18n'; -import { cryptoFactory, LevelLogger } from '../../lib'; +import type { Logger } from 'kibana/server'; +import { cryptoFactory } from '../../lib'; export const decryptJobHeaders = async ( encryptionKey: string | undefined, headers: string, - logger: LevelLogger + logger: Logger ): Promise> => { try { if (typeof headers !== 'string') { diff --git a/x-pack/plugins/reporting/server/export_types/common/generate_png.ts b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts index caa0b7fb91b3..272d1c287178 100644 --- a/x-pack/plugins/reporting/server/export_types/common/generate_png.ts +++ b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts @@ -6,14 +6,14 @@ */ import apm from 'elastic-apm-node'; +import type { Logger } from 'kibana/server'; import * as Rx from 'rxjs'; import { finalize, map, tap } from 'rxjs/operators'; +import type { ReportingCore } from '../../'; import { LayoutTypes } from '../../../../screenshotting/common'; import { REPORTING_TRANSACTION_TYPE } from '../../../common/constants'; import type { PngMetrics } from '../../../common/types'; -import { ReportingCore } from '../../'; -import { ScreenshotOptions } from '../../types'; -import { LevelLogger } from '../../lib'; +import type { ScreenshotOptions } from '../../types'; interface PngResult { buffer: Buffer; @@ -23,7 +23,7 @@ interface PngResult { export function generatePngObservable( reporting: ReportingCore, - logger: LevelLogger, + logger: Logger, options: ScreenshotOptions ): Rx.Observable { const apmTrans = apm.startTransaction('generate-png', REPORTING_TRANSACTION_TYPE); diff --git a/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts b/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts index f5675b50cfdd..850d0ae507e1 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts @@ -5,17 +5,14 @@ * 2.0. */ -import { ReportingCore } from '../..'; -import { - createMockConfigSchema, - createMockLevelLogger, - createMockReportingCore, -} from '../../test_helpers'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { ReportingCore } from '../../'; +import { createMockConfigSchema, createMockReportingCore } from '../../test_helpers'; import { getCustomLogo } from './get_custom_logo'; let mockReportingPlugin: ReportingCore; -const logger = createMockLevelLogger(); +const logger = loggingSystemMock.createLogger(); beforeEach(async () => { mockReportingPlugin = await createMockReportingCore(createMockConfigSchema()); diff --git a/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.ts b/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.ts index fcabd34a642c..108731550398 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.ts @@ -5,16 +5,15 @@ * 2.0. */ -import type { Headers } from 'src/core/server'; +import type { Headers, Logger } from 'kibana/server'; import { ReportingCore } from '../../'; import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../common/constants'; -import { LevelLogger } from '../../lib'; export const getCustomLogo = async ( reporting: ReportingCore, headers: Headers, spaceId: string | undefined, - logger: LevelLogger + logger: Logger ) => { const fakeRequest = reporting.getFakeRequest({ headers }, spaceId, logger); const uiSettingsClient = await reporting.getUiSettingsClient(fakeRequest, logger); diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.test.ts index ee6d6daab88e..5a8c4f1fd760 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.test.ts @@ -16,18 +16,15 @@ jest.mock('./generate_csv/generate_csv', () => ({ }, })); -import { Writable } from 'stream'; import nodeCrypto from '@elastic/node-crypto'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { Writable } from 'stream'; import { ReportingCore } from '../../'; import { CancellationToken } from '../../../common/cancellation_token'; -import { - createMockConfigSchema, - createMockLevelLogger, - createMockReportingCore, -} from '../../test_helpers'; +import { createMockConfigSchema, createMockReportingCore } from '../../test_helpers'; import { runTaskFnFactory } from './execute_job'; -const logger = createMockLevelLogger(); +const logger = loggingSystemMock.createLogger(); const encryptionKey = 'tetkey'; const headers = { sid: 'cooltestheaders' }; let encryptedHeaders: string; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.ts index 97f0aa65e3d6..8b5f0e539582 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { CSV_JOB_TYPE } from '../../../common/constants'; import { getFieldFormats } from '../../services'; import { RunTaskFn, RunTaskFnFactory } from '../../types'; import { decryptJobHeaders } from '../common'; @@ -19,7 +18,7 @@ export const runTaskFnFactory: RunTaskFnFactory> = ( const config = reporting.getConfig(); return async function runTask(jobId, job, cancellationToken, stream) { - const logger = parentLogger.clone([CSV_JOB_TYPE, 'execute-job', jobId]); + const logger = parentLogger.get(`execute-job:${jobId}`); const encryptionKey = config.get('encryptionKey'); const headers = await decryptJobHeaders(encryptionKey, job.headers, logger); diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts index c525cb7c0def..37ec0d400f47 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts @@ -5,21 +5,22 @@ * 2.0. */ -import { Writable } from 'stream'; -import * as Rx from 'rxjs'; import { errors as esErrors } from '@elastic/elasticsearch'; +import type { IScopedClusterClient, IUiSettingsClient, SearchResponse } from 'kibana/server'; import { identity, range } from 'lodash'; -import { IScopedClusterClient, IUiSettingsClient, SearchResponse } from 'src/core/server'; +import * as Rx from 'rxjs'; import { elasticsearchServiceMock, + loggingSystemMock, savedObjectsClientMock, uiSettingsServiceMock, } from 'src/core/server/mocks'; import { ISearchStartSearchSource } from 'src/plugins/data/common'; -import { FieldFormatsRegistry } from 'src/plugins/field_formats/common'; import { searchSourceInstanceMock } from 'src/plugins/data/common/search/search_source/mocks'; import { IScopedSearchClient } from 'src/plugins/data/server'; import { dataPluginMock } from 'src/plugins/data/server/mocks'; +import { FieldFormatsRegistry } from 'src/plugins/field_formats/common'; +import { Writable } from 'stream'; import { ReportingConfig } from '../../../'; import { CancellationToken } from '../../../../common/cancellation_token'; import { @@ -28,11 +29,7 @@ import { UI_SETTINGS_DATEFORMAT_TZ, } from '../../../../common/constants'; import { UnknownError } from '../../../../common/errors'; -import { - createMockConfig, - createMockConfigSchema, - createMockLevelLogger, -} from '../../../test_helpers'; +import { createMockConfig, createMockConfigSchema } from '../../../test_helpers'; import { JobParamsCSV } from '../types'; import { CsvGenerator } from './generate_csv'; @@ -125,7 +122,7 @@ beforeEach(async () => { }); }); -const logger = createMockLevelLogger(); +const logger = loggingSystemMock.createLogger(); it('formats an empty search result to CSV content', async () => { const generateCsv = new CsvGenerator( @@ -850,10 +847,10 @@ describe('error codes', () => { ); const { error_code: errorCode, warnings } = await generateCsv.generateData(); - expect(errorCode).toBe('authentication_expired'); + expect(errorCode).toBe('authentication_expired_error'); expect(warnings).toMatchInlineSnapshot(` Array [ - "This report contains partial CSV results because authentication expired before it could finish. Try exporting a smaller amount of data or increase your authentication timeout.", + "This report contains partial CSV results because the authentication token expired. Export a smaller amount of data or increase the timeout of the authentication token.", ] `); }); diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts index 201484af9d7d..c913706f5856 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -5,9 +5,9 @@ * 2.0. */ -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { errors as esErrors } from '@elastic/elasticsearch'; -import type { IScopedClusterClient, IUiSettingsClient } from 'src/core/server'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { IScopedClusterClient, IUiSettingsClient, Logger } from 'kibana/server'; import type { IScopedSearchClient } from 'src/plugins/data/server'; import type { Datatable } from 'src/plugins/expressions/server'; import type { Writable } from 'stream'; @@ -32,16 +32,15 @@ import type { CancellationToken } from '../../../../common/cancellation_token'; import { CONTENT_TYPE_CSV } from '../../../../common/constants'; import { AuthenticationExpiredError, - UnknownError, ReportingError, + UnknownError, } from '../../../../common/errors'; import { byteSizeValueToNumber } from '../../../../common/schema_utils'; -import type { LevelLogger } from '../../../lib'; import type { TaskRunResult } from '../../../lib/tasks'; import type { JobParamsCSV } from '../types'; import { CsvExportSettings, getExportSettings } from './get_export_settings'; -import { MaxSizeStringBuilder } from './max_size_string_builder'; import { i18nTexts } from './i18n_texts'; +import { MaxSizeStringBuilder } from './max_size_string_builder'; interface Clients { es: IScopedClusterClient; @@ -65,7 +64,7 @@ export class CsvGenerator { private clients: Clients, private dependencies: Dependencies, private cancellationToken: CancellationToken, - private logger: LevelLogger, + private logger: Logger, private stream: Writable ) {} @@ -316,7 +315,7 @@ export class CsvGenerator { } if (!results) { - this.logger.warning(`Search results are undefined!`); + this.logger.warn(`Search results are undefined!`); break; } @@ -396,7 +395,7 @@ export class CsvGenerator { this.logger.debug(`Finished generating. Row count: ${this.csvRowCount}.`); if (!this.maxSizeReached && this.csvRowCount !== totalRecords) { - this.logger.warning( + this.logger.warn( `ES scroll returned fewer total hits than expected! ` + `Search result total hits: ${totalRecords}. Row count: ${this.csvRowCount}.` ); diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.test.ts index 2ae3e5e712d3..ef0f0062bf19 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.test.ts @@ -12,18 +12,18 @@ import { UI_SETTINGS_SEARCH_INCLUDE_FROZEN, } from '../../../../common/constants'; import { IUiSettingsClient } from 'kibana/server'; -import { savedObjectsClientMock, uiSettingsServiceMock } from 'src/core/server/mocks'; import { - createMockConfig, - createMockConfigSchema, - createMockLevelLogger, -} from '../../../test_helpers'; + loggingSystemMock, + savedObjectsClientMock, + uiSettingsServiceMock, +} from 'src/core/server/mocks'; +import { createMockConfig, createMockConfigSchema } from '../../../test_helpers'; import { getExportSettings } from './get_export_settings'; describe('getExportSettings', () => { let uiSettingsClient: IUiSettingsClient; const config = createMockConfig(createMockConfigSchema({})); - const logger = createMockLevelLogger(); + const logger = loggingSystemMock.createLogger(); beforeEach(() => { uiSettingsClient = uiSettingsServiceMock diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.ts index 5b69e33624c5..6a07e3184eb4 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.ts @@ -6,7 +6,7 @@ */ import { ByteSizeValue } from '@kbn/config-schema'; -import { IUiSettingsClient } from 'kibana/server'; +import type { IUiSettingsClient, Logger } from 'kibana/server'; import { createEscapeValue } from '../../../../../../../src/plugins/data/common'; import { ReportingConfig } from '../../../'; import { @@ -16,7 +16,6 @@ import { UI_SETTINGS_DATEFORMAT_TZ, UI_SETTINGS_SEARCH_INCLUDE_FROZEN, } from '../../../../common/constants'; -import { LevelLogger } from '../../../lib'; export interface CsvExportSettings { timezone: string; @@ -37,7 +36,7 @@ export const getExportSettings = async ( client: IUiSettingsClient, config: ReportingConfig, timezone: string | undefined, - logger: LevelLogger + logger: Logger ): Promise => { let setTimezone: string; if (timezone) { diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/i18n_texts.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/i18n_texts.ts index c994226c6a05..72f6d96092e2 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/i18n_texts.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/i18n_texts.ts @@ -19,7 +19,7 @@ export const i18nTexts = { 'xpack.reporting.exportTypes.csv.generateCsv.authenticationExpired.partialResultsMessage', { defaultMessage: - 'This report contains partial CSV results because authentication expired before it could finish. Try exporting a smaller amount of data or increase your authentication timeout.', + 'This report contains partial CSV results because the authentication token expired. Export a smaller amount of data or increase the timeout of the authentication token.', } ), }, diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/execute_job.ts index 53e1f6ba3c95..50ae2ab10f6e 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/execute_job.ts @@ -8,7 +8,6 @@ import { KibanaRequest } from 'src/core/server'; import { Writable } from 'stream'; import { CancellationToken } from '../../../common/cancellation_token'; -import { CSV_SEARCHSOURCE_IMMEDIATE_TYPE } from '../../../common/constants'; import { TaskRunResult } from '../../lib/tasks'; import { getFieldFormats } from '../../services'; import { ReportingRequestHandlerContext, RunTaskFnFactory } from '../../types'; @@ -32,7 +31,7 @@ export const runTaskFnFactory: RunTaskFnFactory = function e parentLogger ) { const config = reporting.getConfig(); - const logger = parentLogger.clone([CSV_SEARCHSOURCE_IMMEDIATE_TYPE, 'execute-job']); + const logger = parentLogger.get('execute-job'); return async function runTask(_jobId, immediateJobParams, context, stream, req) { const job = { @@ -82,7 +81,7 @@ export const runTaskFnFactory: RunTaskFnFactory = function e const { warnings } = result; if (warnings) { warnings.forEach((warning) => { - logger.warning(warning); + logger.warn(warning); }); } diff --git a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts index 9069ec63a882..bc37978372ba 100644 --- a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts @@ -5,11 +5,12 @@ * 2.0. */ -import { Writable } from 'stream'; import * as Rx from 'rxjs'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { Writable } from 'stream'; import { ReportingCore } from '../../../'; import { CancellationToken } from '../../../../common/cancellation_token'; -import { cryptoFactory, LevelLogger } from '../../../lib'; +import { cryptoFactory } from '../../../lib'; import { createMockConfig, createMockConfigSchema, @@ -29,14 +30,7 @@ const cancellationToken = { on: jest.fn(), } as unknown as CancellationToken; -const mockLoggerFactory = { - get: jest.fn().mockImplementation(() => ({ - error: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - })), -}; -const getMockLogger = () => new LevelLogger(mockLoggerFactory); +const getMockLogger = () => loggingSystemMock.createLogger(); const mockEncryptionKey = 'abcabcsecuresecret'; const encryptHeaders = async (headers: Record) => { diff --git a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts index 67d013740bed..52023e53b80b 100644 --- a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts @@ -8,7 +8,7 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { finalize, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; -import { PNG_JOB_TYPE, REPORTING_TRANSACTION_TYPE } from '../../../../common/constants'; +import { REPORTING_TRANSACTION_TYPE } from '../../../../common/constants'; import { TaskRunResult } from '../../../lib/tasks'; import { RunTaskFn, RunTaskFnFactory } from '../../../types'; import { decryptJobHeaders, getFullUrls, generatePngObservable } from '../../common'; @@ -24,7 +24,7 @@ export const runTaskFnFactory: RunTaskFnFactory> = const apmGetAssets = apmTrans?.startSpan('get-assets', 'setup'); let apmGeneratePng: { end: () => void } | null | undefined; - const jobLogger = parentLogger.clone([PNG_JOB_TYPE, 'execute', jobId]); + const jobLogger = parentLogger.get(`execute:${jobId}`); const process$: Rx.Observable = Rx.of(1).pipe( mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)), mergeMap((headers) => { diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts index 1b1ad6878d78..1403873e8da4 100644 --- a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts @@ -6,11 +6,12 @@ */ import * as Rx from 'rxjs'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { Writable } from 'stream'; import { ReportingCore } from '../../'; import { CancellationToken } from '../../../common/cancellation_token'; import { LocatorParams } from '../../../common/types'; -import { cryptoFactory, LevelLogger } from '../../lib'; +import { cryptoFactory } from '../../lib'; import { createMockConfig, createMockConfigSchema, @@ -30,14 +31,7 @@ const cancellationToken = { on: jest.fn(), } as unknown as CancellationToken; -const mockLoggerFactory = { - get: jest.fn().mockImplementation(() => ({ - error: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - })), -}; -const getMockLogger = () => new LevelLogger(mockLoggerFactory); +const getMockLogger = () => loggingSystemMock.createLogger(); const mockEncryptionKey = 'abcabcsecuresecret'; const encryptHeaders = async (headers: Record) => { diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts index 51044aa324a1..5df7a497adf6 100644 --- a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts @@ -8,7 +8,7 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { finalize, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; -import { PNG_JOB_TYPE_V2, REPORTING_TRANSACTION_TYPE } from '../../../common/constants'; +import { REPORTING_TRANSACTION_TYPE } from '../../../common/constants'; import { TaskRunResult } from '../../lib/tasks'; import { RunTaskFn, RunTaskFnFactory } from '../../types'; import { decryptJobHeaders, generatePngObservable } from '../common'; @@ -25,7 +25,7 @@ export const runTaskFnFactory: RunTaskFnFactory> = const apmGetAssets = apmTrans?.startSpan('get-assets', 'setup'); let apmGeneratePng: { end: () => void } | null | undefined; - const jobLogger = parentLogger.clone([PNG_JOB_TYPE_V2, 'execute', jobId]); + const jobLogger = parentLogger.get(`execute:${jobId}`); const process$: Rx.Observable = Rx.of(1).pipe( mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)), mergeMap((headers) => { diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts index a8d2027f2ba1..7faa13486b5a 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts @@ -6,10 +6,11 @@ */ import * as Rx from 'rxjs'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { Writable } from 'stream'; import { ReportingCore } from '../../../'; import { CancellationToken } from '../../../../common/cancellation_token'; -import { cryptoFactory, LevelLogger } from '../../../lib'; +import { cryptoFactory } from '../../../lib'; import { createMockConfigSchema, createMockReportingCore } from '../../../test_helpers'; import { generatePdfObservable } from '../lib/generate_pdf'; import { TaskPayloadPDF } from '../types'; @@ -25,14 +26,7 @@ const cancellationToken = { on: jest.fn(), } as unknown as CancellationToken; -const mockLoggerFactory = { - get: jest.fn().mockImplementation(() => ({ - error: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - })), -}; -const getMockLogger = () => new LevelLogger(mockLoggerFactory); +const getMockLogger = () => loggingSystemMock.createLogger(); const mockEncryptionKey = 'testencryptionkey'; const encryptHeaders = async (headers: Record) => { diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts index ab3793935e1d..9b4db48ed669 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts @@ -8,7 +8,7 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; -import { PDF_JOB_TYPE, REPORTING_TRANSACTION_TYPE } from '../../../../common/constants'; +import { REPORTING_TRANSACTION_TYPE } from '../../../../common/constants'; import { TaskRunResult } from '../../../lib/tasks'; import { RunTaskFn, RunTaskFnFactory } from '../../../types'; import { decryptJobHeaders, getFullUrls, getCustomLogo } from '../../common'; @@ -21,7 +21,7 @@ export const runTaskFnFactory: RunTaskFnFactory> = const encryptionKey = config.get('encryptionKey'); return async function runTask(jobId, job, cancellationToken, stream) { - const jobLogger = parentLogger.clone([PDF_JOB_TYPE, 'execute-job', jobId]); + const jobLogger = parentLogger.get(`execute-job:${jobId}`); const apmTrans = apm.startTransaction('execute-job-pdf', REPORTING_TRANSACTION_TYPE); const apmGetAssets = apmTrans?.startSpan('get-assets', 'setup'); let apmGeneratePdf: { end: () => void } | null | undefined; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts index a401f59b8f4b..ff0ef2cf39af 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts @@ -5,13 +5,13 @@ * 2.0. */ +import type { Logger } from 'kibana/server'; import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; import { mergeMap, tap } from 'rxjs/operators'; +import { ReportingCore } from '../../../'; import { ScreenshotResult } from '../../../../../screenshotting/server'; import type { PdfMetrics } from '../../../../common/types'; -import { ReportingCore } from '../../../'; -import { LevelLogger } from '../../../lib'; import { ScreenshotOptions } from '../../../types'; import { PdfMaker } from '../../common/pdf'; import { getTracker } from './tracker'; @@ -34,7 +34,7 @@ interface PdfResult { export function generatePdfObservable( reporting: ReportingCore, - logger: LevelLogger, + logger: Logger, title: string, options: ScreenshotOptions, logo?: string diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts index 3cf7f8205856..efad71a64a81 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts @@ -8,11 +8,12 @@ jest.mock('./lib/generate_pdf'); import * as Rx from 'rxjs'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { Writable } from 'stream'; import { ReportingCore } from '../../'; import { CancellationToken } from '../../../common/cancellation_token'; import { LocatorParams } from '../../../common/types'; -import { cryptoFactory, LevelLogger } from '../../lib'; +import { cryptoFactory } from '../../lib'; import { createMockConfigSchema, createMockReportingCore } from '../../test_helpers'; import { runTaskFnFactory } from './execute_job'; import { generatePdfObservable } from './lib/generate_pdf'; @@ -26,14 +27,7 @@ const cancellationToken = { on: jest.fn(), } as unknown as CancellationToken; -const mockLoggerFactory = { - get: jest.fn().mockImplementation(() => ({ - error: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - })), -}; -const getMockLogger = () => new LevelLogger(mockLoggerFactory); +const getMockLogger = () => loggingSystemMock.createLogger(); const mockEncryptionKey = 'testencryptionkey'; const encryptHeaders = async (headers: Record) => { diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts index 85684bca66b8..7f887707829c 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts @@ -8,7 +8,7 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; -import { PDF_JOB_TYPE_V2, REPORTING_TRANSACTION_TYPE } from '../../../common/constants'; +import { REPORTING_TRANSACTION_TYPE } from '../../../common/constants'; import { TaskRunResult } from '../../lib/tasks'; import { RunTaskFn, RunTaskFnFactory } from '../../types'; import { decryptJobHeaders, getCustomLogo } from '../common'; @@ -21,7 +21,7 @@ export const runTaskFnFactory: RunTaskFnFactory> = const encryptionKey = config.get('encryptionKey'); return async function runTask(jobId, job, cancellationToken, stream) { - const jobLogger = parentLogger.clone([PDF_JOB_TYPE_V2, 'execute-job', jobId]); + const jobLogger = parentLogger.get(`execute-job:${jobId}`); const apmTrans = apm.startTransaction('execute-job-pdf-v2', REPORTING_TRANSACTION_TYPE); const apmGetAssets = apmTrans?.startSpan('get-assets', 'setup'); let apmGeneratePdf: { end: () => void } | null | undefined; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts index ac922c07574b..8bec3cac28f4 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts @@ -5,14 +5,14 @@ * 2.0. */ +import type { Logger } from 'kibana/server'; import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; import { mergeMap, tap } from 'rxjs/operators'; -import { ReportingCore } from '../../../'; -import { ScreenshotResult } from '../../../../../screenshotting/server'; -import { LocatorParams, PdfMetrics, UrlOrUrlLocatorTuple } from '../../../../common/types'; -import { LevelLogger } from '../../../lib'; -import { ScreenshotOptions } from '../../../types'; +import type { ReportingCore } from '../../../'; +import type { ScreenshotResult } from '../../../../../screenshotting/server'; +import type { LocatorParams, PdfMetrics, UrlOrUrlLocatorTuple } from '../../../../common/types'; +import type { ScreenshotOptions } from '../../../types'; import { PdfMaker } from '../../common/pdf'; import { getFullRedirectAppUrl } from '../../common/v2/get_full_redirect_app_url'; import type { TaskPayloadPDFV2 } from '../types'; @@ -36,7 +36,7 @@ interface PdfResult { export function generatePdfObservable( reporting: ReportingCore, - logger: LevelLogger, + logger: Logger, job: TaskPayloadPDFV2, title: string, locatorParams: LocatorParams[], diff --git a/x-pack/plugins/reporting/server/lib/check_params_version.ts b/x-pack/plugins/reporting/server/lib/check_params_version.ts index 7298384b8757..79237ba56677 100644 --- a/x-pack/plugins/reporting/server/lib/check_params_version.ts +++ b/x-pack/plugins/reporting/server/lib/check_params_version.ts @@ -5,16 +5,16 @@ * 2.0. */ +import type { Logger } from 'kibana/server'; import { UNVERSIONED_VERSION } from '../../common/constants'; import type { BaseParams } from '../../common/types'; -import type { LevelLogger } from './'; -export function checkParamsVersion(jobParams: BaseParams, logger: LevelLogger) { +export function checkParamsVersion(jobParams: BaseParams, logger: Logger) { if (jobParams.version) { logger.debug(`Using reporting job params v${jobParams.version}`); return jobParams.version; } - logger.warning(`No version provided in report job params. Assuming ${UNVERSIONED_VERSION}`); + logger.warn(`No version provided in report job params. Assuming ${UNVERSIONED_VERSION}`); return UNVERSIONED_VERSION; } diff --git a/x-pack/plugins/reporting/server/lib/content_stream.test.ts b/x-pack/plugins/reporting/server/lib/content_stream.test.ts index 0c45ef2d5f5c..069ac22258ad 100644 --- a/x-pack/plugins/reporting/server/lib/content_stream.test.ts +++ b/x-pack/plugins/reporting/server/lib/content_stream.test.ts @@ -5,20 +5,20 @@ * 2.0. */ +import type { Logger } from 'kibana/server'; import { set } from 'lodash'; -import { elasticsearchServiceMock } from 'src/core/server/mocks'; -import { createMockLevelLogger } from '../test_helpers'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; import { ContentStream } from './content_stream'; describe('ContentStream', () => { let client: ReturnType; - let logger: ReturnType; + let logger: Logger; let stream: ContentStream; let base64Stream: ContentStream; beforeEach(() => { client = elasticsearchServiceMock.createClusterClient().asInternalUser; - logger = createMockLevelLogger(); + logger = loggingSystemMock.createLogger(); stream = new ContentStream( client, logger, diff --git a/x-pack/plugins/reporting/server/lib/content_stream.ts b/x-pack/plugins/reporting/server/lib/content_stream.ts index c0b2d458b4d5..b09e446ff576 100644 --- a/x-pack/plugins/reporting/server/lib/content_stream.ts +++ b/x-pack/plugins/reporting/server/lib/content_stream.ts @@ -5,14 +5,13 @@ * 2.0. */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { Duplex } from 'stream'; +import { ByteSizeValue } from '@kbn/config-schema'; +import type { ElasticsearchClient, Logger } from 'kibana/server'; import { defaults, get } from 'lodash'; import Puid from 'puid'; -import { ByteSizeValue } from '@kbn/config-schema'; -import type { ElasticsearchClient } from 'src/core/server'; -import { ReportingCore } from '..'; -import { ReportSource } from '../../common/types'; -import { LevelLogger } from './level_logger'; +import { Duplex } from 'stream'; +import type { ReportingCore } from '../'; +import type { ReportSource } from '../../common/types'; /** * @note The Elasticsearch `http.max_content_length` is including the whole POST body. @@ -87,7 +86,7 @@ export class ContentStream extends Duplex { constructor( private client: ElasticsearchClient, - private logger: LevelLogger, + private logger: Logger, private document: ContentStreamDocument, { encoding = 'base64' }: ContentStreamParameters = {} ) { @@ -348,7 +347,7 @@ export async function getContentStream( return new ContentStream( client, - logger.clone(['content_stream', document.id]), + logger.get('content_stream').get(document.id), document, parameters ); diff --git a/x-pack/plugins/reporting/server/lib/event_logger/adapter.test.ts b/x-pack/plugins/reporting/server/lib/event_logger/adapter.test.ts index aef569a49e35..90c546b198a0 100644 --- a/x-pack/plugins/reporting/server/lib/event_logger/adapter.test.ts +++ b/x-pack/plugins/reporting/server/lib/event_logger/adapter.test.ts @@ -6,11 +6,11 @@ */ import { LogMeta } from 'kibana/server'; -import { createMockLevelLogger } from '../../test_helpers'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { EcsLogAdapter } from './adapter'; describe('EcsLogAdapter', () => { - const logger = createMockLevelLogger(); + const logger = loggingSystemMock.createLogger(); beforeAll(() => { jest .spyOn(global.Date, 'now') @@ -28,7 +28,7 @@ describe('EcsLogAdapter', () => { const event = { kibana: { reporting: { wins: 5000 } } } as object & LogMeta; // an object that extends LogMeta eventLogger.logEvent('hello world', event); - expect(logger.debug).toBeCalledWith('hello world', ['events'], { + expect(logger.debug).toBeCalledWith('hello world', { event: { duration: undefined, end: undefined, @@ -50,7 +50,7 @@ describe('EcsLogAdapter', () => { const event = { kibana: { reporting: { wins: 9000 } } } as object & LogMeta; // an object that extends LogMeta eventLogger.logEvent('hello duration', event); - expect(logger.debug).toBeCalledWith('hello duration', ['events'], { + expect(logger.debug).toBeCalledWith('hello duration', { event: { duration: 120000000000, end: '2021-04-12T16:02:00.000Z', diff --git a/x-pack/plugins/reporting/server/lib/event_logger/adapter.ts b/x-pack/plugins/reporting/server/lib/event_logger/adapter.ts index c9487a79d9e7..71116d8f334b 100644 --- a/x-pack/plugins/reporting/server/lib/event_logger/adapter.ts +++ b/x-pack/plugins/reporting/server/lib/event_logger/adapter.ts @@ -6,23 +6,26 @@ */ import deepMerge from 'deepmerge'; -import { LogMeta } from 'src/core/server'; -import { LevelLogger } from '../level_logger'; -import { IReportingEventLogger } from './logger'; +import type { Logger, LogMeta } from 'kibana/server'; +import type { IReportingEventLogger } from './logger'; /** @internal */ export class EcsLogAdapter implements IReportingEventLogger { start?: Date; end?: Date; + private logger: Logger; + /** * This class provides a logging system to Reporting code, using a shape similar to the EventLog service. * The logging action causes ECS data with Reporting metrics sent to DEBUG logs. * - * @param {LevelLogger} logger - Reporting's wrapper of the core logger + * @param {Logger} logger - Reporting's wrapper of the core logger * @param {Partial} properties - initial ECS data with template for Reporting metrics */ - constructor(private logger: LevelLogger, private properties: Partial) {} + constructor(logger: Logger, private properties: Partial) { + this.logger = logger.get('events'); + } logEvent(message: string, properties: LogMeta) { if (this.start && !this.end) { @@ -44,7 +47,7 @@ export class EcsLogAdapter implements IReportingEventLogger { }); // sends an ECS object with Reporting metrics to the DEBUG logs - this.logger.debug(message, ['events'], deepMerge(newProperties, properties)); + this.logger.debug(message, deepMerge(newProperties, properties)); } startTiming() { diff --git a/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts b/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts index fa45a8d04176..c58777747c3f 100644 --- a/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts +++ b/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts @@ -5,8 +5,8 @@ * 2.0. */ +import { loggingSystemMock } from 'src/core/server/mocks'; import { ConcreteTaskInstance } from '../../../../task_manager/server'; -import { createMockLevelLogger } from '../../test_helpers'; import { BasePayload } from '../../types'; import { Report } from '../store'; import { ReportingEventLogger, reportingEventLoggerFactory } from './logger'; @@ -21,7 +21,7 @@ describe('Event Logger', () => { let factory: ReportingEventLogger; beforeEach(() => { - factory = reportingEventLoggerFactory(createMockLevelLogger()); + factory = reportingEventLoggerFactory(loggingSystemMock.createLogger()); }); it(`should construct with an internal seed object`, () => { diff --git a/x-pack/plugins/reporting/server/lib/event_logger/logger.ts b/x-pack/plugins/reporting/server/lib/event_logger/logger.ts index 6a7feea0c335..965a55e24229 100644 --- a/x-pack/plugins/reporting/server/lib/event_logger/logger.ts +++ b/x-pack/plugins/reporting/server/lib/event_logger/logger.ts @@ -6,8 +6,7 @@ */ import deepMerge from 'deepmerge'; -import { LogMeta } from 'src/core/server'; -import { LevelLogger } from '../'; +import type { Logger, LogMeta } from 'kibana/server'; import { PLUGIN_ID } from '../../../common/constants'; import type { TaskRunMetrics } from '../../../common/types'; import { IReport } from '../store'; @@ -46,7 +45,7 @@ export interface BaseEvent { } /** @internal */ -export function reportingEventLoggerFactory(logger: LevelLogger) { +export function reportingEventLoggerFactory(logger: Logger) { const genericLogger = new EcsLogAdapter(logger, { event: { provider: PLUGIN_ID } }); return class ReportingEventLogger { diff --git a/x-pack/plugins/reporting/server/lib/index.ts b/x-pack/plugins/reporting/server/lib/index.ts index 682f547380ba..36d310fcd131 100644 --- a/x-pack/plugins/reporting/server/lib/index.ts +++ b/x-pack/plugins/reporting/server/lib/index.ts @@ -10,7 +10,6 @@ export { checkParamsVersion } from './check_params_version'; export { ContentStream, getContentStream } from './content_stream'; export { cryptoFactory } from './crypto'; export { ExportTypesRegistry, getExportTypesRegistry } from './export_types_registry'; -export { LevelLogger } from './level_logger'; export { PassThroughStream } from './passthrough_stream'; export { statuses } from './statuses'; export { ReportingStore, IlmPolicyManager } from './store'; diff --git a/x-pack/plugins/reporting/server/lib/level_logger.ts b/x-pack/plugins/reporting/server/lib/level_logger.ts deleted file mode 100644 index 91cf6757dbee..000000000000 --- a/x-pack/plugins/reporting/server/lib/level_logger.ts +++ /dev/null @@ -1,65 +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 { LoggerFactory, LogMeta } from 'src/core/server'; - -const trimStr = (toTrim: string) => { - return typeof toTrim === 'string' ? toTrim.trim() : toTrim; -}; - -export interface GenericLevelLogger { - debug: (msg: string, tags: string[], meta: T) => void; - info: (msg: string) => void; - warning: (msg: string) => void; - error: (msg: Error) => void; -} - -export class LevelLogger implements GenericLevelLogger { - private _logger: LoggerFactory; - private _tags: string[]; - public warning: (msg: string, tags?: string[]) => void; - - constructor(logger: LoggerFactory, tags?: string[]) { - this._logger = logger; - this._tags = tags || []; - - /* - * This shortcut provides maintenance convenience: Reporting code has been - * using both .warn and .warning - */ - this.warning = this.warn.bind(this); - } - - private getLogger(tags: string[]) { - return this._logger.get(...this._tags, ...tags); - } - - public error(err: string | Error, tags: string[] = []) { - this.getLogger(tags).error(err); - } - - public warn(msg: string, tags: string[] = []) { - this.getLogger(tags).warn(msg); - } - - // only "debug" logging supports the LogMeta for now... - public debug(msg: string, tags: string[] = [], meta?: T) { - this.getLogger(tags).debug(msg, meta); - } - - public trace(msg: string, tags: string[] = []) { - this.getLogger(tags).trace(msg); - } - - public info(msg: string, tags: string[] = []) { - this.getLogger(tags).info(trimStr(msg)); - } - - public clone(tags: string[]) { - return new LevelLogger(this._logger, [...this._tags, ...tags]); - } -} diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts index 3e8942be1ffa..7ceafef261dd 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -5,17 +5,13 @@ * 2.0. */ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; import { ReportingCore } from '../../'; -import { - createMockConfigSchema, - createMockLevelLogger, - createMockReportingCore, -} from '../../test_helpers'; +import { createMockConfigSchema, createMockReportingCore } from '../../test_helpers'; import { Report, ReportDocument, ReportingStore, SavedReport } from './'; describe('ReportingStore', () => { - const mockLogger = createMockLevelLogger(); + const mockLogger = loggingSystemMock.createLogger(); let mockCore: ReportingCore; let mockEsClient: ReturnType; diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index 41fdd9580c99..7e920e718d51 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -6,13 +6,14 @@ */ import { IndexResponse, UpdateResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { ElasticsearchClient } from 'src/core/server'; -import { LevelLogger, statuses } from '../'; -import { ReportingCore } from '../../'; +import type { ElasticsearchClient, Logger } from 'kibana/server'; +import { statuses } from '../'; +import type { ReportingCore } from '../../'; import { ILM_POLICY_NAME, REPORTING_SYSTEM_INDEX } from '../../../common/constants'; -import { JobStatus, ReportOutput, ReportSource } from '../../../common/types'; -import { ReportTaskParams } from '../tasks'; -import { IReport, Report, ReportDocument, SavedReport } from './'; +import type { JobStatus, ReportOutput, ReportSource } from '../../../common/types'; +import type { ReportTaskParams } from '../tasks'; +import type { IReport, Report, ReportDocument } from './'; +import { SavedReport } from './'; import { IlmPolicyManager } from './ilm_policy_manager'; import { indexTimestamp } from './index_timestamp'; import { mapping } from './mapping'; @@ -83,12 +84,12 @@ export class ReportingStore { private client?: ElasticsearchClient; private ilmPolicyManager?: IlmPolicyManager; - constructor(private reportingCore: ReportingCore, private logger: LevelLogger) { + constructor(private reportingCore: ReportingCore, private logger: Logger) { const config = reportingCore.getConfig(); this.indexPrefix = REPORTING_SYSTEM_INDEX; this.indexInterval = config.get('queue', 'indexInterval'); - this.logger = logger.clone(['store']); + this.logger = logger.get('store'); } private async getClient() { diff --git a/x-pack/plugins/reporting/server/lib/tasks/error_logger.test.ts b/x-pack/plugins/reporting/server/lib/tasks/error_logger.test.ts index 607c9c32538b..302088e6a6eb 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/error_logger.test.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/error_logger.test.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { createMockLevelLogger } from '../../test_helpers'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { errorLogger } from './error_logger'; -const logger = createMockLevelLogger(); +const logger = loggingSystemMock.createLogger(); describe('Execute Report Error Logger', () => { const errorLogSpy = jest.spyOn(logger, 'error'); diff --git a/x-pack/plugins/reporting/server/lib/tasks/error_logger.ts b/x-pack/plugins/reporting/server/lib/tasks/error_logger.ts index b4d402823066..a67e3caeb2c7 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/error_logger.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/error_logger.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { LevelLogger } from '..'; +import type { Logger } from 'kibana/server'; const MAX_PARTIAL_ERROR_LENGTH = 1000; // 1000 of beginning, 1000 of end const ERROR_PARTIAL_SEPARATOR = '...'; @@ -15,7 +15,7 @@ const MAX_ERROR_LENGTH = MAX_PARTIAL_ERROR_LENGTH * 2 + ERROR_PARTIAL_SEPARATOR. * An error message string could be very long, as it sometimes includes huge * amount of base64 */ -export const errorLogger = (logger: LevelLogger, message: string, err?: Error) => { +export const errorLogger = (logger: Logger, message: string, err?: Error) => { if (err) { const errString = `${message}: ${err}`; const errLength = errString.length; diff --git a/x-pack/plugins/reporting/server/lib/tasks/execute_report.test.ts b/x-pack/plugins/reporting/server/lib/tasks/execute_report.test.ts index df662d963d0e..9f016a6a54a1 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/execute_report.test.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/execute_report.test.ts @@ -5,18 +5,17 @@ * 2.0. */ +import { loggingSystemMock } from 'src/core/server/mocks'; import { ReportingCore } from '../..'; import { RunContext } from '../../../../task_manager/server'; import { taskManagerMock } from '../../../../task_manager/server/mocks'; +import { KibanaShuttingDownError } from '../../../common/errors'; +import type { SavedReport } from '../store'; import { ReportingConfigType } from '../../config'; -import { - createMockConfigSchema, - createMockLevelLogger, - createMockReportingCore, -} from '../../test_helpers'; +import { createMockConfigSchema, createMockReportingCore } from '../../test_helpers'; import { ExecuteReportTask } from './'; -const logger = createMockLevelLogger(); +const logger = loggingSystemMock.createLogger(); describe('Execute Report Task', () => { let mockReporting: ReportingCore; @@ -82,4 +81,45 @@ describe('Execute Report Task', () => { } `); }); + + it('throws during reporting if Kibana starts shutting down', async () => { + mockReporting.getExportTypesRegistry().register({ + id: 'noop', + name: 'Noop', + createJobFnFactory: () => async () => new Promise(() => {}), + runTaskFnFactory: () => async () => new Promise(() => {}), + jobContentExtension: 'none', + jobType: 'noop', + validLicenses: [], + }); + const store = await mockReporting.getStore(); + store.setReportFailed = jest.fn(() => Promise.resolve({} as any)); + const task = new ExecuteReportTask(mockReporting, configType, logger); + task._claimJob = jest.fn(() => + Promise.resolve({ _id: 'test', jobtype: 'noop', status: 'pending' } as SavedReport) + ); + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager); + + const taskDef = task.getTaskDefinition(); + const taskRunner = taskDef.createTaskRunner({ + taskInstance: { + id: 'random-task-id', + params: { index: 'cool-reporting-index', id: 'noop', jobtype: 'noop', payload: {} }, + }, + } as unknown as RunContext); + + const taskPromise = taskRunner.run(); + setImmediate(() => { + mockReporting.pluginStop(); + }); + await taskPromise; + + expect(store.setReportFailed).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + output: expect.objectContaining({ error_code: new KibanaShuttingDownError().code }), + }) + ); + }); }); diff --git a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts index 449f3b8da767..bd4c57437b35 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts @@ -6,20 +6,22 @@ */ import { UpdateResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { Logger } from 'kibana/server'; import moment from 'moment'; import * as Rx from 'rxjs'; import { timeout } from 'rxjs/operators'; import { finished, Writable } from 'stream'; import { promisify } from 'util'; -import { getContentStream, LevelLogger } from '../'; -import { ReportingCore } from '../../'; -import { +import { getContentStream } from '../'; +import type { ReportingCore } from '../../'; +import type { RunContext, TaskManagerStartContract, TaskRunCreatorFunction, } from '../../../../task_manager/server'; import { CancellationToken } from '../../../common/cancellation_token'; -import { ReportingError, UnknownError, QueueTimeoutError } from '../../../common/errors'; +import { mapToReportingError } from '../../../common/errors/map_to_reporting_error'; +import { ReportingError, QueueTimeoutError, KibanaShuttingDownError } from '../../../common/errors'; import { durationToNumber, numberToDuration } from '../../../common/schema_utils'; import type { ReportOutput } from '../../../common/types'; import type { ReportingConfigType } from '../../config'; @@ -60,7 +62,7 @@ function reportFromTask(task: ReportTaskParams) { export class ExecuteReportTask implements ReportingTask { public TYPE = REPORTING_EXECUTE_TYPE; - private logger: LevelLogger; + private logger: Logger; private taskManagerStart?: TaskManagerStartContract; private taskExecutors?: Map; private kibanaId?: string; @@ -70,9 +72,9 @@ export class ExecuteReportTask implements ReportingTask { constructor( private reporting: ReportingCore, private config: ReportingConfigType, - logger: LevelLogger + logger: Logger ) { - this.logger = logger.clone(['runTask']); + this.logger = logger.get('runTask'); } /* @@ -86,7 +88,7 @@ export class ExecuteReportTask implements ReportingTask { const exportTypesRegistry = reporting.getExportTypesRegistry(); const executors = new Map(); for (const exportType of exportTypesRegistry.getAll()) { - const exportTypeLogger = this.logger.clone([exportType.id]); + const exportTypeLogger = this.logger.get(exportType.jobType); const jobExecutor = exportType.runTaskFnFactory(reporting, exportTypeLogger); // The task will run the function with the job type as a param. // This allows us to retrieve the specific export type runFn when called to run an export @@ -232,7 +234,7 @@ export class ExecuteReportTask implements ReportingTask { const defaultOutput = null; docOutput.content = output.toString() || defaultOutput; docOutput.content_type = unknownMime; - docOutput.warnings = [output.details ?? output.toString()]; + docOutput.warnings = [output.toString()]; docOutput.error_code = output.code; } @@ -287,6 +289,12 @@ export class ExecuteReportTask implements ReportingTask { return report; } + // Generic is used to let TS infer the return type at call site. + private async throwIfKibanaShutsDown(): Promise { + await this.reporting.getKibanaShutdown$().toPromise(); + throw new KibanaShuttingDownError(); + } + /* * Provides a TaskRunner for Task Manager */ @@ -360,7 +368,10 @@ export class ExecuteReportTask implements ReportingTask { eventLog.logExecutionStart(); - const output = await this._performJob(task, cancellationToken, stream); + const output = await Promise.race([ + this._performJob(task, cancellationToken, stream), + this.throwIfKibanaShutsDown(), + ]); stream.end(); @@ -417,10 +428,7 @@ export class ExecuteReportTask implements ReportingTask { if (report == null) { throw new Error(`Report ${jobId} is null!`); } - const error = - failedToExecuteErr instanceof ReportingError - ? failedToExecuteErr - : new UnknownError(); + const error = mapToReportingError(failedToExecuteErr); error.details = error.details || `Max attempts (${attempts}) reached for job ${jobId}. Failed with: ${failedToExecuteErr.message}`; @@ -476,7 +484,7 @@ export class ExecuteReportTask implements ReportingTask { return await this.getTaskManagerStart().schedule(taskInstance); } - private async rescheduleTask(task: ReportTaskParams, logger: LevelLogger) { + private async rescheduleTask(task: ReportTaskParams, logger: Logger) { logger.info(`Rescheduling task:${task.id} to retry after error.`); const oldTaskInstance: ReportingExecuteTaskInstance = { diff --git a/x-pack/plugins/reporting/server/lib/tasks/monitor_report.test.ts b/x-pack/plugins/reporting/server/lib/tasks/monitor_report.test.ts index d737c7032855..b7e75de24753 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/monitor_report.test.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/monitor_report.test.ts @@ -5,18 +5,15 @@ * 2.0. */ +import { loggingSystemMock } from 'src/core/server/mocks'; import { ReportingCore } from '../..'; import { RunContext } from '../../../../task_manager/server'; import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { ReportingConfigType } from '../../config'; -import { - createMockConfigSchema, - createMockLevelLogger, - createMockReportingCore, -} from '../../test_helpers'; +import { createMockConfigSchema, createMockReportingCore } from '../../test_helpers'; import { MonitorReportsTask } from './'; -const logger = createMockLevelLogger(); +const logger = loggingSystemMock.createLogger(); describe('Execute Report Task', () => { let mockReporting: ReportingCore; diff --git a/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts b/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts index 4af28e3d1a69..56e12d7d5512 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts @@ -5,8 +5,9 @@ * 2.0. */ +import type { Logger } from 'kibana/server'; import moment from 'moment'; -import { LevelLogger, ReportingStore } from '../'; +import { ReportingStore } from '../'; import { ReportingCore } from '../../'; import { TaskManagerStartContract, TaskRunCreatorFunction } from '../../../../task_manager/server'; import { numberToDuration } from '../../../common/schema_utils'; @@ -38,7 +39,7 @@ import { ReportingTask, ReportingTaskStatus, REPORTING_MONITOR_TYPE, ReportTaskP export class MonitorReportsTask implements ReportingTask { public TYPE = REPORTING_MONITOR_TYPE; - private logger: LevelLogger; + private logger: Logger; private taskManagerStart?: TaskManagerStartContract; private store?: ReportingStore; private timeout: moment.Duration; @@ -46,9 +47,9 @@ export class MonitorReportsTask implements ReportingTask { constructor( private reporting: ReportingCore, private config: ReportingConfigType, - parentLogger: LevelLogger + parentLogger: Logger ) { - this.logger = parentLogger.clone([REPORTING_MONITOR_TYPE]); + this.logger = parentLogger.get(REPORTING_MONITOR_TYPE); this.timeout = numberToDuration(config.queue.timeout); } @@ -91,31 +92,42 @@ export class MonitorReportsTask implements ReportingTask { return; } - const { - _id: jobId, - _source: { process_expiration: processExpiration, status }, - } = recoveredJob; + const report = new SavedReport({ ...recoveredJob, ...recoveredJob._source }); + const { _id: jobId, process_expiration: processExpiration, status } = report; + const eventLog = this.reporting.getEventLogger(report); if (![statuses.JOB_STATUS_PENDING, statuses.JOB_STATUS_PROCESSING].includes(status)) { - throw new Error(`Invalid job status in the monitoring search result: ${status}`); // only pending or processing jobs possibility need rescheduling + const invalidStatusError = new Error( + `Invalid job status in the monitoring search result: ${status}` + ); // only pending or processing jobs possibility need rescheduling + this.logger.error(invalidStatusError); + eventLog.logError(invalidStatusError); + + // fatal: can not reschedule the job + throw invalidStatusError; } if (status === statuses.JOB_STATUS_PENDING) { - this.logger.info( + const migratingJobError = new Error( `${jobId} was scheduled in a previous version and left in [${status}] status. Rescheduling...` ); + this.logger.error(migratingJobError); + eventLog.logError(migratingJobError); } if (status === statuses.JOB_STATUS_PROCESSING) { const expirationTime = moment(processExpiration); const overdueValue = moment().valueOf() - expirationTime.valueOf(); - this.logger.info( + const overdueExpirationError = new Error( `${jobId} status is [${status}] and the expiration time was [${overdueValue}ms] ago. Rescheduling...` ); + this.logger.error(overdueExpirationError); + eventLog.logError(overdueExpirationError); } + eventLog.logRetry(); + // clear process expiration and set status to pending - const report = new SavedReport({ ...recoveredJob, ...recoveredJob._source }); await reportingStore.prepareReportForRetry(report); // if there is a version conflict response, this just throws and logs an error // clear process expiration and reschedule @@ -145,7 +157,7 @@ export class MonitorReportsTask implements ReportingTask { } // reschedule the task with TM - private async rescheduleTask(task: ReportTaskParams, logger: LevelLogger) { + private async rescheduleTask(task: ReportTaskParams, logger: Logger) { if (!this.taskManagerStart) { throw new Error('Reporting task runner has not been initialized!'); } @@ -153,8 +165,6 @@ export class MonitorReportsTask implements ReportingTask { const newTask = await this.reporting.scheduleTask(task); - this.reporting.getEventLogger({ _id: task.id, ...task }, newTask).logRetry(); - return newTask; } diff --git a/x-pack/plugins/reporting/server/plugin.test.ts b/x-pack/plugins/reporting/server/plugin.test.ts index e179d847d952..98f02668323b 100644 --- a/x-pack/plugins/reporting/server/plugin.test.ts +++ b/x-pack/plugins/reporting/server/plugin.test.ts @@ -5,14 +5,12 @@ * 2.0. */ -import type { CoreSetup, CoreStart } from 'kibana/server'; -import { coreMock } from 'src/core/server/mocks'; +import type { CoreSetup, CoreStart, Logger } from 'kibana/server'; +import { coreMock, loggingSystemMock } from 'src/core/server/mocks'; import type { ReportingCore, ReportingInternalStart } from './core'; -import { LevelLogger } from './lib'; import { ReportingPlugin } from './plugin'; import { createMockConfigSchema, - createMockLevelLogger, createMockPluginSetup, createMockPluginStart, } from './test_helpers'; @@ -27,7 +25,7 @@ describe('Reporting Plugin', () => { let coreStart: CoreStart; let pluginSetup: ReportingSetupDeps; let pluginStart: ReportingInternalStart; - let logger: jest.Mocked; + let logger: jest.Mocked; let plugin: ReportingPlugin; beforeEach(async () => { @@ -38,9 +36,9 @@ describe('Reporting Plugin', () => { pluginSetup = createMockPluginSetup({}) as unknown as ReportingSetupDeps; pluginStart = await createMockPluginStart(coreStart, configSchema); - logger = createMockLevelLogger(); + logger = loggingSystemMock.createLogger(); plugin = new ReportingPlugin(initContext); - (plugin as unknown as { logger: LevelLogger }).logger = logger; + (plugin as unknown as { logger: Logger }).logger = logger; }); it('has a sync setup process', () => { diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index a0d4bfed7c7e..52a72e713902 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -5,12 +5,12 @@ * 2.0. */ -import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/server'; +import type { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; import { PLUGIN_ID } from '../common/constants'; import { ReportingCore } from './'; import { buildConfig, registerUiSettings, ReportingConfigType } from './config'; import { registerDeprecations } from './deprecations'; -import { LevelLogger, ReportingStore } from './lib'; +import { ReportingStore } from './lib'; import { registerRoutes } from './routes'; import { setFieldFormats } from './services'; import type { @@ -28,11 +28,11 @@ import { registerReportingUsageCollector } from './usage'; export class ReportingPlugin implements Plugin { - private logger: LevelLogger; + private logger: Logger; private reportingCore?: ReportingCore; constructor(private initContext: PluginInitializerContext) { - this.logger = new LevelLogger(initContext.logger.get()); + this.logger = initContext.logger.get(); } public setup(core: CoreSetup, plugins: ReportingSetupDeps) { @@ -113,4 +113,8 @@ export class ReportingPlugin return reportingCore.getContract(); } + + stop() { + this.reportingCore?.pluginStop(); + } } diff --git a/x-pack/plugins/reporting/server/routes/deprecations/deprecations.ts b/x-pack/plugins/reporting/server/routes/deprecations/deprecations.ts index 4c368337cd48..89d55ff04ab8 100644 --- a/x-pack/plugins/reporting/server/routes/deprecations/deprecations.ts +++ b/x-pack/plugins/reporting/server/routes/deprecations/deprecations.ts @@ -5,16 +5,16 @@ * 2.0. */ import { errors } from '@elastic/elasticsearch'; -import { SecurityHasPrivilegesIndexPrivilegesCheck } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { RequestHandler } from 'src/core/server'; +import type { SecurityHasPrivilegesIndexPrivilegesCheck } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { Logger, RequestHandler } from 'kibana/server'; import { API_GET_ILM_POLICY_STATUS, API_MIGRATE_ILM_POLICY_URL, ILM_POLICY_NAME, } from '../../../common/constants'; -import { IlmPolicyStatusResponse } from '../../../common/types'; -import { ReportingCore } from '../../core'; -import { IlmPolicyManager, LevelLogger as Logger } from '../../lib'; +import type { IlmPolicyStatusResponse } from '../../../common/types'; +import type { ReportingCore } from '../../core'; +import { IlmPolicyManager } from '../../lib'; import { deprecations } from '../../lib/deprecations'; export const registerDeprecationsRoutes = (reporting: ReportingCore, logger: Logger) => { diff --git a/x-pack/plugins/reporting/server/routes/deprecations/integration_tests/deprecations.test.ts b/x-pack/plugins/reporting/server/routes/deprecations/integration_tests/deprecations.test.ts index 67d7d0c4a0c0..9c76aade058f 100644 --- a/x-pack/plugins/reporting/server/routes/deprecations/integration_tests/deprecations.test.ts +++ b/x-pack/plugins/reporting/server/routes/deprecations/integration_tests/deprecations.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { loggingSystemMock } from 'src/core/server/mocks'; import { setupServer } from 'src/core/server/test_utils'; import supertest from 'supertest'; import { licensingMock } from '../../../../../licensing/server/mocks'; @@ -12,7 +13,6 @@ import { securityMock } from '../../../../../security/server/mocks'; import { API_GET_ILM_POLICY_STATUS } from '../../../../common/constants'; import { createMockConfigSchema, - createMockLevelLogger, createMockPluginSetup, createMockPluginStart, createMockReportingCore, @@ -54,7 +54,7 @@ describe(`GET ${API_GET_ILM_POLICY_STATUS}`, () => { it('correctly handles authz when security is unavailable', async () => { const core = await createReportingCore({}); - registerDeprecationsRoutes(core, createMockLevelLogger()); + registerDeprecationsRoutes(core, loggingSystemMock.createLogger()); await server.start(); await supertest(httpSetup.server.listener) @@ -68,7 +68,7 @@ describe(`GET ${API_GET_ILM_POLICY_STATUS}`, () => { security.license.isEnabled.mockReturnValue(false); const core = await createReportingCore({ security }); - registerDeprecationsRoutes(core, createMockLevelLogger()); + registerDeprecationsRoutes(core, loggingSystemMock.createLogger()); await server.start(); await supertest(httpSetup.server.listener) diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts b/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts index f68df294b411..fb95ad9e3188 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts @@ -6,11 +6,11 @@ */ import { i18n } from '@kbn/i18n'; -import { ReportingCore } from '../..'; +import type { Logger } from 'kibana/server'; +import type { ReportingCore } from '../..'; import { API_DIAGNOSE_URL } from '../../../common/constants'; -import { LevelLogger as Logger } from '../../lib'; import { authorizedUserPreRouting } from '../lib/authorized_user_pre_routing'; -import { DiagnosticResponse } from './'; +import type { DiagnosticResponse } from './'; const logsToHelpMap = { 'error while loading shared libraries': i18n.translate( diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/index.ts b/x-pack/plugins/reporting/server/routes/diagnostic/index.ts index 92404b76e074..b5e2a8585afb 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/index.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/index.ts @@ -5,10 +5,10 @@ * 2.0. */ +import type { Logger } from 'kibana/server'; +import type { ReportingCore } from '../../core'; import { registerDiagnoseBrowser } from './browser'; import { registerDiagnoseScreenshot } from './screenshot'; -import { LevelLogger as Logger } from '../../lib'; -import { ReportingCore } from '../../core'; export const registerDiagnosticRoutes = (reporting: ReportingCore, logger: Logger) => { registerDiagnoseBrowser(reporting, logger); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/integration_tests/browser.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/integration_tests/browser.test.ts index 911807e63a9d..dc8fdb7e6d0c 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/integration_tests/browser.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/integration_tests/browser.test.ts @@ -6,13 +6,13 @@ */ import * as Rx from 'rxjs'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { setupServer } from 'src/core/server/test_utils'; import supertest from 'supertest'; import { ReportingCore } from '../../../'; import type { ScreenshottingStart } from '../../../../../screenshotting/server'; import { createMockConfigSchema, - createMockLevelLogger, createMockPluginSetup, createMockReportingCore, } from '../../../test_helpers'; @@ -27,7 +27,7 @@ const fontNotFoundMessage = 'Could not find the default font'; describe('POST /diagnose/browser', () => { jest.setTimeout(6000); const reportingSymbol = Symbol('reporting'); - const mockLogger = createMockLevelLogger(); + const mockLogger = loggingSystemMock.createLogger(); let server: SetupServerReturn['server']; let httpSetup: SetupServerReturn['httpSetup']; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/integration_tests/screenshot.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/integration_tests/screenshot.test.ts index ad90679e67ad..3bc3f5bbb5e2 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/integration_tests/screenshot.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/integration_tests/screenshot.test.ts @@ -5,13 +5,13 @@ * 2.0. */ +import { loggingSystemMock } from 'src/core/server/mocks'; import { setupServer } from 'src/core/server/test_utils'; import supertest from 'supertest'; import { ReportingCore } from '../../../'; import { generatePngObservable } from '../../../export_types/common'; import { createMockConfigSchema, - createMockLevelLogger, createMockPluginSetup, createMockReportingCore, } from '../../../test_helpers'; @@ -38,7 +38,7 @@ describe('POST /diagnose/screenshot', () => { }; const config = createMockConfigSchema({ queue: { timeout: 120000 } }); - const mockLogger = createMockLevelLogger(); + const mockLogger = loggingSystemMock.createLogger(); beforeEach(async () => { ({ server, httpSetup } = await setupServer(reportingSymbol)); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts index 90b4c9d9a30c..6819970fe753 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts @@ -6,12 +6,12 @@ */ import { i18n } from '@kbn/i18n'; +import type { Logger } from 'kibana/server'; import { ReportingCore } from '../..'; import { APP_WRAPPER_CLASS } from '../../../../../../src/core/server'; import { API_DIAGNOSE_URL } from '../../../common/constants'; import { generatePngObservable } from '../../export_types/common'; import { getAbsoluteUrlFactory } from '../../export_types/common/get_absolute_url'; -import { LevelLogger as Logger } from '../../lib'; import { authorizedUserPreRouting } from '../lib/authorized_user_pre_routing'; import { DiagnosticResponse } from './'; diff --git a/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts b/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts index b6ada00ba55a..19687b9d3ec9 100644 --- a/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts @@ -6,13 +6,13 @@ */ import { schema } from '@kbn/config-schema'; -import { KibanaRequest } from 'src/core/server'; -import { ReportingCore } from '../../'; +import type { KibanaRequest, Logger } from 'kibana/server'; +import type { ReportingCore } from '../../'; import { CSV_SEARCHSOURCE_IMMEDIATE_TYPE } from '../../../common/constants'; import { runTaskFnFactory } from '../../export_types/csv_searchsource_immediate/execute_job'; -import { JobParamsDownloadCSV } from '../../export_types/csv_searchsource_immediate/types'; -import { LevelLogger as Logger, PassThroughStream } from '../../lib'; -import { BaseParams } from '../../types'; +import type { JobParamsDownloadCSV } from '../../export_types/csv_searchsource_immediate/types'; +import { PassThroughStream } from '../../lib'; +import type { BaseParams } from '../../types'; import { authorizedUserPreRouting } from '../lib/authorized_user_pre_routing'; import { RequestHandler } from '../lib/request_handler'; @@ -64,7 +64,7 @@ export function registerGenerateCsvFromSavedObjectImmediate( authorizedUserPreRouting( reporting, async (user, context, req: CsvFromSavedObjectRequest, res) => { - const logger = parentLogger.clone([CSV_SEARCHSOURCE_IMMEDIATE_TYPE]); + const logger = parentLogger.get(CSV_SEARCHSOURCE_IMMEDIATE_TYPE); const runTaskFn = runTaskFnFactory(reporting, logger); const requestHandler = new RequestHandler(reporting, user, context, req, res, logger); const stream = new PassThroughStream(); diff --git a/x-pack/plugins/reporting/server/routes/generate/generate_from_jobparams.ts b/x-pack/plugins/reporting/server/routes/generate/generate_from_jobparams.ts index cfcb7d6d2b05..c5e7bb2197d7 100644 --- a/x-pack/plugins/reporting/server/routes/generate/generate_from_jobparams.ts +++ b/x-pack/plugins/reporting/server/routes/generate/generate_from_jobparams.ts @@ -7,16 +7,16 @@ import { schema } from '@kbn/config-schema'; import rison from 'rison-node'; -import { ReportingCore } from '../..'; +import type { Logger } from 'kibana/server'; +import type { ReportingCore } from '../..'; import { API_BASE_URL } from '../../../common/constants'; -import { LevelLogger } from '../../lib'; -import { BaseParams } from '../../types'; +import type { BaseParams } from '../../types'; import { authorizedUserPreRouting } from '../lib/authorized_user_pre_routing'; import { RequestHandler } from '../lib/request_handler'; const BASE_GENERATE = `${API_BASE_URL}/generate`; -export function registerJobGenerationRoutes(reporting: ReportingCore, logger: LevelLogger) { +export function registerJobGenerationRoutes(reporting: ReportingCore, logger: Logger) { const setupDeps = reporting.getPluginSetupDeps(); const { router } = setupDeps; diff --git a/x-pack/plugins/reporting/server/routes/generate/integration_tests/generation_from_jobparams.test.ts b/x-pack/plugins/reporting/server/routes/generate/integration_tests/generation_from_jobparams.test.ts index f6db9e92086e..f0db06485cf4 100644 --- a/x-pack/plugins/reporting/server/routes/generate/integration_tests/generation_from_jobparams.test.ts +++ b/x-pack/plugins/reporting/server/routes/generate/integration_tests/generation_from_jobparams.test.ts @@ -7,6 +7,7 @@ import rison from 'rison-node'; import { BehaviorSubject } from 'rxjs'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { setupServer } from 'src/core/server/test_utils'; import supertest from 'supertest'; import { ReportingCore } from '../../../'; @@ -16,7 +17,6 @@ import { ExportTypesRegistry } from '../../../lib/export_types_registry'; import { Report } from '../../../lib/store'; import { createMockConfigSchema, - createMockLevelLogger, createMockPluginSetup, createMockPluginStart, createMockReportingCore, @@ -38,7 +38,7 @@ describe('POST /api/reporting/generate', () => { queue: { indexInterval: 'year', timeout: 10000, pollEnabled: true }, }); - const mockLogger = createMockLevelLogger(); + const mockLogger = loggingSystemMock.createLogger(); beforeEach(async () => { ({ server, httpSetup } = await setupServer(reportingSymbol)); diff --git a/x-pack/plugins/reporting/server/routes/index.ts b/x-pack/plugins/reporting/server/routes/index.ts index 49f602062b0c..0cc0d1bdc679 100644 --- a/x-pack/plugins/reporting/server/routes/index.ts +++ b/x-pack/plugins/reporting/server/routes/index.ts @@ -5,8 +5,8 @@ * 2.0. */ +import type { Logger } from 'kibana/server'; import { ReportingCore } from '..'; -import { LevelLogger } from '../lib'; import { registerDeprecationsRoutes } from './deprecations/deprecations'; import { registerDiagnosticRoutes } from './diagnostic'; import { @@ -15,7 +15,7 @@ import { } from './generate'; import { registerJobInfoRoutes } from './management'; -export function registerRoutes(reporting: ReportingCore, logger: LevelLogger) { +export function registerRoutes(reporting: ReportingCore, logger: Logger) { registerDeprecationsRoutes(reporting, logger); registerDiagnosticRoutes(reporting, logger); registerGenerateCsvFromSavedObjectImmediate(reporting, logger); diff --git a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts index 7f4d85ff1415..af679b403cba 100644 --- a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts +++ b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts @@ -15,7 +15,6 @@ import { import { errors } from '@elastic/elasticsearch'; import { i18n } from '@kbn/i18n'; import { ElasticsearchClient } from 'src/core/server'; -import { PromiseType } from 'utility-types'; import { ReportingCore } from '../../'; import { REPORTING_SYSTEM_INDEX } from '../../../common/constants'; import { ReportApiJSON, ReportSource } from '../../../common/types'; @@ -61,7 +60,7 @@ export function jobsQueryFactory(reportingCore: ReportingCore): JobsQueryFactory } async function execQuery< - T extends (client: ElasticsearchClient) => Promise> | undefined> + T extends (client: ElasticsearchClient) => Promise> | undefined> >(callback: T): Promise> | undefined> { try { const { asInternalUser: client } = await reportingCore.getEsClient(); @@ -138,7 +137,7 @@ export function jobsQueryFactory(reportingCore: ReportingCore): JobsQueryFactory async get(user, id) { const { logger } = reportingCore.getPluginSetupDeps(); if (!id) { - logger.warning(`No ID provided for GET`); + logger.warn(`No ID provided for GET`); return; } @@ -163,7 +162,7 @@ export function jobsQueryFactory(reportingCore: ReportingCore): JobsQueryFactory const result = response?.hits?.hits?.[0]; if (!result?._source) { - logger.warning(`No hits resulted in search`); + logger.warn(`No hits resulted in search`); return; } diff --git a/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts b/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts index d1c1dddb3c30..c97ec3285839 100644 --- a/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts +++ b/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts @@ -6,16 +6,12 @@ */ import { KibanaRequest, KibanaResponseFactory } from 'kibana/server'; -import { coreMock, httpServerMock } from 'src/core/server/mocks'; +import { coreMock, httpServerMock, loggingSystemMock } from 'src/core/server/mocks'; import { ReportingCore } from '../..'; import { JobParamsPDFDeprecated, TaskPayloadPDF } from '../../export_types/printable_pdf/types'; import { Report, ReportingStore } from '../../lib/store'; import { ReportApiJSON } from '../../lib/store/report'; -import { - createMockConfigSchema, - createMockLevelLogger, - createMockReportingCore, -} from '../../test_helpers'; +import { createMockConfigSchema, createMockReportingCore } from '../../test_helpers'; import { ReportingRequestHandlerContext, ReportingSetup } from '../../types'; import { RequestHandler } from './request_handler'; @@ -43,7 +39,7 @@ const getMockResponseFactory = () => unauthorized: (obj: unknown) => obj, } as unknown as KibanaResponseFactory); -const mockLogger = createMockLevelLogger(); +const mockLogger = loggingSystemMock.createLogger(); describe('Handle request to generate', () => { let reportingCore: ReportingCore; diff --git a/x-pack/plugins/reporting/server/routes/lib/request_handler.ts b/x-pack/plugins/reporting/server/routes/lib/request_handler.ts index b0a2032c18f1..b8a3a4c69802 100644 --- a/x-pack/plugins/reporting/server/routes/lib/request_handler.ts +++ b/x-pack/plugins/reporting/server/routes/lib/request_handler.ts @@ -7,12 +7,12 @@ import Boom from '@hapi/boom'; import { i18n } from '@kbn/i18n'; -import { KibanaRequest, KibanaResponseFactory } from 'kibana/server'; -import { ReportingCore } from '../..'; +import type { KibanaRequest, KibanaResponseFactory, Logger } from 'kibana/server'; +import type { ReportingCore } from '../..'; import { API_BASE_URL } from '../../../common/constants'; -import { checkParamsVersion, cryptoFactory, LevelLogger } from '../../lib'; +import { checkParamsVersion, cryptoFactory } from '../../lib'; import { Report } from '../../lib/store'; -import { BaseParams, ReportingRequestHandlerContext, ReportingUser } from '../../types'; +import type { BaseParams, ReportingRequestHandlerContext, ReportingUser } from '../../types'; export const handleUnavailable = (res: KibanaResponseFactory) => { return res.custom({ statusCode: 503, body: 'Not Available' }); @@ -30,7 +30,7 @@ export class RequestHandler { private context: ReportingRequestHandlerContext, private req: KibanaRequest, private res: KibanaResponseFactory, - private logger: LevelLogger + private logger: Logger ) {} private async encryptHeaders() { @@ -53,7 +53,7 @@ export class RequestHandler { } const [createJob, store] = await Promise.all([ - exportType.createJobFnFactory(reporting, logger.clone([exportType.id])), + exportType.createJobFnFactory(reporting, logger.get(exportType.id)), reporting.getStore(), ]); diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_levellogger.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_levellogger.ts deleted file mode 100644 index a6e6be47bdfc..000000000000 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_levellogger.ts +++ /dev/null @@ -1,29 +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. - */ - -jest.mock('../lib/level_logger'); - -import { loggingSystemMock } from 'src/core/server/mocks'; -import { LevelLogger } from '../lib/level_logger'; - -export function createMockLevelLogger() { - // eslint-disable-next-line no-console - const consoleLogger = (tag: string) => (message: unknown) => console.log(tag, message); - - const logger = new LevelLogger(loggingSystemMock.create()) as jest.Mocked; - - // logger.debug.mockImplementation(consoleLogger('debug')); // uncomment this to see debug logs in jest tests - logger.info.mockImplementation(consoleLogger('info')); - logger.warn.mockImplementation(consoleLogger('warn')); - logger.warning = jest.fn().mockImplementation(consoleLogger('warn')); - logger.error.mockImplementation(consoleLogger('error')); - logger.trace.mockImplementation(consoleLogger('trace')); - - logger.clone.mockImplementation(() => logger); - - return logger; -} diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts index 49d92a0fe444..414d3dd10f8f 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -10,7 +10,12 @@ jest.mock('../usage'); import _ from 'lodash'; import { BehaviorSubject } from 'rxjs'; -import { coreMock, elasticsearchServiceMock, statusServiceMock } from 'src/core/server/mocks'; +import { + coreMock, + elasticsearchServiceMock, + loggingSystemMock, + statusServiceMock, +} from 'src/core/server/mocks'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { dataPluginMock } from 'src/plugins/data/server/mocks'; import { FieldFormatsRegistry } from 'src/plugins/field_formats/common'; @@ -27,7 +32,6 @@ import { buildConfig, ReportingConfigType } from '../config'; import { ReportingInternalSetup, ReportingInternalStart } from '../core'; import { ReportingStore } from '../lib'; import { setFieldFormats } from '../services'; -import { createMockLevelLogger } from './create_mock_levellogger'; export const createMockPluginSetup = ( setupMock: Partial> @@ -38,13 +42,13 @@ export const createMockPluginSetup = ( router: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() }, security: securityMock.createSetup(), taskManager: taskManagerMock.createSetup(), - logger: createMockLevelLogger(), + logger: loggingSystemMock.createLogger(), status: statusServiceMock.createSetupContract(), ...setupMock, }; }; -const logger = createMockLevelLogger(); +const logger = loggingSystemMock.createLogger(); const createMockReportingStore = async (config: ReportingConfigType) => { const mockConfigSchema = createMockConfigSchema(config); @@ -115,6 +119,16 @@ export const createMockConfigSchema = ( enabled: false, ...overrides.roles, }, + capture: { + maxAttempts: 1, + loadDelay: 1, + timeouts: { + openUrl: 100, + renderComplete: 100, + waitForElements: 100, + }, + zoom: 1, + }, } as ReportingConfigType; }; diff --git a/x-pack/plugins/reporting/server/test_helpers/index.ts b/x-pack/plugins/reporting/server/test_helpers/index.ts index df0a18207534..0e1dffe142c7 100644 --- a/x-pack/plugins/reporting/server/test_helpers/index.ts +++ b/x-pack/plugins/reporting/server/test_helpers/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -export { createMockLevelLogger } from './create_mock_levellogger'; export { createMockConfig, createMockConfigSchema, diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index fa69509d16be..b3c9261bfd92 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { IRouter, RequestHandlerContext } from 'src/core/server'; +import type { IRouter, Logger, RequestHandlerContext } from 'kibana/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import type { DataPluginStart } from 'src/plugins/data/server/plugin'; import { FieldFormatsStart } from 'src/plugins/field_formats/server'; @@ -29,7 +29,6 @@ import type { CancellationToken } from '../common/cancellation_token'; import type { BaseParams, BasePayload, TaskRunResult, UrlOrUrlLocatorTuple } from '../common/types'; import type { ReportingConfigType } from './config'; import type { ReportingCore } from './core'; -import type { LevelLogger } from './lib'; import type { ReportTaskParams } from './lib/tasks'; /** @@ -71,12 +70,12 @@ export type RunTaskFn = ( export type CreateJobFnFactory = ( reporting: ReportingCore, - logger: LevelLogger + logger: Logger ) => CreateJobFnType; export type RunTaskFnFactory = ( reporting: ReportingCore, - logger: LevelLogger + logger: Logger ) => RunTaskFnType; export interface ExportTypeDefinition< diff --git a/x-pack/plugins/rule_registry/common/search_strategy/index.ts b/x-pack/plugins/rule_registry/common/search_strategy/index.ts index efb8a3478263..9b3ec2d7fec4 100644 --- a/x-pack/plugins/rule_registry/common/search_strategy/index.ts +++ b/x-pack/plugins/rule_registry/common/search_strategy/index.ts @@ -12,8 +12,15 @@ import { IEsSearchRequest, IEsSearchResponse } from 'src/plugins/data/common'; export type RuleRegistrySearchRequest = IEsSearchRequest & { featureIds: ValidFeatureId[]; query?: { bool: estypes.QueryDslBoolQuery }; + sort?: estypes.SortCombinations[]; + pagination?: RuleRegistrySearchRequestPagination; }; +export interface RuleRegistrySearchRequestPagination { + pageIndex: number; + pageSize: number; +} + type Prev = [ never, 0, diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.test.ts b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.test.ts new file mode 100644 index 000000000000..0a700cab50ee --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.test.ts @@ -0,0 +1,315 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { left, right } from 'fp-ts/lib/Either'; +import { RuleDataClient, RuleDataClientConstructorOptions, WaitResult } from './rule_data_client'; +import { IndexInfo } from '../rule_data_plugin_service/index_info'; +import { Dataset, RuleDataWriterInitializationError } from '..'; +import { resourceInstallerMock } from '../rule_data_plugin_service/resource_installer.mock'; +import { loggingSystemMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { IndexPatternsFetcher } from '../../../../../src/plugins/data/server'; +import { createNoMatchingIndicesError } from '../../../../../src/plugins/data_views/server/fetcher/lib/errors'; + +const mockLogger = loggingSystemMock.create().get(); +const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient().asInternalUser; +const mockResourceInstaller = resourceInstallerMock.create(); + +// Be careful setting this delay too high. Jest tests can time out +const delay = (ms: number = 3000) => new Promise((resolve) => setTimeout(resolve, ms)); + +interface GetRuleDataClientOptionsOpts { + isWriteEnabled?: boolean; + isWriterCacheEnabled?: boolean; + waitUntilReadyForReading?: Promise; + waitUntilReadyForWriting?: Promise; +} +function getRuleDataClientOptions({ + isWriteEnabled, + isWriterCacheEnabled, + waitUntilReadyForReading, + waitUntilReadyForWriting, +}: GetRuleDataClientOptionsOpts): RuleDataClientConstructorOptions { + return { + indexInfo: new IndexInfo({ + indexOptions: { + feature: 'apm', + registrationContext: 'observability.apm', + dataset: 'alerts' as Dataset, + componentTemplateRefs: [], + componentTemplates: [], + }, + kibanaVersion: '8.2.0', + }), + resourceInstaller: mockResourceInstaller, + isWriteEnabled: isWriteEnabled ?? true, + isWriterCacheEnabled: isWriterCacheEnabled ?? true, + waitUntilReadyForReading: + waitUntilReadyForReading ?? Promise.resolve(right(scopedClusterClient) as WaitResult), + waitUntilReadyForWriting: + waitUntilReadyForWriting ?? Promise.resolve(right(scopedClusterClient) as WaitResult), + logger: mockLogger, + }; +} + +describe('RuleDataClient', () => { + const getFieldsForWildcardMock = jest.fn(); + + test('options are set correctly in constructor', () => { + const namespace = 'test'; + const ruleDataClient = new RuleDataClient(getRuleDataClientOptions({})); + expect(ruleDataClient.indexName).toEqual(`.alerts-observability.apm.alerts`); + expect(ruleDataClient.kibanaVersion).toEqual('8.2.0'); + expect(ruleDataClient.indexNameWithNamespace(namespace)).toEqual( + `.alerts-observability.apm.alerts-${namespace}` + ); + expect(ruleDataClient.isWriteEnabled()).toEqual(true); + }); + + describe('getReader()', () => { + beforeAll(() => { + getFieldsForWildcardMock.mockResolvedValue(['foo']); + IndexPatternsFetcher.prototype.getFieldsForWildcard = getFieldsForWildcardMock; + }); + + beforeEach(() => { + getFieldsForWildcardMock.mockClear(); + }); + + afterAll(() => { + getFieldsForWildcardMock.mockRestore(); + }); + + test('waits until cluster client is ready before searching', async () => { + const ruleDataClient = new RuleDataClient( + getRuleDataClientOptions({ + waitUntilReadyForReading: new Promise((resolve) => + setTimeout(resolve, 3000, right(scopedClusterClient)) + ), + }) + ); + + const query = { query: { bool: { filter: { range: { '@timestamp': { gte: 0 } } } } } }; + const reader = ruleDataClient.getReader(); + await reader.search({ + body: query, + }); + + expect(scopedClusterClient.search).toHaveBeenCalledWith({ + body: query, + index: `.alerts-observability.apm.alerts*`, + }); + }); + + test('re-throws error when search throws error', async () => { + scopedClusterClient.search.mockRejectedValueOnce(new Error('something went wrong!')); + const ruleDataClient = new RuleDataClient(getRuleDataClientOptions({})); + const query = { query: { bool: { filter: { range: { '@timestamp': { gte: 0 } } } } } }; + const reader = ruleDataClient.getReader(); + + await expect( + reader.search({ + body: query, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"something went wrong!"`); + }); + + test('waits until cluster client is ready before getDynamicIndexPattern', async () => { + const ruleDataClient = new RuleDataClient( + getRuleDataClientOptions({ + waitUntilReadyForReading: new Promise((resolve) => + setTimeout(resolve, 3000, right(scopedClusterClient)) + ), + }) + ); + + const reader = ruleDataClient.getReader(); + expect(await reader.getDynamicIndexPattern()).toEqual({ + fields: ['foo'], + timeFieldName: '@timestamp', + title: '.alerts-observability.apm.alerts*', + }); + }); + + test('re-throws generic errors from getFieldsForWildcard', async () => { + getFieldsForWildcardMock.mockRejectedValueOnce(new Error('something went wrong!')); + const ruleDataClient = new RuleDataClient(getRuleDataClientOptions({})); + const reader = ruleDataClient.getReader(); + + await expect(reader.getDynamicIndexPattern()).rejects.toThrowErrorMatchingInlineSnapshot( + `"something went wrong!"` + ); + }); + + test('correct handles no_matching_indices errors from getFieldsForWildcard', async () => { + getFieldsForWildcardMock.mockRejectedValueOnce(createNoMatchingIndicesError([])); + const ruleDataClient = new RuleDataClient(getRuleDataClientOptions({})); + const reader = ruleDataClient.getReader(); + + expect(await reader.getDynamicIndexPattern()).toEqual({ + fields: [], + timeFieldName: '@timestamp', + title: '.alerts-observability.apm.alerts*', + }); + }); + + test('handles errors getting cluster client', async () => { + const ruleDataClient = new RuleDataClient( + getRuleDataClientOptions({ + waitUntilReadyForReading: Promise.resolve( + left(new Error('could not get cluster client')) + ), + }) + ); + + const query = { query: { bool: { filter: { range: { '@timestamp': { gte: 0 } } } } } }; + const reader = ruleDataClient.getReader(); + await expect( + reader.search({ + body: query, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"could not get cluster client"`); + + await expect(reader.getDynamicIndexPattern()).rejects.toThrowErrorMatchingInlineSnapshot( + `"could not get cluster client"` + ); + }); + }); + + describe('getWriter()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('bulk()', () => { + test('logs debug and returns undefined if writing is disabled', async () => { + const ruleDataClient = new RuleDataClient( + getRuleDataClientOptions({ isWriteEnabled: false }) + ); + const writer = ruleDataClient.getWriter(); + + // Previously, a delay between calling getWriter() and using a writer function + // would cause an Unhandled promise rejection if there were any errors getting a writer + // Adding this delay in the tests to ensure this does not pop up again. + await delay(); + + expect(await writer.bulk({})).toEqual(undefined); + expect(mockLogger.debug).toHaveBeenCalledWith( + `Writing is disabled, bulk() will not write any data.` + ); + }); + + test('logs error, returns undefined and turns off writing if initialization error', async () => { + const ruleDataClient = new RuleDataClient( + getRuleDataClientOptions({ + waitUntilReadyForWriting: Promise.resolve( + left(new Error('could not get cluster client')) + ), + }) + ); + expect(ruleDataClient.isWriteEnabled()).toBe(true); + const writer = ruleDataClient.getWriter(); + + // Previously, a delay between calling getWriter() and using a writer function + // would cause an Unhandled promise rejection if there were any errors getting a writer + // Adding this delay in the tests to ensure this does not pop up again. + await delay(); + + expect(await writer.bulk({})).toEqual(undefined); + expect(mockLogger.error).toHaveBeenNthCalledWith( + 1, + new RuleDataWriterInitializationError( + 'index', + 'observability.apm', + new Error('could not get cluster client') + ) + ); + expect(mockLogger.error).toHaveBeenNthCalledWith( + 2, + `The writer for the Rule Data Client for the observability.apm registration context was not initialized properly, bulk() cannot continue, and writing will be disabled.` + ); + expect(ruleDataClient.isWriteEnabled()).toBe(false); + }); + + test('logs error, returns undefined and turns off writing if resource installation error', async () => { + const error = new Error('bad resource installation'); + mockResourceInstaller.installAndUpdateNamespaceLevelResources.mockRejectedValueOnce(error); + const ruleDataClient = new RuleDataClient(getRuleDataClientOptions({})); + expect(ruleDataClient.isWriteEnabled()).toBe(true); + const writer = ruleDataClient.getWriter(); + + // Previously, a delay between calling getWriter() and using a writer function + // would cause an Unhandled promise rejection if there were any errors getting a writer + // Adding this delay in the tests to ensure this does not pop up again. + await delay(); + + expect(await writer.bulk({})).toEqual(undefined); + expect(mockLogger.error).toHaveBeenNthCalledWith( + 1, + new RuleDataWriterInitializationError('namespace', 'observability.apm', error) + ); + expect(mockLogger.error).toHaveBeenNthCalledWith( + 2, + `The writer for the Rule Data Client for the observability.apm registration context was not initialized properly, bulk() cannot continue, and writing will be disabled.` + ); + expect(ruleDataClient.isWriteEnabled()).toBe(false); + }); + + test('logs error and returns undefined if bulk function throws error', async () => { + const error = new Error('something went wrong!'); + scopedClusterClient.bulk.mockRejectedValueOnce(error); + const ruleDataClient = new RuleDataClient(getRuleDataClientOptions({})); + expect(ruleDataClient.isWriteEnabled()).toBe(true); + const writer = ruleDataClient.getWriter(); + + // Previously, a delay between calling getWriter() and using a writer function + // would cause an Unhandled promise rejection if there were any errors getting a writer + // Adding this delay in the tests to ensure this does not pop up again. + await delay(); + + expect(await writer.bulk({})).toEqual(undefined); + expect(mockLogger.error).toHaveBeenNthCalledWith(1, error); + expect(ruleDataClient.isWriteEnabled()).toBe(true); + }); + + test('waits until cluster client is ready before calling bulk', async () => { + const ruleDataClient = new RuleDataClient( + getRuleDataClientOptions({ + waitUntilReadyForWriting: new Promise((resolve) => + setTimeout(resolve, 3000, right(scopedClusterClient)) + ), + }) + ); + + const writer = ruleDataClient.getWriter(); + // Previously, a delay between calling getWriter() and using a writer function + // would cause an Unhandled promise rejection if there were any errors getting a writer + // Adding this delay in the tests to ensure this does not pop up again. + await delay(); + + const response = await writer.bulk({}); + + expect(response).toEqual({ + body: {}, + headers: { + 'x-elastic-product': 'Elasticsearch', + }, + meta: {}, + statusCode: 200, + warnings: [], + }); + + expect(scopedClusterClient.bulk).toHaveBeenCalledWith( + { + index: `.alerts-observability.apm.alerts-default`, + require_alias: true, + }, + { meta: true } + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts index 491c9ff22d21..6fe9d43ddbee 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts @@ -18,12 +18,12 @@ import { RuleDataWriterInitializationError, } from '../rule_data_plugin_service/errors'; import { IndexInfo } from '../rule_data_plugin_service/index_info'; -import { ResourceInstaller } from '../rule_data_plugin_service/resource_installer'; +import { IResourceInstaller } from '../rule_data_plugin_service/resource_installer'; import { IRuleDataClient, IRuleDataReader, IRuleDataWriter } from './types'; -interface ConstructorOptions { +export interface RuleDataClientConstructorOptions { indexInfo: IndexInfo; - resourceInstaller: ResourceInstaller; + resourceInstaller: IResourceInstaller; isWriteEnabled: boolean; isWriterCacheEnabled: boolean; waitUntilReadyForReading: Promise; @@ -40,7 +40,7 @@ export class RuleDataClient implements IRuleDataClient { // Writers cached by namespace private writerCache: Map; - constructor(private readonly options: ConstructorOptions) { + constructor(private readonly options: RuleDataClientConstructorOptions) { this.writeEnabled = this.options.isWriteEnabled; this.writerCacheEnabled = this.options.isWriterCacheEnabled; this.writerCache = new Map(); @@ -181,43 +181,46 @@ export class RuleDataClient implements IRuleDataClient { } }; - const prepareForWritingResult = prepareForWriting(); + const prepareForWritingResult = prepareForWriting().catch((error) => { + if (error instanceof RuleDataWriterInitializationError) { + this.options.logger.error(error); + this.options.logger.error( + `The writer for the Rule Data Client for the ${indexInfo.indexOptions.registrationContext} registration context was not initialized properly, bulk() cannot continue, and writing will be disabled.` + ); + turnOffWrite(); + } else if (error instanceof RuleDataWriteDisabledError) { + this.options.logger.debug(`Writing is disabled, bulk() will not write any data.`); + } + return undefined; + }); return { bulk: async (request: estypes.BulkRequest) => { - return prepareForWritingResult - .then((clusterClient) => { + try { + const clusterClient = await prepareForWritingResult; + if (clusterClient) { const requestWithDefaultParameters = { ...request, require_alias: true, index: alias, }; - return clusterClient - .bulk(requestWithDefaultParameters, { meta: true }) - .then((response) => { - if (response.body.errors) { - const error = new errors.ResponseError(response); - this.options.logger.error(error); - } - return response; - }); - }) - .catch((error) => { - if (error instanceof RuleDataWriterInitializationError) { - this.options.logger.error(error); - this.options.logger.error( - `The writer for the Rule Data Client for the ${indexInfo.indexOptions.registrationContext} registration context was not initialized properly, bulk() cannot continue, and writing will be disabled.` - ); - turnOffWrite(); - } else if (error instanceof RuleDataWriteDisabledError) { - this.options.logger.debug(`Writing is disabled, bulk() will not write any data.`); - } else { + const response = await clusterClient.bulk(requestWithDefaultParameters, { meta: true }); + + if (response.body.errors) { + const error = new errors.ResponseError(response); this.options.logger.error(error); } + return response; + } else { + this.options.logger.debug(`Writing is disabled, bulk() will not write any data.`); + } + return undefined; + } catch (error) { + this.options.logger.error(error); - return undefined; - }); + return undefined; + } }, }; } diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.mock.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.mock.ts index 0b3940b93642..6e84f569d481 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.mock.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.mock.ts @@ -5,11 +5,11 @@ * 2.0. */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import { ResourceInstaller } from './resource_installer'; +import { IResourceInstaller, ResourceInstaller } from './resource_installer'; type Schema = PublicMethodsOf; export type ResourceInstallerMock = jest.Mocked; -const createResourceInstallerMock = () => { +const createResourceInstallerMock = (): jest.Mocked => { return { installCommonResources: jest.fn(), installIndexLevelResources: jest.fn(), diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts index 8e7d13b0dc21..ab7bc28af8c1 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts @@ -10,6 +10,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { ElasticsearchClient, Logger } from 'kibana/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; import { DEFAULT_ILM_POLICY_ID, ECS_COMPONENT_TEMPLATE_NAME, @@ -31,6 +32,7 @@ interface ConstructorOptions { disabledRegistrationContexts: string[]; } +export type IResourceInstaller = PublicMethodsOf; export class ResourceInstaller { constructor(private readonly options: ConstructorOptions) {} diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts index 126be5c6d297..b91609151031 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts @@ -14,7 +14,7 @@ import { INDEX_PREFIX } from '../config'; import { IRuleDataClient, RuleDataClient, WaitResult } from '../rule_data_client'; import { IndexInfo } from './index_info'; import { Dataset, IndexOptions } from './index_options'; -import { ResourceInstaller } from './resource_installer'; +import { IResourceInstaller, ResourceInstaller } from './resource_installer'; import { joinWithDash } from './utils'; /** @@ -89,7 +89,7 @@ interface ConstructorOptions { export class RuleDataService implements IRuleDataService { private readonly indicesByBaseName: Map; private readonly indicesByFeatureId: Map; - private readonly resourceInstaller: ResourceInstaller; + private readonly resourceInstaller: IResourceInstaller; private installCommonResources: Promise>; private isInitialized: boolean; diff --git a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts index 9f83930dadc6..2ea4b4c191c0 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts @@ -68,6 +68,7 @@ describe('ruleRegistrySearchStrategyProvider()', () => { }); let getAuthzFilterSpy: jest.SpyInstance; + const searchStrategySearch = jest.fn().mockImplementation(() => of(response)); beforeEach(() => { ruleDataService.findIndicesByFeature.mockImplementation(() => { @@ -80,10 +81,14 @@ describe('ruleRegistrySearchStrategyProvider()', () => { data.search.getSearchStrategy.mockImplementation(() => { return { - search: () => of(response), + search: searchStrategySearch, }; }); + (data.search.searchAsInternalUser.search as jest.Mock).mockImplementation(() => { + return of(response); + }); + getAuthzFilterSpy = jest .spyOn(getAuthzFilterImport, 'getAuthzFilter') .mockImplementation(async () => { @@ -94,7 +99,9 @@ describe('ruleRegistrySearchStrategyProvider()', () => { afterEach(() => { ruleDataService.findIndicesByFeature.mockClear(); data.search.getSearchStrategy.mockClear(); + (data.search.searchAsInternalUser.search as jest.Mock).mockClear(); getAuthzFilterSpy.mockClear(); + searchStrategySearch.mockClear(); }); it('should handle a basic search request', async () => { @@ -199,4 +206,175 @@ describe('ruleRegistrySearchStrategyProvider()', () => { .toPromise(); expect(result).toBe(EMPTY_RESPONSE); }); + + it('should not apply rbac filters for siem', async () => { + const request: RuleRegistrySearchRequest = { + featureIds: [AlertConsumers.SIEM], + }; + const options = {}; + const deps = { + request: {}, + }; + + const strategy = ruleRegistrySearchStrategyProvider( + data, + ruleDataService, + alerting, + logger, + security, + spaces + ); + + await strategy + .search(request, options, deps as unknown as SearchStrategyDependencies) + .toPromise(); + expect(getAuthzFilterSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if requesting multiple featureIds and one is SIEM', async () => { + const request: RuleRegistrySearchRequest = { + featureIds: [AlertConsumers.SIEM, AlertConsumers.LOGS], + }; + const options = {}; + const deps = { + request: {}, + }; + + const strategy = ruleRegistrySearchStrategyProvider( + data, + ruleDataService, + alerting, + logger, + security, + spaces + ); + + let err; + try { + await strategy + .search(request, options, deps as unknown as SearchStrategyDependencies) + .toPromise(); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + }); + + it('should use internal user when requesting o11y alerts as RBAC is applied', async () => { + const request: RuleRegistrySearchRequest = { + featureIds: [AlertConsumers.LOGS], + }; + const options = {}; + const deps = { + request: {}, + }; + + const strategy = ruleRegistrySearchStrategyProvider( + data, + ruleDataService, + alerting, + logger, + security, + spaces + ); + + await strategy + .search(request, options, deps as unknown as SearchStrategyDependencies) + .toPromise(); + expect(data.search.searchAsInternalUser.search).toHaveBeenCalled(); + expect(searchStrategySearch).not.toHaveBeenCalled(); + }); + + it('should use scoped user when requesting siem alerts as RBAC is not applied', async () => { + const request: RuleRegistrySearchRequest = { + featureIds: [AlertConsumers.SIEM], + }; + const options = {}; + const deps = { + request: {}, + }; + + const strategy = ruleRegistrySearchStrategyProvider( + data, + ruleDataService, + alerting, + logger, + security, + spaces + ); + + await strategy + .search(request, options, deps as unknown as SearchStrategyDependencies) + .toPromise(); + expect(data.search.searchAsInternalUser.search as jest.Mock).not.toHaveBeenCalled(); + expect(searchStrategySearch).toHaveBeenCalled(); + }); + + it('should support pagination', async () => { + const request: RuleRegistrySearchRequest = { + featureIds: [AlertConsumers.LOGS], + pagination: { + pageSize: 10, + pageIndex: 0, + }, + }; + const options = {}; + const deps = { + request: {}, + }; + + const strategy = ruleRegistrySearchStrategyProvider( + data, + ruleDataService, + alerting, + logger, + security, + spaces + ); + + await strategy + .search(request, options, deps as unknown as SearchStrategyDependencies) + .toPromise(); + expect((data.search.searchAsInternalUser.search as jest.Mock).mock.calls.length).toBe(1); + expect( + (data.search.searchAsInternalUser.search as jest.Mock).mock.calls[0][0].params.body.size + ).toBe(10); + expect( + (data.search.searchAsInternalUser.search as jest.Mock).mock.calls[0][0].params.body.from + ).toBe(0); + }); + + it('should support sorting', async () => { + const request: RuleRegistrySearchRequest = { + featureIds: [AlertConsumers.LOGS], + sort: [ + { + test: { + order: 'desc', + }, + }, + ], + }; + const options = {}; + const deps = { + request: {}, + }; + + const strategy = ruleRegistrySearchStrategyProvider( + data, + ruleDataService, + alerting, + logger, + security, + spaces + ); + + await strategy + .search(request, options, deps as unknown as SearchStrategyDependencies) + .toPromise(); + expect((data.search.searchAsInternalUser.search as jest.Mock).mock.calls.length).toBe(1); + expect( + (data.search.searchAsInternalUser.search as jest.Mock).mock.calls[0][0].params.body.sort + ).toStrictEqual([{ test: { order: 'desc' } }]); + }); }); diff --git a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts index dd7f392b0a26..8cd0a0d410c9 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts @@ -8,7 +8,7 @@ import { map, mergeMap, catchError } from 'rxjs/operators'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { Logger } from 'src/core/server'; import { from, of } from 'rxjs'; -import { isValidFeatureId } from '@kbn/rule-data-utils'; +import { isValidFeatureId, AlertConsumers } from '@kbn/rule-data-utils'; import { ENHANCED_ES_SEARCH_STRATEGY } from '../../../../../src/plugins/data/common'; import { ISearchStrategy, PluginStart } from '../../../../../src/plugins/data/server'; import { @@ -36,10 +36,22 @@ export const ruleRegistrySearchStrategyProvider = ( security?: SecurityPluginSetup, spaces?: SpacesPluginStart ): ISearchStrategy => { - const es = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY); - + const internalUserEs = data.search.searchAsInternalUser; + const requestUserEs = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY); return { search: (request, options, deps) => { + // SIEM uses RBAC fields in their alerts but also utilizes ES DLS which + // is different than every other solution so we need to special case + // those requests. + let siemRequest = false; + if (request.featureIds.length === 1 && request.featureIds[0] === AlertConsumers.SIEM) { + siemRequest = true; + } else if (request.featureIds.includes(AlertConsumers.SIEM)) { + throw new Error( + 'The ruleRegistryAlertsSearchStrategy search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.' + ); + } + const securityAuditLogger = security?.audit.asScoped(deps.request); const getActiveSpace = async () => spaces?.spacesService.getActiveSpace(deps.request); const getAsync = async () => { @@ -47,10 +59,14 @@ export const ruleRegistrySearchStrategyProvider = ( getActiveSpace(), alerting.getAlertingAuthorizationWithRequest(deps.request), ]); - const authzFilter = (await getAuthzFilter( - authorization, - ReadOperations.Find - )) as estypes.QueryDslQueryContainer; + let authzFilter; + + if (!siemRequest) { + authzFilter = (await getAuthzFilter( + authorization, + ReadOperations.Find + )) as estypes.QueryDslQueryContainer; + } return { space, authzFilter }; }; return from(getAsync()).pipe( @@ -91,22 +107,26 @@ export const ruleRegistrySearchStrategyProvider = ( filter.push(getSpacesFilter(space.id) as estypes.QueryDslQueryContainer); } + const sort = request.sort ?? []; + const query = { bool: { - ...request.query?.bool, filter, }, }; + const size = request.pagination ? request.pagination.pageSize : MAX_ALERT_SEARCH_SIZE; const params = { index: indices, body: { _source: false, fields: ['*'], - size: MAX_ALERT_SEARCH_SIZE, + sort, + size, + from: request.pagination ? request.pagination.pageIndex * size : 0, query, }, }; - return es.search({ ...request, params }, options, deps); + return (siemRequest ? requestUserEs : internalUserEs).search({ params }, options, deps); }), map((response) => { // Do we have to loop over each hit? Yes. @@ -141,9 +161,8 @@ export const ruleRegistrySearchStrategyProvider = ( ); }, cancel: async (id, options, deps) => { - if (es.cancel) { - return es.cancel(id, options, deps); - } + if (internalUserEs.cancel) internalUserEs.cancel(id, options, deps); + if (requestUserEs.cancel) requestUserEs.cancel(id, options, deps); }, }; }; diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index 3593030913ba..dbd911649870 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -112,6 +112,7 @@ function createRule(shouldWriteAlerts: boolean = true) { services: { alertFactory, savedObjectsClient: {} as any, + uiSettingsClient: {} as any, scopedClusterClient: {} as any, shouldWriteAlerts: () => shouldWriteAlerts, shouldStopExecution: () => false, diff --git a/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts b/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts index 3d880988182b..53b688a87845 100644 --- a/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts +++ b/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts @@ -7,6 +7,7 @@ import { elasticsearchServiceMock, savedObjectsClientMock, + uiSettingsServiceMock, } from '../../../../../src/core/server/mocks'; import { AlertExecutorOptions, @@ -69,10 +70,10 @@ export const createDefaultAlertExecutorOptions = < services: { alertFactory: alertsMock.createAlertServices().alertFactory, savedObjectsClient: savedObjectsClientMock.create(), + uiSettingsClient: uiSettingsServiceMock.createClient(), scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), shouldWriteAlerts: () => shouldWriteAlerts, shouldStopExecution: () => false, - search: alertsMock.createAlertServices().search, }, state, updatedBy: null, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/empty/index.ts b/x-pack/plugins/screenshotting/common/errors.ts similarity index 54% rename from x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/empty/index.ts rename to x-pack/plugins/screenshotting/common/errors.ts index a9c930ed0b93..35082c7cc407 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/empty/index.ts +++ b/x-pack/plugins/screenshotting/common/errors.ts @@ -5,5 +5,10 @@ * 2.0. */ -export { PolicyEventFiltersEmptyUnassigned } from './policy_event_filters_empty_unassigned'; -export { PolicyEventFiltersEmptyUnexisting } from './policy_event_filters_empty_unexisting'; +/* eslint-disable max-classes-per-file */ + +export class FailedToSpawnBrowserError extends Error {} + +export class BrowserClosedUnexpectedly extends Error {} + +export class FailedToCaptureScreenshot extends Error {} diff --git a/x-pack/plugins/screenshotting/common/index.ts b/x-pack/plugins/screenshotting/common/index.ts index 1492f3f945ab..d514eb507bb7 100644 --- a/x-pack/plugins/screenshotting/common/index.ts +++ b/x-pack/plugins/screenshotting/common/index.ts @@ -7,3 +7,6 @@ export type { LayoutParams } from './layout'; export { LayoutTypes } from './layout'; + +import * as errors from './errors'; +export { errors }; diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts index 4d1e0566f994..27bb4e79374f 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts @@ -29,6 +29,7 @@ import { import type { Logger } from 'src/core/server'; import type { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server'; import { ConfigType } from '../../../config'; +import { errors } from '../../../../common'; import { getChromiumDisconnectedError } from '../'; import { safeChildProcess } from '../../safe_child_process'; import { HeadlessChromiumDriver } from '../driver'; @@ -101,7 +102,7 @@ const DEFAULT_ARGS = [ const DIAGNOSTIC_TIME = 5 * 1000; export class HeadlessChromiumDriverFactory { - private userDataDir = fs.mkdtempSync(path.join(getDataPath(), 'chromium-')); + private userDataDir: string; type = 'chromium'; constructor( @@ -111,6 +112,10 @@ export class HeadlessChromiumDriverFactory { private binaryPath: string, private basePath: string ) { + const dataDir = getDataPath(); + fs.mkdirSync(dataDir, { recursive: true }); + this.userDataDir = fs.mkdtempSync(path.join(dataDir, 'chromium-')); + if (this.config.browser.chromium.disableSandbox) { logger.warn(`Enabling the Chromium sandbox provides an additional layer of protection.`); } @@ -141,7 +146,6 @@ export class HeadlessChromiumDriverFactory { logger.debug(`Chromium launch args set to: ${chromiumArgs}`); let browser: Browser | undefined; - try { browser = await puppeteer.launch({ pipe: !this.config.browser.chromium.inspect, @@ -162,7 +166,9 @@ export class HeadlessChromiumDriverFactory { }, }); } catch (err) { - observer.error(new Error(`Error spawning Chromium browser! ${err}`)); + observer.error( + new errors.FailedToSpawnBrowserError(`Error spawning Chromium browser! ${err}`) + ); return; } diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/index.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/index.ts index bc32e719e0f7..70fb9caa4512 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/index.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/index.ts @@ -5,8 +5,12 @@ * 2.0. */ +import { errors } from '../../../common'; + export const getChromiumDisconnectedError = () => - new Error('Browser was closed unexpectedly! Check the server logs for more info.'); + new errors.BrowserClosedUnexpectedly( + 'Browser was closed unexpectedly! Check the server logs for more info.' + ); export { HeadlessChromiumDriver } from './driver'; export type { Context } from './driver'; diff --git a/x-pack/plugins/screenshotting/server/browsers/extract/extract.ts b/x-pack/plugins/screenshotting/server/browsers/extract/extract.ts index ccdfb1eaad5c..0fab91fc9861 100644 --- a/x-pack/plugins/screenshotting/server/browsers/extract/extract.ts +++ b/x-pack/plugins/screenshotting/server/browsers/extract/extract.ts @@ -18,7 +18,7 @@ export async function extract(archivePath: string, targetPath: string) { unpacker = unzip; break; default: - throw new ExtractError(`Unable to unpack filetype: ${fileType}`); + throw new ExtractError(new Error(`Unable to unpack filetype: ${fileType}`)); } await unpacker(archivePath, targetPath); diff --git a/x-pack/plugins/screenshotting/server/browsers/extract/extract_error.ts b/x-pack/plugins/screenshotting/server/browsers/extract/extract_error.ts index 04a915c2afc9..2e8b85b21715 100644 --- a/x-pack/plugins/screenshotting/server/browsers/extract/extract_error.ts +++ b/x-pack/plugins/screenshotting/server/browsers/extract/extract_error.ts @@ -6,9 +6,9 @@ */ export class ExtractError extends Error { - public readonly cause: string; + public readonly cause: Error; - constructor(cause: string, message = 'Failed to extract the browser archive') { + constructor(cause: Error, message = 'Failed to extract the browser archive') { super(message); this.message = message; this.name = this.constructor.name; diff --git a/x-pack/plugins/screenshotting/server/layouts/preserve_layout.css b/x-pack/plugins/screenshotting/server/layouts/preserve_layout.css index 60513c417165..7b692881d5bd 100644 --- a/x-pack/plugins/screenshotting/server/layouts/preserve_layout.css +++ b/x-pack/plugins/screenshotting/server/layouts/preserve_layout.css @@ -1,12 +1,5 @@ -/* - ****** - ****** This is a collection of CSS overrides that make Kibana look better for - ****** generating PDF reports with headless browser - ****** - */ - /** - * global + * Global utilities */ /* elements can hide themselves when shared */ @@ -14,26 +7,9 @@ display: none !important; } -/* hide unusable controls */ -kbn-top-nav, -filter-bar, -.kbnTopNavMenu__wrapper, -::-webkit-scrollbar, -.euiNavDrawer { - display: none !important; -} - /** - * Discover Tweaks - */ - -/* hide unusable controls */ -discover-app .dscTimechart, -discover-app .dscSidebar__container, -discover-app .dscCollapsibleSidebar__collapseButton, -discover-app .discover-table-footer { - display: none; -} +* Global overrides +*/ /** * The global banner (e.g. "Help us improve Elastic...") should not print. @@ -41,53 +17,3 @@ discover-app .discover-table-footer { #globalBannerList { display: none; } - -/** - * Visualize Editor Tweaks - */ - -/* hide unusable controls -* !important is required to override resizable panel inline display */ -.visEditor__content .visEditor--default > :not(.visEditor__visualization__wrapper) { - display: none !important; -} - -/** THIS IS FOR TSVB UNTIL REFACTOR **/ -.tvbEditorVisualization { - position: static !important; -} -.visualize .tvbVisTimeSeries__legendToggle, -.tvbEditor--hideForReporting { - /* all non-content rows in interface */ - display: none; -} -/** END TSVB BAD BAD HACKS **/ - -/* remove left padding from visualizations so that map lines up with .leaflet-container and -* setting the position to be fixed and to take up the entire screen, because some zoom levels/viewports -* are triggering the media breakpoints that cause the .visEditor__canvas to take up more room than the viewport */ -.visEditor .visEditor__canvas { - padding-left: 0px; - position: fixed; - width: 100%; - height: 100%; - top: 0; - left: 0; -} - -/** - * Visualization tweaks - */ - -/* hide unusable controls */ -.visualize .visLegend__toggle, -.visualize .kbnAggTable__controls/* export raw, export formatted, etc. */ , -.visualize .leaflet-container .leaflet-top.leaflet-left/* tilemap controls */ , -.visualize paginate-controls /* page numbers */ { - display: none; -} - -/* Ensure the min-height of the small breakpoint isn't used */ -.vis-editor visualization { - min-height: 0 !important; -} diff --git a/x-pack/plugins/screenshotting/server/screenshots/observable.ts b/x-pack/plugins/screenshotting/server/screenshots/observable.ts index a743c206ef98..1c78d397a741 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/observable.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/observable.ts @@ -9,6 +9,7 @@ import type { Transaction } from 'elastic-apm-node'; import { defer, forkJoin, throwError, Observable } from 'rxjs'; import { catchError, mergeMap, switchMapTo, timeoutWith } from 'rxjs/operators'; import type { Headers, Logger } from 'src/core/server'; +import { errors } from '../../common'; import type { Context, HeadlessChromiumDriver } from '../browsers'; import { getChromiumDisconnectedError, DEFAULT_VIEWPORT } from '../browsers'; import type { Layout } from '../layouts'; @@ -244,7 +245,12 @@ export class ScreenshotObservableHandler { const elements = data.elementsPositionAndAttributes ?? getDefaultElementPosition(this.layout.getViewport(1)); - const screenshots = await getScreenshots(this.driver, this.logger, elements); + let screenshots: Screenshot[] = []; + try { + screenshots = await getScreenshots(this.driver, this.logger, elements); + } catch (e) { + throw new errors.FailedToCaptureScreenshot(e.message); + } const { timeRange, error: setupError } = data; return { diff --git a/x-pack/plugins/searchprofiler/public/application/components/percentage_badge/percentage_badge.tsx b/x-pack/plugins/searchprofiler/public/application/components/percentage_badge/percentage_badge.tsx index 0ee7aeddb7eb..fa36da1b8fed 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/percentage_badge/percentage_badge.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/percentage_badge/percentage_badge.tsx @@ -25,9 +25,7 @@ export const PercentageBadge = ({ timePercentage, label, valueType = 'percent' } return ( { expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(1); expect(mockElasticsearchClient.bulk).not.toHaveBeenCalled(); expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.indices.refresh).not.toHaveBeenCalled(); // since the search failed, we don't refresh the index }); it('throws if bulk delete call to Elasticsearch fails', async () => { @@ -227,7 +228,20 @@ describe('Session index', () => { expect(mockElasticsearchClient.openPointInTime).toHaveBeenCalledTimes(1); expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(1); expect(mockElasticsearchClient.bulk).toHaveBeenCalledTimes(1); - expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); // since we attempted to delete sessions, we still refresh the index + }); + + it('does not throw if index refresh call to Elasticsearch fails', async () => { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.indices.refresh.mockRejectedValue(failureReason); + + await sessionIndex.cleanUp(); + expect(mockElasticsearchClient.openPointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); // since we attempted to delete sessions, we still refresh the index }); it('when neither `lifespan` nor `idleTimeout` is configured', async () => { @@ -388,6 +402,7 @@ describe('Session index', () => { } ); expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.indices.refresh).toHaveBeenCalledTimes(1); }); it('when only `idleTimeout` is configured', async () => { @@ -474,6 +489,7 @@ describe('Session index', () => { } ); expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.indices.refresh).toHaveBeenCalledTimes(1); }); it('when both `lifespan` and `idleTimeout` are configured', async () => { @@ -570,6 +586,7 @@ describe('Session index', () => { } ); expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.indices.refresh).toHaveBeenCalledTimes(1); }); it('when both `lifespan` and `idleTimeout` are configured and multiple providers are enabled', async () => { @@ -714,6 +731,7 @@ describe('Session index', () => { } ); expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.indices.refresh).toHaveBeenCalledTimes(1); }); it('should clean up sessions in batches of 10,000', async () => { @@ -729,6 +747,7 @@ describe('Session index', () => { expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(2); expect(mockElasticsearchClient.bulk).toHaveBeenCalledTimes(2); expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.indices.refresh).toHaveBeenCalledTimes(1); }); it('should limit number of batches to 10', async () => { @@ -742,6 +761,7 @@ describe('Session index', () => { expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(10); expect(mockElasticsearchClient.bulk).toHaveBeenCalledTimes(10); expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.indices.refresh).toHaveBeenCalledTimes(1); }); it('should log audit event', async () => { diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts index 8a69b9b7f004..2a677f6ecf56 100644 --- a/x-pack/plugins/security/server/session_management/session_index.ts +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -444,20 +444,21 @@ export class SessionIndex { * Trigger a removal of any outdated session values. */ async cleanUp() { - this.options.logger.debug(`Running cleanup routine.`); + const { auditLogger, elasticsearchClient, logger } = this.options; + logger.debug(`Running cleanup routine.`); + let error: Error | undefined; + let indexNeedsRefresh = false; try { for await (const sessionValues of this.getSessionValuesInBatches()) { const operations: Array>> = []; sessionValues.forEach(({ _id, _source }) => { const { usernameHash, provider } = _source!; - this.options.auditLogger.log( - sessionCleanupEvent({ sessionId: _id, usernameHash, provider }) - ); + auditLogger.log(sessionCleanupEvent({ sessionId: _id, usernameHash, provider })); operations.push({ delete: { _id } }); }); if (operations.length > 0) { - const bulkResponse = await this.options.elasticsearchClient.bulk( + const bulkResponse = await elasticsearchClient.bulk( { index: this.indexName, operations, @@ -471,24 +472,40 @@ export class SessionIndex { 0 ); if (errorCount < bulkResponse.items.length) { - this.options.logger.warn( + logger.warn( `Failed to clean up ${errorCount} of ${bulkResponse.items.length} invalid or expired sessions. The remaining sessions were cleaned up successfully.` ); + indexNeedsRefresh = true; } else { - this.options.logger.error( + logger.error( `Failed to clean up ${bulkResponse.items.length} invalid or expired sessions.` ); } } else { - this.options.logger.debug( - `Cleaned up ${bulkResponse.items.length} invalid or expired sessions.` - ); + logger.debug(`Cleaned up ${bulkResponse.items.length} invalid or expired sessions.`); + indexNeedsRefresh = true; } } } } catch (err) { - this.options.logger.error(`Failed to clean up sessions: ${err.message}`); - throw err; + logger.error(`Failed to clean up sessions: ${err.message}`); + error = err; + } + + if (indexNeedsRefresh) { + // Only refresh the index if we have actually deleted one or more sessions. The index will auto-refresh eventually anyway, this just + // ensures that searches after the cleanup process are accurate, and this only impacts integration tests. + try { + await elasticsearchClient.indices.refresh({ index: this.indexName }); + logger.debug(`Refreshed session index.`); + } catch (err) { + logger.error(`Failed to refresh session index: ${err.message}`); + } + } + + if (error) { + // If we couldn't fetch or delete sessions, throw an error so the task will be retried. + throw error; } } diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index cad298510953..fa5481ca7f77 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -100,6 +100,8 @@ export enum SecurityPageName { hostsExternalAlerts = 'hosts-external_alerts', hostsRisk = 'hosts-risk', users = 'users', + usersAnomalies = 'users-anomalies', + usersRisk = 'users-risk', investigate = 'investigate', network = 'network', networkAnomalies = 'network-anomalies', @@ -170,6 +172,9 @@ export const DEFAULT_INDEX_PATTERN = [ /** This Kibana Advanced Setting enables the `Security news` feed widget */ export const ENABLE_NEWS_FEED_SETTING = 'securitySolution:enableNewsFeed' as const; +/** This Kibana Advanced Setting enables the warnings for CCS read permissions */ +export const ENABLE_CCS_READ_WARNING_SETTING = 'securitySolution:enableCcsWarning' as const; + /** This Kibana Advanced Setting sets the auto refresh interval for the detections all rules table */ export const DEFAULT_RULES_TABLE_REFRESH_SETTING = 'securitySolution:rulesTableRefresh' as const; @@ -364,6 +369,8 @@ export const ELASTIC_NAME = 'estc' as const; export const RISKY_HOSTS_INDEX_PREFIX = 'ml_host_risk_score_' as const; +export const RISKY_USERS_INDEX_PREFIX = 'ml_user_risk_score_' as const; + export const TRANSFORM_STATES = { ABORTING: 'aborting', FAILED: 'failed', diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts index 78ac7d6f1bbe..04d482c84713 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts @@ -428,9 +428,11 @@ export interface RulePreviewLogs { errors: string[]; warnings: string[]; startedAt?: string; + duration: number; } export interface PreviewResponse { previewId: string | undefined; logs: RulePreviewLogs[] | undefined; + isAborted: boolean | undefined; } diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts index 6fbe54578f46..6ddb2fc19ef0 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts @@ -118,7 +118,7 @@ export class BaseDataGenerator { } /** generate random OS family value */ - protected randomOSFamily(): string { + public randomOSFamily(): string { return this.randomChoice(OS_FAMILY); } @@ -133,7 +133,7 @@ export class BaseDataGenerator { } /** Generate a random number up to the max provided */ - protected randomN(max: number): number { + public randomN(max: number): number { return Math.floor(this.random() * max); } diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/event_filter_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/event_filter_generator.ts index daf96a314964..99683bcd1186 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/event_filter_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/event_filter_generator.ts @@ -5,7 +5,10 @@ * 2.0. */ -import type { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { + CreateExceptionListItemSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants'; import { BaseDataGenerator } from './base_data_generator'; import { ExceptionsListItemGenerator } from './exceptions_list_item_generator'; @@ -13,7 +16,7 @@ import { BY_POLICY_ARTIFACT_TAG_PREFIX, GLOBAL_ARTIFACT_TAG } from '../service/a const EFFECT_SCOPE_TYPES = [BY_POLICY_ARTIFACT_TAG_PREFIX, GLOBAL_ARTIFACT_TAG]; export class EventFilterGenerator extends BaseDataGenerator { - generate(): CreateExceptionListItemSchema { + generate(overrides: Partial = {}): CreateExceptionListItemSchema { const eventFilterGenerator = new ExceptionsListItemGenerator(); const eventFilterData: CreateExceptionListItemSchema = eventFilterGenerator.generateEventFilter( { @@ -29,6 +32,7 @@ export class EventFilterGenerator extends BaseDataGenerator = {}): ExceptionListItemSchema { - return this.generate({ - name: `Blocklist ${this.randomString(5)}`, - list_id: ENDPOINT_BLOCKLISTS_LIST_ID, - item_id: `generator_endpoint_blocklist_${this.randomUUID()}`, - os_types: ['windows'], - entries: [ - this.randomChoice([ + const os = this.randomOSFamily() as ExceptionListItemSchema['os_types'][number]; + const entriesList: CreateExceptionListItemSchema['entries'] = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: os === 'windows' ? 'C:\\Fol*\\file.*' : '/usr/*/*.dmg', + }, + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: os === 'windows' ? 'C:\\Fol*\\file.exe' : '/usr/*/app.dmg', + }, + { + field: 'process.executable.caseless', + value: + os === 'windows' + ? ['C:\\some\\path', 'C:\\some\\other\\path', 'C:\\yet\\another\\path'] + : ['/some/path', 'some/other/path', 'yet/another/path'], + type: 'match_any', + operator: 'included', + }, + { + field: 'process.hash.sha256', + value: [ + 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', + '2C26B46B68FFC68FF99B453C1D30413413422D706483BFA0F98A5E886266E7AE', + 'FCDE2B2EDBA56BF408601FB721FE9B5C338D10EE429EA04FAE5511B68FBF8FB9', + ], + type: 'match_any', + operator: 'included', + }, + { + field: 'process.Ext.code_signature', + entries: [ { - field: 'process.executable.caseless', - value: ['/some/path', 'some/other/path', 'yet/another/path'], - type: 'match_any', + field: 'trusted', + value: 'true', + type: 'match', operator: 'included', }, { - field: 'process.hash.sha256', - value: [ - 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', - '2C26B46B68FFC68FF99B453C1D30413413422D706483BFA0F98A5E886266E7AE', - 'FCDE2B2EDBA56BF408601FB721FE9B5C338D10EE429EA04FAE5511B68FBF8FB9', - ], + field: 'subject_name', + value: + os === 'windows' + ? ['notsus.app', 'verynotsus.app', 'superlegit.app'] + : ['notsus.exe', 'verynotsus.exe', 'superlegit.exe'], type: 'match_any', operator: 'included', }, - { - field: 'process.Ext.code_signature', - entries: [ - { - field: 'trusted', - value: 'true', - type: 'match', - operator: 'included', - }, - { - field: 'subject_name', - value: ['notsus.exe', 'verynotsus.exe', 'superlegit.exe'], - type: 'match_any', - operator: 'included', - }, - ], - type: 'nested', - }, - ]), - ], + ], + type: 'nested', + }, + ]; + + return this.generate({ + name: `Blocklist ${this.randomString(5)}`, + list_id: ENDPOINT_BLOCKLISTS_LIST_ID, + item_id: `generator_endpoint_blocklist_${this.seededUUIDv4()}`, + tags: [this.randomChoice([BY_POLICY_ARTIFACT_TAG_PREFIX, GLOBAL_ARTIFACT_TAG])], + os_types: [os], + entries: [entriesList[this.randomN(5)]], ...overrides, }); } diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts index 22d81ba4a345..abe29f62dfd5 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts @@ -57,7 +57,7 @@ export class FleetActionGenerator extends BaseDataGenerator { agent_id: this.randomUUID(), started_at: this.randomPastDate(), completed_at: timeStamp.toISOString(), - error: 'some error happen', + error: 'some error happened', '@timestamp': timeStamp.toISOString(), }, overrides diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/trusted_app_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/trusted_app_generator.ts index 27c4abc65610..216380560649 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/trusted_app_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/trusted_app_generator.ts @@ -7,8 +7,9 @@ import { DeepPartial } from 'utility-types'; import { merge } from 'lodash'; +import { ConditionEntryField } from '@kbn/securitysolution-utils'; import { BaseDataGenerator } from './base_data_generator'; -import { ConditionEntryField, EffectScope, NewTrustedApp, TrustedApp } from '../types'; +import { EffectScope, NewTrustedApp, TrustedApp } from '../types'; const TRUSTED_APP_NAMES = [ 'Symantec Endpoint Security', diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_actions.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_actions.ts deleted file mode 100644 index 3c8d23e37515..000000000000 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_actions.ts +++ /dev/null @@ -1,209 +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 { Client } from '@elastic/elasticsearch'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { HostMetadata, LogsEndpointAction, LogsEndpointActionResponse } from '../types'; -import { EndpointActionGenerator } from '../data_generators/endpoint_action_generator'; -import { wrapErrorAndRejectPromise } from './utils'; -import { ENDPOINT_ACTIONS_INDEX, ENDPOINT_ACTION_RESPONSES_INDEX } from '../constants'; - -const defaultEndpointActionGenerator = new EndpointActionGenerator(); - -export interface IndexedEndpointActionsForHostResponse { - endpointActions: LogsEndpointAction[]; - endpointActionResponses: LogsEndpointActionResponse[]; - endpointActionsIndex: string; - endpointActionResponsesIndex: string; -} - -/** - * Indexes a random number of Endpoint Actions for a given host - * - * @param esClient - * @param endpointHost - * @param [endpointActionGenerator] - */ -export const indexEndpointActionsForHost = async ( - esClient: Client, - endpointHost: HostMetadata, - endpointActionGenerator: EndpointActionGenerator = defaultEndpointActionGenerator -): Promise => { - const agentId = endpointHost.elastic.agent.id; - const total = endpointActionGenerator.randomN(5); - const response: IndexedEndpointActionsForHostResponse = { - endpointActions: [], - endpointActionResponses: [], - endpointActionsIndex: ENDPOINT_ACTIONS_INDEX, - endpointActionResponsesIndex: ENDPOINT_ACTION_RESPONSES_INDEX, - }; - - for (let i = 0; i < total; i++) { - // create an action - const action = endpointActionGenerator.generate({ - EndpointActions: { - data: { comment: 'data generator: this host is same as bad' }, - }, - }); - - action.agent.id = [agentId]; - - await esClient - .index({ - index: ENDPOINT_ACTIONS_INDEX, - body: action, - }) - .catch(wrapErrorAndRejectPromise); - - // Create an action response for the above - const actionResponse = endpointActionGenerator.generateResponse({ - agent: { id: agentId }, - EndpointActions: { - action_id: action.EndpointActions.action_id, - data: action.EndpointActions.data, - }, - }); - - await esClient - .index({ - index: ENDPOINT_ACTION_RESPONSES_INDEX, - body: actionResponse, - }) - .catch(wrapErrorAndRejectPromise); - - response.endpointActions.push(action); - response.endpointActionResponses.push(actionResponse); - } - - // Add edge cases (maybe) - if (endpointActionGenerator.randomFloat() < 0.3) { - const randomFloat = endpointActionGenerator.randomFloat(); - - // 60% of the time just add either an Isolate -OR- an UnIsolate action - if (randomFloat < 0.6) { - let action: LogsEndpointAction; - - if (randomFloat < 0.3) { - // add a pending isolation - action = endpointActionGenerator.generateIsolateAction({ - '@timestamp': new Date().toISOString(), - }); - } else { - // add a pending UN-isolation - action = endpointActionGenerator.generateUnIsolateAction({ - '@timestamp': new Date().toISOString(), - }); - } - - action.agent.id = [agentId]; - - await esClient - .index({ - index: ENDPOINT_ACTIONS_INDEX, - body: action, - }) - .catch(wrapErrorAndRejectPromise); - - response.endpointActions.push(action); - } else { - // Else (40% of the time) add a pending isolate AND pending un-isolate - const action1 = endpointActionGenerator.generateIsolateAction({ - '@timestamp': new Date().toISOString(), - }); - const action2 = endpointActionGenerator.generateUnIsolateAction({ - '@timestamp': new Date().toISOString(), - }); - - action1.agent.id = [agentId]; - action2.agent.id = [agentId]; - - await Promise.all([ - esClient - .index({ - index: ENDPOINT_ACTIONS_INDEX, - body: action1, - }) - .catch(wrapErrorAndRejectPromise), - esClient - .index({ - index: ENDPOINT_ACTIONS_INDEX, - body: action2, - }) - .catch(wrapErrorAndRejectPromise), - ]); - - response.endpointActions.push(action1, action2); - } - } - - return response; -}; - -export interface DeleteIndexedEndpointActionsResponse { - endpointActionRequests: estypes.DeleteByQueryResponse | undefined; - endpointActionResponses: estypes.DeleteByQueryResponse | undefined; -} - -export const deleteIndexedEndpointActions = async ( - esClient: Client, - indexedData: IndexedEndpointActionsForHostResponse -): Promise => { - const response: DeleteIndexedEndpointActionsResponse = { - endpointActionRequests: undefined, - endpointActionResponses: undefined, - }; - - if (indexedData.endpointActions.length) { - response.endpointActionRequests = await esClient - .deleteByQuery({ - index: `${indexedData.endpointActionsIndex}-*`, - wait_for_completion: true, - body: { - query: { - bool: { - filter: [ - { - terms: { - action_id: indexedData.endpointActions.map( - (action) => action.EndpointActions.action_id - ), - }, - }, - ], - }, - }, - }, - }) - .catch(wrapErrorAndRejectPromise); - } - - if (indexedData.endpointActionResponses) { - response.endpointActionResponses = await esClient - .deleteByQuery({ - index: `${indexedData.endpointActionResponsesIndex}-*`, - wait_for_completion: true, - body: { - query: { - bool: { - filter: [ - { - terms: { - action_id: indexedData.endpointActionResponses.map( - (action) => action.EndpointActions.action_id - ), - }, - }, - ], - }, - }, - }, - }) - .catch(wrapErrorAndRejectPromise); - } - - return response; -}; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_fleet_actions.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_fleet_actions.ts new file mode 100644 index 000000000000..dc0f2b29ec94 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_fleet_actions.ts @@ -0,0 +1,343 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Client } from '@elastic/elasticsearch'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { + EndpointAction, + EndpointActionResponse, + HostMetadata, + LogsEndpointAction, + LogsEndpointActionResponse, +} from '../types'; +import { ENDPOINT_ACTIONS_INDEX, ENDPOINT_ACTION_RESPONSES_INDEX } from '../constants'; +import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../../fleet/common'; +import { FleetActionGenerator } from '../data_generators/fleet_action_generator'; +import { wrapErrorAndRejectPromise } from './utils'; + +const defaultFleetActionGenerator = new FleetActionGenerator(); + +export interface IndexedEndpointAndFleetActionsForHostResponse { + actions: EndpointAction[]; + actionResponses: EndpointActionResponse[]; + actionsIndex: string; + responsesIndex: string; + endpointActions: LogsEndpointAction[]; + endpointActionResponses: LogsEndpointActionResponse[]; + endpointActionsIndex: string; + endpointActionResponsesIndex: string; +} + +/** + * Indexes a random number of Endpoint (via Fleet) Actions for a given host + * (NOTE: ensure that fleet is setup first before calling this loading function) + * + * @param esClient + * @param endpointHost + * @param [fleetActionGenerator] + */ +export const indexEndpointAndFleetActionsForHost = async ( + esClient: Client, + endpointHost: HostMetadata, + fleetActionGenerator: FleetActionGenerator = defaultFleetActionGenerator +): Promise => { + const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } }; + const agentId = endpointHost.elastic.agent.id; + const total = fleetActionGenerator.randomN(5); + const response: IndexedEndpointAndFleetActionsForHostResponse = { + actions: [], + actionResponses: [], + endpointActions: [], + endpointActionResponses: [], + actionsIndex: AGENT_ACTIONS_INDEX, + responsesIndex: AGENT_ACTIONS_RESULTS_INDEX, + endpointActionsIndex: ENDPOINT_ACTIONS_INDEX, + endpointActionResponsesIndex: ENDPOINT_ACTION_RESPONSES_INDEX, + }; + + for (let i = 0; i < total; i++) { + // create an action + const action = fleetActionGenerator.generate({ + data: { comment: 'data generator: this host is bad' }, + }); + + action.agents = [agentId]; + const indexFleetActions = esClient + .index( + { + index: AGENT_ACTIONS_INDEX, + body: action, + }, + ES_INDEX_OPTIONS + ) + .catch(wrapErrorAndRejectPromise); + + if (fleetActionGenerator.randomFloat() < 0.4) { + const endpointActionsBody = { + EndpointActions: { + ...action, + '@timestamp': undefined, + user_id: undefined, + }, + agent: { + id: [agentId], + }, + '@timestamp': action['@timestamp'], + user: { + id: action.user_id, + }, + }; + + await Promise.all([ + indexFleetActions, + esClient + .index({ + index: ENDPOINT_ACTIONS_INDEX, + body: endpointActionsBody, + }) + .catch(wrapErrorAndRejectPromise), + ]); + } else { + await indexFleetActions; + } + + const randomFloat = fleetActionGenerator.randomFloat(); + // Create an action response for the above + const actionResponse = fleetActionGenerator.generateResponse({ + action_id: action.action_id, + agent_id: agentId, + action_response: { + endpoint: { + // add ack to 2/5th of fleet response + ack: randomFloat < 0.4 ? true : undefined, + }, + }, + // error for 3/10th of responses + error: randomFloat < 0.3 ? 'some error happened' : undefined, + }); + + const indexFleetResponses = esClient + .index( + { + index: AGENT_ACTIONS_RESULTS_INDEX, + body: actionResponse, + }, + ES_INDEX_OPTIONS + ) + .catch(wrapErrorAndRejectPromise); + + if (randomFloat < 0.4) { + const endpointActionResponseBody = { + EndpointActions: { + ...actionResponse, + data: actionResponse.action_data, + '@timestamp': undefined, + action_data: undefined, + agent_id: undefined, + error: undefined, + }, + agent: { + id: agentId, + }, + // error for 3/10th of responses + error: + randomFloat < 0.3 + ? undefined + : { + message: actionResponse.error, + }, + '@timestamp': actionResponse['@timestamp'], + }; + + await Promise.all([ + indexFleetResponses, + esClient + .index({ + index: ENDPOINT_ACTION_RESPONSES_INDEX, + body: endpointActionResponseBody, + }) + .catch(wrapErrorAndRejectPromise), + ]); + } else { + await indexFleetResponses; + } + + response.actions.push(action); + response.actionResponses.push(actionResponse); + } + + // Add edge cases (maybe) + if (fleetActionGenerator.randomFloat() < 0.3) { + const randomFloat = fleetActionGenerator.randomFloat(); + + // 60% of the time just add either an Isolate -OR- an UnIsolate action + if (randomFloat < 0.6) { + let action: EndpointAction; + + if (randomFloat < 0.3) { + // add a pending isolation + action = fleetActionGenerator.generateIsolateAction({ + '@timestamp': new Date().toISOString(), + }); + } else { + // add a pending UN-isolation + action = fleetActionGenerator.generateUnIsolateAction({ + '@timestamp': new Date().toISOString(), + }); + } + + action.agents = [agentId]; + + await esClient + .index( + { + index: AGENT_ACTIONS_INDEX, + body: action, + }, + ES_INDEX_OPTIONS + ) + .catch(wrapErrorAndRejectPromise); + + response.actions.push(action); + } else { + // Else (40% of the time) add a pending isolate AND pending un-isolate + const action1 = fleetActionGenerator.generateIsolateAction({ + '@timestamp': new Date().toISOString(), + }); + const action2 = fleetActionGenerator.generateUnIsolateAction({ + '@timestamp': new Date().toISOString(), + }); + + action1.agents = [agentId]; + action2.agents = [agentId]; + + await Promise.all([ + esClient + .index( + { + index: AGENT_ACTIONS_INDEX, + body: action1, + }, + ES_INDEX_OPTIONS + ) + .catch(wrapErrorAndRejectPromise), + esClient + .index( + { + index: AGENT_ACTIONS_INDEX, + body: action2, + }, + ES_INDEX_OPTIONS + ) + .catch(wrapErrorAndRejectPromise), + ]); + + response.actions.push(action1, action2); + } + } + + return response; +}; + +export interface DeleteIndexedEndpointFleetActionsResponse { + actions: estypes.DeleteByQueryResponse | undefined; + responses: estypes.DeleteByQueryResponse | undefined; + endpointActionRequests: estypes.DeleteByQueryResponse | undefined; + endpointActionResponses: estypes.DeleteByQueryResponse | undefined; +} + +export const deleteIndexedEndpointAndFleetActions = async ( + esClient: Client, + indexedData: IndexedEndpointAndFleetActionsForHostResponse +): Promise => { + const response: DeleteIndexedEndpointFleetActionsResponse = { + actions: undefined, + responses: undefined, + endpointActionRequests: undefined, + endpointActionResponses: undefined, + }; + + if (indexedData.actions.length) { + [response.actions, response.endpointActionRequests] = await Promise.all([ + esClient + .deleteByQuery({ + index: `${indexedData.actionsIndex}-*`, + wait_for_completion: true, + body: { + query: { + bool: { + filter: [ + { terms: { action_id: indexedData.actions.map((action) => action.action_id) } }, + ], + }, + }, + }, + }) + .catch(wrapErrorAndRejectPromise), + esClient + .deleteByQuery({ + index: `${indexedData.endpointActionsIndex}-*`, + wait_for_completion: true, + body: { + query: { + bool: { + filter: [ + { terms: { action_id: indexedData.actions.map((action) => action.action_id) } }, + ], + }, + }, + }, + }) + .catch(wrapErrorAndRejectPromise), + ]); + } + + if (indexedData.actionResponses) { + [response.responses, response.endpointActionResponses] = await Promise.all([ + esClient + .deleteByQuery({ + index: `${indexedData.responsesIndex}-*`, + wait_for_completion: true, + body: { + query: { + bool: { + filter: [ + { + terms: { + action_id: indexedData.actionResponses.map((action) => action.action_id), + }, + }, + ], + }, + }, + }, + }) + .catch(wrapErrorAndRejectPromise), + esClient + .deleteByQuery({ + index: `${indexedData.endpointActionResponsesIndex}-*`, + wait_for_completion: true, + body: { + query: { + bool: { + filter: [ + { + terms: { + action_id: indexedData.actionResponses.map((action) => action.action_id), + }, + }, + ], + }, + }, + }, + }) + .catch(wrapErrorAndRejectPromise), + ]); + } + + return response; +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts index be26f8496c5e..d2c42a1b4009 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts @@ -22,17 +22,11 @@ import { indexFleetAgentForHost, } from './index_fleet_agent'; import { - deleteIndexedFleetActions, - DeleteIndexedFleetActionsResponse, - IndexedFleetActionsForHostResponse, - indexFleetActionsForHost, -} from './index_fleet_actions'; -import { - deleteIndexedEndpointActions, - DeleteIndexedEndpointActionsResponse, - IndexedEndpointActionsForHostResponse, - indexEndpointActionsForHost, -} from './index_endpoint_actions'; + deleteIndexedEndpointAndFleetActions, + DeleteIndexedEndpointFleetActionsResponse, + IndexedEndpointAndFleetActionsForHostResponse, + indexEndpointAndFleetActionsForHost, +} from './index_endpoint_fleet_actions'; import { deleteIndexedFleetEndpointPolicies, @@ -45,8 +39,7 @@ import { EndpointDataLoadingError, wrapErrorAndRejectPromise } from './utils'; export interface IndexedHostsResponse extends IndexedFleetAgentResponse, - IndexedFleetActionsForHostResponse, - IndexedEndpointActionsForHostResponse, + IndexedEndpointAndFleetActionsForHostResponse, IndexedFleetEndpointPolicyResponse { /** * The documents (1 or more) that were generated for the (single) endpoint host. @@ -90,7 +83,6 @@ export async function indexEndpointHostDocs({ metadataIndex, policyResponseIndex, enrollFleet, - addEndpointActions, generator, }: { numDocs: number; @@ -101,7 +93,6 @@ export async function indexEndpointHostDocs({ metadataIndex: string; policyResponseIndex: string; enrollFleet: boolean; - addEndpointActions: boolean; generator: EndpointDocGenerator; }): Promise { const timeBetweenDocs = 6 * 3600 * 1000; // 6 hours between metadata documents @@ -194,14 +185,7 @@ export async function indexEndpointHostDocs({ }; // Create some fleet endpoint actions and .logs-endpoint actions for this Host - if (addEndpointActions) { - await Promise.all([ - indexFleetActionsForHost(client, hostMetadata), - indexEndpointActionsForHost(client, hostMetadata), - ]); - } else { - await indexFleetActionsForHost(client, hostMetadata); - } + await indexEndpointAndFleetActionsForHost(client, hostMetadata, undefined); } hostMetadata = { @@ -259,8 +243,7 @@ const fetchKibanaVersion = async (kbnClient: KbnClient) => { export interface DeleteIndexedEndpointHostsResponse extends DeleteIndexedFleetAgentsResponse, - DeleteIndexedFleetActionsResponse, - DeleteIndexedEndpointActionsResponse, + DeleteIndexedEndpointFleetActionsResponse, DeleteIndexedFleetEndpointPoliciesResponse { hosts: DeleteByQueryResponse | undefined; policyResponses: DeleteByQueryResponse | undefined; @@ -335,8 +318,7 @@ export const deleteIndexedEndpointHosts = async ( } merge(response, await deleteIndexedFleetAgents(esClient, indexedData)); - merge(response, await deleteIndexedFleetActions(esClient, indexedData)); - merge(response, await deleteIndexedEndpointActions(esClient, indexedData)); + merge(response, await deleteIndexedEndpointAndFleetActions(esClient, indexedData)); merge(response, await deleteIndexedFleetEndpointPolicies(kbnClient, indexedData)); return response; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_actions.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_actions.ts deleted file mode 100644 index 47448be2e0a9..000000000000 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_actions.ts +++ /dev/null @@ -1,219 +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 { Client } from '@elastic/elasticsearch'; -import { DeleteByQueryResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { EndpointAction, EndpointActionResponse, HostMetadata } from '../types'; -import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../../fleet/common'; -import { FleetActionGenerator } from '../data_generators/fleet_action_generator'; -import { wrapErrorAndRejectPromise } from './utils'; - -const defaultFleetActionGenerator = new FleetActionGenerator(); - -export interface IndexedFleetActionsForHostResponse { - actions: EndpointAction[]; - actionResponses: EndpointActionResponse[]; - actionsIndex: string; - responsesIndex: string; -} - -/** - * Indexes a randome number of Endpoint (via Fleet) Actions for a given host - * (NOTE: ensure that fleet is setup first before calling this loading function) - * - * @param esClient - * @param endpointHost - * @param [fleetActionGenerator] - */ -export const indexFleetActionsForHost = async ( - esClient: Client, - endpointHost: HostMetadata, - fleetActionGenerator: FleetActionGenerator = defaultFleetActionGenerator -): Promise => { - const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } }; - const agentId = endpointHost.elastic.agent.id; - const total = fleetActionGenerator.randomN(5); - const response: IndexedFleetActionsForHostResponse = { - actions: [], - actionResponses: [], - actionsIndex: AGENT_ACTIONS_INDEX, - responsesIndex: AGENT_ACTIONS_RESULTS_INDEX, - }; - - for (let i = 0; i < total; i++) { - // create an action - const action = fleetActionGenerator.generate({ - data: { comment: 'data generator: this host is bad' }, - }); - - action.agents = [agentId]; - - esClient - .index( - { - index: AGENT_ACTIONS_INDEX, - body: action, - }, - ES_INDEX_OPTIONS - ) - .catch(wrapErrorAndRejectPromise); - - // Create an action response for the above - const actionResponse = fleetActionGenerator.generateResponse({ - action_id: action.action_id, - agent_id: agentId, - action_response: { - endpoint: { - // add ack to 4/5th of fleet response - ack: fleetActionGenerator.randomFloat() < 0.8 ? true : undefined, - }, - }, - }); - - esClient - .index( - { - index: AGENT_ACTIONS_RESULTS_INDEX, - body: actionResponse, - }, - ES_INDEX_OPTIONS - ) - .catch(wrapErrorAndRejectPromise); - - response.actions.push(action); - response.actionResponses.push(actionResponse); - } - - // Add edge cases (maybe) - if (fleetActionGenerator.randomFloat() < 0.3) { - const randomFloat = fleetActionGenerator.randomFloat(); - - // 60% of the time just add either an Isolate -OR- an UnIsolate action - if (randomFloat < 0.6) { - let action: EndpointAction; - - if (randomFloat < 0.3) { - // add a pending isolation - action = fleetActionGenerator.generateIsolateAction({ - '@timestamp': new Date().toISOString(), - }); - } else { - // add a pending UN-isolation - action = fleetActionGenerator.generateUnIsolateAction({ - '@timestamp': new Date().toISOString(), - }); - } - - action.agents = [agentId]; - - await esClient - .index( - { - index: AGENT_ACTIONS_INDEX, - body: action, - }, - ES_INDEX_OPTIONS - ) - .catch(wrapErrorAndRejectPromise); - - response.actions.push(action); - } else { - // Else (40% of the time) add a pending isolate AND pending un-isolate - const action1 = fleetActionGenerator.generateIsolateAction({ - '@timestamp': new Date().toISOString(), - }); - const action2 = fleetActionGenerator.generateUnIsolateAction({ - '@timestamp': new Date().toISOString(), - }); - - action1.agents = [agentId]; - action2.agents = [agentId]; - - await Promise.all([ - esClient - .index( - { - index: AGENT_ACTIONS_INDEX, - body: action1, - }, - ES_INDEX_OPTIONS - ) - .catch(wrapErrorAndRejectPromise), - esClient - .index( - { - index: AGENT_ACTIONS_INDEX, - body: action2, - }, - ES_INDEX_OPTIONS - ) - .catch(wrapErrorAndRejectPromise), - ]); - - response.actions.push(action1, action2); - } - } - - return response; -}; - -export interface DeleteIndexedFleetActionsResponse { - actions: DeleteByQueryResponse | undefined; - responses: DeleteByQueryResponse | undefined; -} - -export const deleteIndexedFleetActions = async ( - esClient: Client, - indexedData: IndexedFleetActionsForHostResponse -): Promise => { - const response: DeleteIndexedFleetActionsResponse = { - actions: undefined, - responses: undefined, - }; - - if (indexedData.actions.length) { - response.actions = await esClient - .deleteByQuery({ - index: `${indexedData.actionsIndex}-*`, - wait_for_completion: true, - body: { - query: { - bool: { - filter: [ - { terms: { action_id: indexedData.actions.map((action) => action.action_id) } }, - ], - }, - }, - }, - }) - .catch(wrapErrorAndRejectPromise); - } - - if (indexedData.actionResponses) { - response.responses = await esClient - .deleteByQuery({ - index: `${indexedData.responsesIndex}-*`, - wait_for_completion: true, - body: { - query: { - bool: { - filter: [ - { - terms: { - action_id: indexedData.actionResponses.map((action) => action.action_id), - }, - }, - ], - }, - }, - }, - }) - .catch(wrapErrorAndRejectPromise); - } - - return response; -}; diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts index 301a032fb47d..6d750ae3c667 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts @@ -578,4 +578,16 @@ describe('data generator', () => { const visitedEvents = countResolverEvents(rootNode, alertAncestors + generations); expect(visitedEvents).toEqual(events.length); }); + + it('creates full resolver tree with a single entry_leader id', () => { + const events = [...generator.alertsGenerator(1)]; + const [rootEvent, ...children] = events; + expect( + children.every((event) => { + return ( + event.process?.entry_leader?.entity_id === rootEvent.process?.entry_leader?.entity_id + ); + }) + ).toBe(true); + }); }); diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index f9ecb2e018df..443b27802c53 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -346,6 +346,7 @@ export interface TreeOptions { ancestryArraySize?: number; eventsDataStream?: DataStream; alertsDataStream?: DataStream; + sessionEntryLeader?: string; } type TreeOptionDefaults = Required; @@ -363,6 +364,7 @@ export function getTreeOptionsWithDef(options?: TreeOptions): TreeOptionDefaults relatedEvents: options?.relatedEvents ?? 5, relatedEventsOrdered: options?.relatedEventsOrdered ?? false, relatedAlerts: options?.relatedAlerts ?? 3, + sessionEntryLeader: options?.sessionEntryLeader ?? '', percentWithRelated: options?.percentWithRelated ?? 30, percentTerminated: options?.percentTerminated ?? 100, alwaysGenMaxChildrenPerNode: options?.alwaysGenMaxChildrenPerNode ?? false, @@ -535,12 +537,14 @@ export class EndpointDocGenerator extends BaseDataGenerator { */ public generateMalwareAlert({ ts = new Date().getTime(), + sessionEntryLeader = this.randomString(10), entityID = this.randomString(10), parentEntityID, ancestry = [], alertsDataStream = alertsDefaultDataStream, }: { ts?: number; + sessionEntryLeader?: string; entityID?: string; parentEntityID?: string; ancestry?: string[]; @@ -608,6 +612,21 @@ export class EndpointDocGenerator extends BaseDataGenerator { sha1: 'fake sha1', sha256: 'fake sha256', }, + entry_leader: { + entity_id: sessionEntryLeader, + name: 'fake entry', + pid: Math.floor(Math.random() * 1000), + }, + session_leader: { + entity_id: sessionEntryLeader, + name: 'fake session', + pid: Math.floor(Math.random() * 1000), + }, + group_leader: { + entity_id: sessionEntryLeader, + name: 'fake leader', + pid: Math.floor(Math.random() * 1000), + }, Ext: { ancestry, code_signature: [ @@ -648,6 +667,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { */ public generateMemoryAlert({ ts = new Date().getTime(), + sessionEntryLeader = this.randomString(10), entityID = this.randomString(10), parentEntityID, ancestry = [], @@ -655,6 +675,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { alertType, }: { ts?: number; + sessionEntryLeader?: string; entityID?: string; parentEntityID?: string; ancestry?: string[]; @@ -701,6 +722,21 @@ export class EndpointDocGenerator extends BaseDataGenerator { sha1: 'fake sha1', sha256: 'fake sha256', }, + entry_leader: { + entity_id: sessionEntryLeader, + name: 'fake entry', + pid: Math.floor(Math.random() * 1000), + }, + session_leader: { + entity_id: sessionEntryLeader, + name: 'fake session', + pid: Math.floor(Math.random() * 1000), + }, + group_leader: { + entity_id: sessionEntryLeader, + name: 'fake leader', + pid: Math.floor(Math.random() * 1000), + }, Ext: { ancestry, code_signature: [ @@ -758,12 +794,14 @@ export class EndpointDocGenerator extends BaseDataGenerator { public generateAlert({ ts = new Date().getTime(), entityID = this.randomString(10), + sessionEntryLeader, parentEntityID, ancestry = [], alertsDataStream = alertsDefaultDataStream, }: { ts?: number; entityID?: string; + sessionEntryLeader?: string; parentEntityID?: string; ancestry?: string[]; alertsDataStream?: DataStream; @@ -773,6 +811,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { case AlertTypes.MALWARE: return this.generateMalwareAlert({ ts, + sessionEntryLeader, entityID, parentEntityID, ancestry, @@ -783,6 +822,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { return this.generateMemoryAlert({ ts, entityID, + sessionEntryLeader, parentEntityID, ancestry, alertsDataStream, @@ -791,6 +831,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { case AlertTypes.BEHAVIOR: return this.generateBehaviorAlert({ ts, + sessionEntryLeader, entityID, parentEntityID, ancestry, @@ -811,12 +852,14 @@ export class EndpointDocGenerator extends BaseDataGenerator { */ public generateBehaviorAlert({ ts = new Date().getTime(), + sessionEntryLeader = this.randomString(10), entityID = this.randomString(10), parentEntityID, ancestry = [], alertsDataStream = alertsDefaultDataStream, }: { ts?: number; + sessionEntryLeader?: string; entityID?: string; parentEntityID?: string; ancestry?: string[]; @@ -878,6 +921,21 @@ export class EndpointDocGenerator extends BaseDataGenerator { status: 'trusted', subject_name: 'Microsoft Windows', }, + entry_leader: { + entity_id: sessionEntryLeader, + name: 'fake entry', + pid: Math.floor(Math.random() * 1000), + }, + session_leader: { + entity_id: sessionEntryLeader, + name: 'fake session', + pid: Math.floor(Math.random() * 1000), + }, + group_leader: { + entity_id: sessionEntryLeader, + name: 'fake leader', + pid: Math.floor(Math.random() * 1000), + }, parent: parentEntityID ? { entity_id: parentEntityID, @@ -950,6 +1008,10 @@ export class EndpointDocGenerator extends BaseDataGenerator { options.ancestry?.slice(0, options?.ancestryArrayLimit ?? ANCESTRY_LIMIT) ?? []; const processName = options.processName ? options.processName : this.randomProcessName(); + const sessionEntryLeader = options.sessionEntryLeader + ? options.sessionEntryLeader + : this.randomString(10); + const userName = this.randomString(10); const detailRecordForEventType = options.extensions || ((eventCategory) => { @@ -992,13 +1054,29 @@ export class EndpointDocGenerator extends BaseDataGenerator { pid: 'pid' in options && typeof options.pid !== 'undefined' ? options.pid : this.randomN(5000), executable: `C:\\${processName}`, - args: `"C:\\${processName}" \\${this.randomString(3)}`, + args: [`"C:\\${processName}"`, `--${this.randomString(3)}`], + working_directory: `/home/${userName}/`, code_signature: { status: 'trusted', subject_name: 'Microsoft', }, hash: { md5: this.seededUUIDv4() }, entity_id: options.entityID ? options.entityID : this.randomString(10), + entry_leader: { + entity_id: sessionEntryLeader, + name: 'fake entry', + pid: Math.floor(Math.random() * 1000), + }, + session_leader: { + entity_id: sessionEntryLeader, + name: 'fake session', + pid: Math.floor(Math.random() * 1000), + }, + group_leader: { + entity_id: sessionEntryLeader, + name: 'fake leader', + pid: Math.floor(Math.random() * 1000), + }, parent: options.parentEntityID ? { entity_id: options.parentEntityID, @@ -1017,7 +1095,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { }, user: { domain: this.randomString(10), - name: this.randomString(10), + name: userName, }, }; } @@ -1179,7 +1257,9 @@ export class EndpointDocGenerator extends BaseDataGenerator { public *alertsGenerator(numAlerts: number, options: TreeOptions = {}) { const opts = getTreeOptionsWithDef(options); for (let i = 0; i < numAlerts; i++) { - yield* this.fullResolverTreeGenerator(opts); + // 1 session per resolver tree + const sessionEntryLeader = this.randomString(10); + yield* this.fullResolverTreeGenerator({ ...opts, sessionEntryLeader }); } } @@ -1222,8 +1302,11 @@ export class EndpointDocGenerator extends BaseDataGenerator { const events = []; const startDate = new Date().getTime(); + const root = this.generateEvent({ timestamp: startDate + 1000, + entityID: opts.sessionEntryLeader, + sessionEntryLeader: opts.sessionEntryLeader, eventsDataStream: opts.eventsDataStream, }); events.push(root); @@ -1240,6 +1323,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { node, relatedAlerts: alertsPerNode, alertCreationTime: secBeforeAlert, + sessionEntryLeader: opts.sessionEntryLeader, alertsDataStream: opts.alertsDataStream, })) { eventList.push(relatedAlert); @@ -1252,6 +1336,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { relatedEvents: opts.relatedEvents, processDuration: secBeforeEvent, ordered: opts.relatedEventsOrdered, + sessionEntryLeader: opts.sessionEntryLeader, eventsDataStream: opts.eventsDataStream, })) { eventList.push(relatedEvent); @@ -1273,6 +1358,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { timestamp: timestamp + termProcessDuration * 1000, entityID: entityIDSafeVersion(root), parentEntityID: parentEntityIDSafeVersion(root), + sessionEntryLeader: opts.sessionEntryLeader, eventCategory: ['process'], eventType: ['end'], eventsDataStream: opts.eventsDataStream, @@ -1292,6 +1378,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { ancestor = this.generateEvent({ timestamp, parentEntityID: entityIDSafeVersion(ancestor), + sessionEntryLeader: opts.sessionEntryLeader, // add the parent to the ancestry array ancestry, ancestryArrayLimit: opts.ancestryArraySize, @@ -1309,6 +1396,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { timestamp: timestamp + termProcessDuration * 1000, entityID: entityIDSafeVersion(ancestor), parentEntityID: parentEntityIDSafeVersion(ancestor), + sessionEntryLeader: opts.sessionEntryLeader, eventCategory: ['process'], eventType: ['end'], ancestry: ancestryArray(ancestor), @@ -1339,6 +1427,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { ts: timestamp, entityID: entityIDSafeVersion(ancestor), parentEntityID: parentEntityIDSafeVersion(ancestor), + sessionEntryLeader: opts.sessionEntryLeader, ancestry: ancestryArray(ancestor), alertsDataStream: opts.alertsDataStream, }) @@ -1395,6 +1484,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { const child = this.generateEvent({ timestamp, parentEntityID: currentStateEntityID, + sessionEntryLeader: opts.sessionEntryLeader, ancestry, ancestryArrayLimit: opts.ancestryArraySize, eventsDataStream: opts.eventsDataStream, @@ -1416,6 +1506,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { yield this.generateEvent({ timestamp: timestamp + processDuration * 1000, entityID: entityIDSafeVersion(child), + sessionEntryLeader: opts.sessionEntryLeader, parentEntityID: parentEntityIDSafeVersion(child), eventCategory: ['process'], eventType: ['end'], @@ -1428,6 +1519,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { yield* this.relatedEventsGenerator({ node: child, relatedEvents: opts.relatedEvents, + sessionEntryLeader: opts.sessionEntryLeader, processDuration, ordered: opts.relatedEventsOrdered, eventsDataStream: opts.eventsDataStream, @@ -1435,6 +1527,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { yield* this.relatedAlertsGenerator({ node: child, relatedAlerts: opts.relatedAlerts, + sessionEntryLeader: opts.sessionEntryLeader, alertCreationTime: processDuration, alertsDataStream: opts.alertsDataStream, }); @@ -1456,11 +1549,13 @@ export class EndpointDocGenerator extends BaseDataGenerator { relatedEvents = 10, processDuration = 6 * 3600, ordered = false, + sessionEntryLeader, eventsDataStream = eventsDefaultDataStream, }: { node: Event; relatedEvents?: RelatedEventInfo[] | number; processDuration?: number; + sessionEntryLeader: string; ordered?: boolean; eventsDataStream?: DataStream; }) { @@ -1491,6 +1586,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { yield this.generateEvent({ timestamp: ts, entityID: entityIDSafeVersion(node), + sessionEntryLeader, parentEntityID: parentEntityIDSafeVersion(node), eventCategory: eventInfo.category, eventType: eventInfo.creationType, @@ -1512,17 +1608,20 @@ export class EndpointDocGenerator extends BaseDataGenerator { relatedAlerts = 3, alertCreationTime = 6 * 3600, alertsDataStream = alertsDefaultDataStream, + sessionEntryLeader, }: { node: Event; relatedAlerts: number; alertCreationTime: number; alertsDataStream: DataStream; + sessionEntryLeader: string; }) { for (let i = 0; i < relatedAlerts; i++) { const ts = (timestampSafeVersion(node) ?? 0) + this.randomN(alertCreationTime) * 1000; yield this.generateAlert({ ts, entityID: entityIDSafeVersion(node), + sessionEntryLeader, parentEntityID: parentEntityIDSafeVersion(node), ancestry: ancestryArray(node), alertsDataStream, diff --git a/x-pack/plugins/security_solution/common/endpoint/index_data.ts b/x-pack/plugins/security_solution/common/endpoint/index_data.ts index 5c81196a3709..57c20ffa242e 100644 --- a/x-pack/plugins/security_solution/common/endpoint/index_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/index_data.ts @@ -57,7 +57,6 @@ export async function indexHostsAndAlerts( alertIndex: string, alertsPerHost: number, fleet: boolean, - logsEndpoint: boolean, options: TreeOptions = {} ): Promise { const random = seedrandom(seed); @@ -103,7 +102,6 @@ export async function indexHostsAndAlerts( metadataIndex, policyResponseIndex, enrollFleet: fleet, - addEndpointActions: logsEndpoint, generator, }); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index ae8fce4efc6e..ee5e064fb866 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -11,13 +11,8 @@ import { PostTrustedAppCreateRequestSchema, PutTrustedAppUpdateRequestSchema, } from './trusted_apps'; -import { - ConditionEntry, - ConditionEntryField, - NewTrustedApp, - OperatingSystem, - PutTrustedAppsRequestParams, -} from '../types'; +import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { ConditionEntry, NewTrustedApp, PutTrustedAppsRequestParams } from '../types'; describe('When invoking Trusted Apps Schema', () => { describe('for GET List', () => { diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index 4b04f1568277..88ac65768e16 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -6,7 +6,8 @@ */ import { schema } from '@kbn/config-schema'; -import { ConditionEntry, ConditionEntryField, OperatingSystem } from '../types'; +import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { ConditionEntry } from '../types'; import { getDuplicateFields, isValidHash } from '../service/trusted_apps/validations'; export const DeleteTrustedAppsRequestSchema = { diff --git a/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts index 13054a231a45..a818e4d56d5b 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts @@ -9,6 +9,7 @@ import { ENDPOINT_TRUSTED_APPS_LIST_ID, ENDPOINT_EVENT_FILTERS_LIST_ID, ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + ENDPOINT_BLOCKLISTS_LIST_ID, } from '@kbn/securitysolution-list-constants'; export const BY_POLICY_ARTIFACT_TAG_PREFIX = 'policy:'; @@ -19,6 +20,7 @@ export const ALL_ENDPOINT_ARTIFACT_LIST_IDS: readonly string[] = [ ENDPOINT_TRUSTED_APPS_LIST_ID, ENDPOINT_EVENT_FILTERS_LIST_ID, ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + ENDPOINT_BLOCKLISTS_LIST_ID, ]; export const DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS: Readonly = [ diff --git a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts index 0e6f2a5a7df4..2d2c50572a8b 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts @@ -5,12 +5,8 @@ * 2.0. */ -import { - ConditionEntry, - ConditionEntryField, - OperatingSystem, - TrustedAppEntryTypes, -} from '../../types'; +import { ConditionEntryField } from '@kbn/securitysolution-utils'; +import { ConditionEntry } from '../../types'; const HASH_LENGTHS: readonly number[] = [ 32, // MD5 @@ -37,118 +33,3 @@ export const getDuplicateFields = (entries: ConditionEntry[]) => { .filter((entry) => entry[1].length > 1) .map((entry) => entry[0]); }; - -/* - * regex to match executable names - * starts matching from the eol of the path - * file names with a single or multiple spaces (for spaced names) - * and hyphens and combinations of these that produce complex names - * such as: - * c:\home\lib\dmp.dmp - * c:\home\lib\my-binary-app-+/ some/ x/ dmp.dmp - * /home/lib/dmp.dmp - * /home/lib/my-binary-app+-\ some\ x\ dmp.dmp - */ -const WIN_EXEC_PATH = /\\(\w+|\w*[\w+|-]+\/ +)+\w+[\w+|-]+\.*\w+$/i; -const UNIX_EXEC_PATH = /(\/|\w*[\w+|-]+\\ +)+\w+[\w+|-]+\.*\w*$/i; - -export const hasSimpleExecutableName = ({ - os, - type, - value, -}: { - os: OperatingSystem; - type: TrustedAppEntryTypes; - value: string; -}): boolean => { - if (type === 'wildcard') { - return os === OperatingSystem.WINDOWS ? WIN_EXEC_PATH.test(value) : UNIX_EXEC_PATH.test(value); - } - return true; -}; - -export const isPathValid = ({ - os, - field, - type, - value, -}: { - os: OperatingSystem; - field: ConditionEntryField; - type: TrustedAppEntryTypes; - value: string; -}): boolean => { - if (field === ConditionEntryField.PATH) { - if (type === 'wildcard') { - return os === OperatingSystem.WINDOWS - ? isWindowsWildcardPathValid(value) - : isLinuxMacWildcardPathValid(value); - } - return doesPathMatchRegex({ value, os }); - } - return true; -}; - -const doesPathMatchRegex = ({ os, value }: { os: OperatingSystem; value: string }): boolean => { - if (os === OperatingSystem.WINDOWS) { - const filePathRegex = - /^[a-z]:(?:|\\\\[^<>:"'/\\|?*]+\\[^<>:"'/\\|?*]+|%\w+%|)[\\](?:[^<>:"'/\\|?*]+[\\/])*([^<>:"'/\\|?*])+$/i; - return filePathRegex.test(value); - } - return /^(\/|(\/[\w\-]+)+|\/[\w\-]+\.[\w]+|(\/[\w-]+)+\/[\w\-]+\.[\w]+)$/i.test(value); -}; - -const isWindowsWildcardPathValid = (path: string): boolean => { - const firstCharacter = path[0]; - const lastCharacter = path.slice(-1); - const trimmedValue = path.trim(); - const hasSlash = /\//.test(trimmedValue); - if (path.length === 0) { - return false; - } else if ( - hasSlash || - trimmedValue.length !== path.length || - firstCharacter === '^' || - lastCharacter === '\\' || - !hasWildcard({ path, isWindowsPath: true }) - ) { - return false; - } else { - return true; - } -}; - -const isLinuxMacWildcardPathValid = (path: string): boolean => { - const firstCharacter = path[0]; - const lastCharacter = path.slice(-1); - const trimmedValue = path.trim(); - if (path.length === 0) { - return false; - } else if ( - trimmedValue.length !== path.length || - firstCharacter !== '/' || - lastCharacter === '/' || - path.length > 1024 === true || - path.includes('//') === true || - !hasWildcard({ path, isWindowsPath: false }) - ) { - return false; - } else { - return true; - } -}; - -const hasWildcard = ({ - path, - isWindowsPath, -}: { - path: string; - isWindowsPath: boolean; -}): boolean => { - for (const pathComponent of path.split(isWindowsPath ? '\\' : '/')) { - if (/[\*|\?]+/.test(pathComponent) === true) { - return true; - } - } - return false; -}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/generator.ts b/x-pack/plugins/security_solution/common/endpoint/types/generator.ts index ba900cfcb38f..b512697fb5d0 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/generator.ts @@ -14,6 +14,7 @@ export interface EventOptions { timestamp?: number; entityID?: string; parentEntityID?: string; + sessionEntryLeader?: string; eventType?: string | string[]; eventCategory?: string | string[]; processName?: string; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index 1fce6f17bdea..6e6095a990a1 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -739,11 +739,27 @@ export type SafeEndpointEvent = Partial<{ }>; pid: ECSField; hash: Hashes; + working_directory: ECSField; parent: Partial<{ entity_id: ECSField; name: ECSField; pid: ECSField; }>; + session_leader: Partial<{ + entity_id: ECSField; + name: ECSField; + pid: ECSField; + }>; + entry_leader: Partial<{ + entity_id: ECSField; + name: ECSField; + pid: ECSField; + }>; + group_leader: Partial<{ + entity_id: ECSField; + name: ECSField; + pid: ECSField; + }>; /* * The array has a special format. The entity_ids towards the beginning of the array are closer ancestors and the * values towards the end of the array are more distant ancestors (grandparents). Therefore diff --git a/x-pack/plugins/security_solution/common/endpoint/types/os.ts b/x-pack/plugins/security_solution/common/endpoint/types/os.ts index f892d077a9ed..af73dcd91ae8 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/os.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/os.ts @@ -5,12 +5,6 @@ * 2.0. */ -export enum OperatingSystem { - LINUX = 'linux', - MAC = 'macos', - WINDOWS = 'windows', -} - // PolicyConfig uses mac instead of macos export enum PolicyOperatingSystem { windows = 'windows', diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index 9815bc3535de..3872df8d1024 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -6,7 +6,11 @@ */ import { TypeOf } from '@kbn/config-schema'; - +import { + ConditionEntryField, + OperatingSystem, + TrustedAppEntryTypes, +} from '@kbn/securitysolution-utils'; import { DeleteTrustedAppsRequestSchema, GetOneTrustedAppRequestSchema, @@ -15,7 +19,6 @@ import { PutTrustedAppUpdateRequestSchema, GetTrustedAppsSummaryRequestSchema, } from '../schema/trusted_apps'; -import { OperatingSystem } from './os'; /** API request params for deleting Trusted App entry */ export type DeleteTrustedAppsRequestParams = TypeOf; @@ -67,18 +70,11 @@ export interface GetTrustedAppsSummaryResponse { linux: number; } -export enum ConditionEntryField { - HASH = 'process.hash.*', - PATH = 'process.executable.caseless', - SIGNER = 'process.Ext.code_signature', -} - export enum OperatorFieldIds { is = 'is', matches = 'matches', } -export type TrustedAppEntryTypes = 'match' | 'wildcard'; export interface ConditionEntry { field: T; type: TrustedAppEntryTypes; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts index ee836b89f901..8a9a047aab3f 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts @@ -9,7 +9,6 @@ import { CloudEcs } from '../../../../ecs/cloud'; import { HostEcs, OsEcs } from '../../../../ecs/host'; import { Hit, Hits, Maybe, SearchHit, StringOrNumber, TotalValue } from '../../../common'; import { EndpointPendingActions, HostStatus } from '../../../../endpoint/types'; -import { HostRiskSeverity } from '../kpi'; export enum HostPolicyResponseActionStatus { success = 'success', @@ -127,21 +126,3 @@ export interface HostHit extends Hit { } export type HostHits = Hits; - -export const enum HostRiskScoreFields { - timestamp = '@timestamp', - hostName = 'host.name', - riskScore = 'risk_stats.risk_score', - risk = 'risk', - // TODO: Steph/Host Risk - // ruleRisks = 'rule_risks', -} - -export interface HostRiskScoreItem { - _id?: Maybe; - [HostRiskScoreFields.hostName]: Maybe; - [HostRiskScoreFields.risk]: Maybe; - [HostRiskScoreFields.riskScore]: Maybe; - // TODO: Steph/Host Risk - // [HostRiskScoreFields.ruleRisks]: Maybe; -} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts index 7495e2dd2b86..bae99649c2e0 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts @@ -11,7 +11,6 @@ export * from './common'; export * from './details'; export * from './first_last_seen'; export * from './kpi'; -export * from './risk_score'; export * from './overview'; export * from './uncommon_processes'; @@ -23,6 +22,5 @@ export enum HostsQueries { hosts = 'hosts', hostsEntities = 'hostsEntities', overview = 'overviewHost', - hostsRiskScore = 'hostsRiskScore', uncommonProcesses = 'uncommonProcesses', } diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/index.ts index 2acbce2c8865..d48172bebee4 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/index.ts @@ -9,7 +9,6 @@ export * from './authentications'; export * from './common'; export * from './hosts'; export * from './unique_ips'; -export * from './risky_hosts'; import { HostsKpiAuthenticationsStrategyResponse } from './authentications'; import { HostsKpiHostsStrategyResponse } from './hosts'; @@ -21,7 +20,6 @@ export enum HostsKpiQueries { kpiHosts = 'hostsKpiHosts', kpiHostsEntities = 'hostsKpiHostsEntities', kpiUniqueIps = 'hostsKpiUniqueIps', - kpiRiskyHosts = 'hostsKpiRiskyHosts', kpiUniqueIpsEntities = 'hostsKpiUniqueIpsEntities', } diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/risky_hosts/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/risky_hosts/index.ts deleted file mode 100644 index 610077bd6bc6..000000000000 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/risky_hosts/index.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 type { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; -import type { Inspect, Maybe } from '../../../../common'; -import type { RequestBasicOptions } from '../../..'; - -export type HostsKpiRiskyHostsRequestOptions = RequestBasicOptions; - -export interface HostsKpiRiskyHostsStrategyResponse extends IEsSearchResponse { - inspect?: Maybe; - riskyHosts: { - [key in HostRiskSeverity]: number; - }; -} - -export const enum HostRiskSeverity { - unknown = 'Unknown', - low = 'Low', - moderate = 'Moderate', - high = 'High', - critical = 'Critical', -} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts deleted file mode 100644 index b36fbd5ce57a..000000000000 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts +++ /dev/null @@ -1,58 +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 { FactoryQueryTypes, HostRiskScoreFields } from '../..'; -import type { - IEsSearchRequest, - IEsSearchResponse, -} from '../../../../../../../../src/plugins/data/common'; -import { RISKY_HOSTS_INDEX_PREFIX } from '../../../../constants'; -import { ESQuery } from '../../../../typed_json'; -import { Inspect, Maybe, SortField, TimerangeInput } from '../../../common'; - -export interface HostsRiskScoreRequestOptions extends IEsSearchRequest { - defaultIndex: string[]; - factoryQueryType?: FactoryQueryTypes; - hostNames?: string[]; - timerange?: TimerangeInput; - onlyLatest?: boolean; - pagination?: { - cursorStart: number; - querySize: number; - }; - sort?: HostRiskScoreSortField; - filterQuery?: ESQuery | string | undefined; -} - -export interface HostsRiskScoreStrategyResponse extends IEsSearchResponse { - inspect?: Maybe; - totalCount: number; -} - -export interface HostsRiskScore { - '@timestamp': string; - host: { - name: string; - }; - risk: string; - risk_stats: { - rule_risks: RuleRisk[]; - risk_score: number; - }; -} - -export interface RuleRisk { - rule_name: string; - rule_risk: number; - rule_id?: string; // TODO Remove the '?' when the new transform is delivered -} - -export const getHostRiskIndex = (spaceId: string, onlyLatest: boolean = true): string => { - return `${RISKY_HOSTS_INDEX_PREFIX}${onlyLatest ? 'latest_' : ''}${spaceId}`; -}; - -export type HostRiskScoreSortField = SortField; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index eee621d02838..eb659b37a688 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -28,8 +28,6 @@ import { HostsKpiUniqueIpsStrategyResponse, HostsKpiUniqueIpsRequestOptions, HostFirstLastSeenRequestOptions, - HostsRiskScoreStrategyResponse, - HostsRiskScoreRequestOptions, } from './hosts'; import { NetworkQueries, @@ -77,20 +75,28 @@ import { } from './cti'; import { - HostsKpiRiskyHostsRequestOptions, - HostsKpiRiskyHostsStrategyResponse, -} from './hosts/kpi/risky_hosts'; + RiskScoreStrategyResponse, + RiskQueries, + RiskScoreRequestOptions, + KpiRiskScoreStrategyResponse, + KpiRiskScoreRequestOptions, +} from './risk_score'; +import { UsersQueries } from './users'; +import { UserDetailsRequestOptions, UserDetailsStrategyResponse } from './users/details'; export * from './cti'; export * from './hosts'; +export * from './risk_score'; export * from './matrix_histogram'; export * from './network'; export type FactoryQueryTypes = | HostsQueries | HostsKpiQueries + | UsersQueries | NetworkQueries | NetworkKpiQueries + | RiskQueries | CtiQueries | typeof MatrixHistogramQuery | typeof MatrixHistogramQueryEntities; @@ -119,8 +125,6 @@ export type StrategyResponseType = T extends HostsQ ? HostsStrategyResponse : T extends HostsQueries.details ? HostDetailsStrategyResponse - : T extends HostsQueries.hostsRiskScore - ? HostsRiskScoreStrategyResponse : T extends HostsQueries.overview ? HostsOverviewStrategyResponse : T extends HostsQueries.authentications @@ -133,10 +137,10 @@ export type StrategyResponseType = T extends HostsQ ? HostsKpiAuthenticationsStrategyResponse : T extends HostsKpiQueries.kpiHosts ? HostsKpiHostsStrategyResponse - : T extends HostsKpiQueries.kpiRiskyHosts - ? HostsKpiRiskyHostsStrategyResponse : T extends HostsKpiQueries.kpiUniqueIps ? HostsKpiUniqueIpsStrategyResponse + : T extends UsersQueries.details + ? UserDetailsStrategyResponse : T extends NetworkQueries.details ? NetworkDetailsStrategyResponse : T extends NetworkQueries.dns @@ -169,12 +173,14 @@ export type StrategyResponseType = T extends HostsQ ? CtiEventEnrichmentStrategyResponse : T extends CtiQueries.dataSource ? CtiDataSourceStrategyResponse + : T extends RiskQueries.riskScore + ? RiskScoreStrategyResponse + : T extends RiskQueries.kpiRiskScore + ? KpiRiskScoreStrategyResponse : never; export type StrategyRequestType = T extends HostsQueries.hosts ? HostsRequestOptions - : T extends HostsQueries.hostsRiskScore - ? HostsRiskScoreRequestOptions : T extends HostsQueries.details ? HostDetailsRequestOptions : T extends HostsQueries.overview @@ -191,8 +197,8 @@ export type StrategyRequestType = T extends HostsQu ? HostsKpiHostsRequestOptions : T extends HostsKpiQueries.kpiUniqueIps ? HostsKpiUniqueIpsRequestOptions - : T extends HostsKpiQueries.kpiRiskyHosts - ? HostsKpiRiskyHostsRequestOptions + : T extends UsersQueries.details + ? UserDetailsRequestOptions : T extends NetworkQueries.details ? NetworkDetailsRequestOptions : T extends NetworkQueries.dns @@ -225,6 +231,10 @@ export type StrategyRequestType = T extends HostsQu ? CtiEventEnrichmentRequestOptions : T extends CtiQueries.dataSource ? CtiDataSourceRequestOptions + : T extends RiskQueries.riskScore + ? RiskScoreRequestOptions + : T extends RiskQueries.kpiRiskScore + ? KpiRiskScoreRequestOptions : never; export interface DocValueFieldsInput { diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/all/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/all/index.ts new file mode 100644 index 000000000000..4c65f9db1b14 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/all/index.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 { FactoryQueryTypes } from '../..'; +import type { + IEsSearchRequest, + IEsSearchResponse, +} from '../../../../../../../../src/plugins/data/common'; + +import { ESQuery } from '../../../../typed_json'; +import { Inspect, Maybe, SortField, TimerangeInput } from '../../../common'; + +export interface RiskScoreRequestOptions extends IEsSearchRequest { + defaultIndex: string[]; + factoryQueryType?: FactoryQueryTypes; + timerange?: TimerangeInput; + onlyLatest?: boolean; + pagination?: { + cursorStart: number; + querySize: number; + }; + sort?: RiskScoreSortField; + filterQuery?: ESQuery | string | undefined; +} + +export interface RiskScoreStrategyResponse extends IEsSearchResponse { + inspect?: Maybe; + totalCount: number; +} + +export interface RiskScore { + '@timestamp': string; + risk: string; + risk_stats: { + rule_risks: RuleRisk[]; + risk_score: number; + }; +} + +export interface HostsRiskScore extends RiskScore { + host: { + name: string; + }; +} + +export interface UsersRiskScore extends RiskScore { + user: { + name: string; + }; +} + +export interface RuleRisk { + rule_name: string; + rule_risk: number; + rule_id: string; +} + +export type RiskScoreSortField = SortField; + +export const enum RiskScoreFields { + timestamp = '@timestamp', + hostName = 'host.name', + userName = 'user.name', + riskScore = 'risk_stats.risk_score', + risk = 'risk', +} + +export interface RiskScoreItem { + _id?: Maybe; + [RiskScoreFields.hostName]: Maybe; + [RiskScoreFields.userName]: Maybe; + [RiskScoreFields.risk]: Maybe; + [RiskScoreFields.riskScore]: Maybe; +} + +export const enum RiskSeverity { + unknown = 'Unknown', + low = 'Low', + moderate = 'Moderate', + high = 'High', + critical = 'Critical', +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.test.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.test.ts similarity index 58% rename from x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.test.ts rename to x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.test.ts index 8c58ccaabe8d..c1943059e153 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.test.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.test.ts @@ -5,10 +5,14 @@ * 2.0. */ -import { getHostRiskIndex } from '.'; +import { getHostRiskIndex, getUserRiskIndex } from './'; describe('hosts risk search_strategy getHostRiskIndex', () => { - it('should properly return index if space is specified', () => { + it('should properly return host index if space is specified', () => { expect(getHostRiskIndex('testName')).toEqual('ml_host_risk_score_latest_testName'); }); + + it('should properly return user index if space is specified', () => { + expect(getUserRiskIndex('testName')).toEqual('ml_user_risk_score_latest_testName'); + }); }); diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.ts new file mode 100644 index 000000000000..afc964be58ac --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RISKY_HOSTS_INDEX_PREFIX, RISKY_USERS_INDEX_PREFIX } from '../../../../constants'; + +export const getHostRiskIndex = (spaceId: string, onlyLatest: boolean = true): string => { + return `${RISKY_HOSTS_INDEX_PREFIX}${onlyLatest ? 'latest_' : ''}${spaceId}`; +}; + +export const getUserRiskIndex = (spaceId: string, onlyLatest: boolean = true): string => { + return `${RISKY_USERS_INDEX_PREFIX}${onlyLatest ? 'latest_' : ''}${spaceId}`; +}; + +export const buildHostNamesFilter = (hostNames: string[]) => { + return { terms: { 'host.name': hostNames } }; +}; + +export const buildUserNamesFilter = (userNames: string[]) => { + return { terms: { 'user.name': userNames } }; +}; + +export enum RiskQueries { + riskScore = 'riskScore', + kpiRiskScore = 'kpiRiskScore', +} + +export type RiskScoreAggByFields = 'host.name' | 'user.name'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/index.ts similarity index 77% rename from x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/index.ts rename to x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/index.ts index 9b13b6f5741a..fd0e3e7af9f0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/index.ts @@ -5,4 +5,6 @@ * 2.0. */ -export { PolicyEventFiltersList } from './policy_event_filters_list'; +export * from './all'; +export * from './common'; +export * from './kpi'; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/kpi/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/kpi/index.ts new file mode 100644 index 000000000000..895a3d15476f --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/kpi/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FactoryQueryTypes, RiskScoreAggByFields, RiskSeverity } from '../..'; +import type { + IEsSearchRequest, + IEsSearchResponse, +} from '../../../../../../../../src/plugins/data/common'; +import { ESQuery } from '../../../../typed_json'; + +import { Inspect, Maybe } from '../../../common'; + +export interface KpiRiskScoreRequestOptions extends IEsSearchRequest { + defaultIndex: string[]; + factoryQueryType?: FactoryQueryTypes; + filterQuery?: ESQuery | string | undefined; + aggBy: RiskScoreAggByFields; +} + +export interface KpiRiskScoreStrategyResponse extends IEsSearchResponse { + inspect?: Maybe; + kpiRiskScore: { + [key in RiskSeverity]: number; + }; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/common/index.ts new file mode 100644 index 000000000000..a522cd4d8921 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/common/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Maybe, RiskSeverity } from '../../..'; +import { HostEcs } from '../../../../ecs/host'; +import { UserEcs } from '../../../../ecs/user'; + +export const enum UserRiskScoreFields { + timestamp = '@timestamp', + userName = 'user.name', + riskScore = 'risk_stats.risk_score', + risk = 'risk', +} + +export interface UserRiskScoreItem { + _id?: Maybe; + [UserRiskScoreFields.userName]: Maybe; + [UserRiskScoreFields.risk]: Maybe; + [UserRiskScoreFields.riskScore]: Maybe; +} + +export interface UserItem { + user?: Maybe; + host?: Maybe; + lastSeen?: Maybe; + firstSeen?: Maybe; +} + +export enum UsersFields { + lastSeen = 'lastSeen', + hostName = 'userName', +} + +export interface UserAggEsItem { + user_id?: UserBuckets; + user_domain?: UserBuckets; + user_name?: UserBuckets; + host_os_name?: UserBuckets; + host_ip?: UserBuckets; + host_os_family?: UserBuckets; + first_seen?: { value_as_string: string }; + last_seen?: { value_as_string: string }; +} + +export interface UserBuckets { + buckets: Array<{ + key: string; + doc_count: number; + }>; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/details/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/details/index.ts new file mode 100644 index 000000000000..941e8081d114 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/details/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; + +import { Inspect, Maybe, TimerangeInput } from '../../../common'; +import { UserItem, UsersFields } from '../common'; +import { RequestOptionsPaginated } from '../..'; + +export interface UserDetailsStrategyResponse extends IEsSearchResponse { + userDetails: UserItem; + inspect?: Maybe; +} + +export interface UserDetailsRequestOptions extends Partial> { + userName: string; + skip?: boolean; + timerange: TimerangeInput; + inspect?: Maybe; +} + +export interface AggregationRequest { + [aggField: string]: estypes.AggregationsAggregationContainer; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/index.ts new file mode 100644 index 000000000000..fd5c90031b9a --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum UsersQueries { + details = 'userDetails', +} diff --git a/x-pack/plugins/security_solution/common/utils/path_placeholder.test.ts b/x-pack/plugins/security_solution/common/utils/path_placeholder.test.ts index 9618440c105d..58f9fa70f739 100644 --- a/x-pack/plugins/security_solution/common/utils/path_placeholder.test.ts +++ b/x-pack/plugins/security_solution/common/utils/path_placeholder.test.ts @@ -6,7 +6,11 @@ */ import { getPlaceholderTextByOSType, getPlaceholderText } from './path_placeholder'; -import { ConditionEntryField, OperatingSystem, TrustedAppEntryTypes } from '../endpoint/types'; +import { + ConditionEntryField, + OperatingSystem, + TrustedAppEntryTypes, +} from '@kbn/securitysolution-utils'; const trustedAppEntry = { os: OperatingSystem.LINUX, diff --git a/x-pack/plugins/security_solution/common/utils/path_placeholder.ts b/x-pack/plugins/security_solution/common/utils/path_placeholder.ts index baa9b71cd448..328df398dd57 100644 --- a/x-pack/plugins/security_solution/common/utils/path_placeholder.ts +++ b/x-pack/plugins/security_solution/common/utils/path_placeholder.ts @@ -4,8 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { ConditionEntryField, OperatingSystem, TrustedAppEntryTypes } from '../endpoint/types'; +import { + ConditionEntryField, + OperatingSystem, + TrustedAppEntryTypes, +} from '@kbn/securitysolution-utils'; export const getPlaceholderText = () => ({ windows: { diff --git a/x-pack/plugins/security_solution/cypress/integration/data_sources/create_runtime_field.spec.ts b/x-pack/plugins/security_solution/cypress/integration/data_sources/create_runtime_field.spec.ts index 152147b1844b..6cd525f519dd 100644 --- a/x-pack/plugins/security_solution/cypress/integration/data_sources/create_runtime_field.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/data_sources/create_runtime_field.spec.ts @@ -26,7 +26,7 @@ describe('Create DataView runtime field', () => { cleanKibana(); }); - it.skip('adds field to alert table', () => { + it('adds field to alert table', () => { const fieldName = 'field.name.alert.page'; loginAndWaitForPage(ALERTS_URL); createCustomRuleEnabled(getNewRule()); diff --git a/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts index 05b9cb567faf..62ba50a494df 100644 --- a/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts @@ -139,7 +139,7 @@ describe('Timeline scope', () => { loginAndWaitForPage(TIMELINES_URL); }); - it.skip('correctly loads SIEM data view before and after signals index exists', () => { + it('correctly loads SIEM data view before and after signals index exists', () => { openTimelineUsingToggle(); openSourcerer('timeline'); isDataViewSelection(siemDataViewTitle); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts index 06ff1938d5d4..b71396f116a9 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ROLES } from '../../../common/test'; import { getNewRule } from '../../objects/rule'; import { ALERTS_COUNT, @@ -26,7 +27,7 @@ import { refreshPage } from '../../tasks/security_header'; import { ALERTS_URL } from '../../urls/navigation'; -describe.skip('Marking alerts as acknowledged', () => { +describe('Marking alerts as acknowledged', () => { beforeEach(() => { cleanKibana(); loginAndWaitForPage(ALERTS_URL); @@ -63,3 +64,41 @@ describe.skip('Marking alerts as acknowledged', () => { }); }); }); + +describe('Marking alerts as acknowledged with read only role', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPage(ALERTS_URL, ROLES.t2_analyst); + createCustomRuleEnabled(getNewRule()); + refreshPage(); + waitForAlertsToPopulate(100); + }); + + it('Mark one alert as acknowledged when more than one open alerts are selected', () => { + cy.get(ALERTS_COUNT) + .invoke('text') + .then((alertNumberString) => { + const numberOfAlerts = alertNumberString.split(' ')[0]; + const numberOfAlertsToBeMarkedAcknowledged = 1; + const numberOfAlertsToBeSelected = 3; + + cy.get(TAKE_ACTION_POPOVER_BTN).should('not.exist'); + selectNumberOfAlerts(numberOfAlertsToBeSelected); + cy.get(TAKE_ACTION_POPOVER_BTN).should('exist'); + + markAcknowledgedFirstAlert(); + const expectedNumberOfAlerts = +numberOfAlerts - numberOfAlertsToBeMarkedAcknowledged; + cy.get(ALERTS_COUNT).should('have.text', `${expectedNumberOfAlerts} alerts`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should('have.text', `${expectedNumberOfAlerts}`); + + goToAcknowledgedAlerts(); + waitForAlerts(); + + cy.get(ALERTS_COUNT).should('have.text', `${numberOfAlertsToBeMarkedAcknowledged} alert`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should( + 'have.text', + `${numberOfAlertsToBeMarkedAcknowledged}` + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts index 6a8bf9cd42ea..eb93bddcf384 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts @@ -26,7 +26,7 @@ import { getUnmappedRule } from '../../objects/rule'; import { ALERTS_URL } from '../../urls/navigation'; -describe.skip('Alert details with unmapped fields', () => { +describe('Alert details with unmapped fields', () => { beforeEach(() => { cleanKibana(); esArchiverLoad('unmapped_fields'); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/attach_to_case.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/attach_to_case.spec.ts index 03cac07ac8b7..e728b010e68b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/attach_to_case.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/attach_to_case.spec.ts @@ -23,7 +23,7 @@ const loadDetectionsPage = (role: ROLES) => { waitForAlertsToPopulate(); }; -describe.skip('Alerts timeline', () => { +describe('Alerts timeline', () => { before(() => { // First we login as a privileged user to create alerts. cleanKibana(); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/building_block_alerts.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/building_block_alerts.spec.ts index d9cf95921912..27ba580164ea 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/building_block_alerts.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/building_block_alerts.spec.ts @@ -18,7 +18,7 @@ import { ALERTS_URL, DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigatio const EXPECTED_NUMBER_OF_ALERTS = 16; -describe.skip('Alerts generated by building block rules', () => { +describe('Alerts generated by building block rules', () => { beforeEach(() => { cleanKibana(); loginAndWaitForPageWithoutDateRange(ALERTS_URL); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts index a46502687d3a..0275e35b1e43 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts @@ -6,6 +6,7 @@ */ import { getNewRule } from '../../objects/rule'; +import { ROLES } from '../../../common/test'; import { ALERTS_COUNT, SELECTED_ALERTS, @@ -31,7 +32,7 @@ import { refreshPage } from '../../tasks/security_header'; import { ALERTS_URL } from '../../urls/navigation'; -describe.skip('Closing alerts', () => { +describe('Closing alerts', () => { beforeEach(() => { cleanKibana(); loginAndWaitForPage(ALERTS_URL); @@ -174,3 +175,44 @@ describe.skip('Closing alerts', () => { }); }); }); + +describe('Closing alerts with read only role', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPage(ALERTS_URL, ROLES.t2_analyst); + createCustomRuleEnabled(getNewRule(), '1', '100m', 100); + refreshPage(); + waitForAlertsToPopulate(100); + deleteCustomRule(); + }); + + it('Closes alerts', () => { + const numberOfAlertsToBeClosed = 3; + cy.get(ALERTS_COUNT) + .invoke('text') + .then((alertNumberString) => { + const numberOfAlerts = alertNumberString.split(' ')[0]; + cy.get(ALERTS_COUNT).should('have.text', `${numberOfAlerts} alerts`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should('have.text', `${numberOfAlerts}`); + + selectNumberOfAlerts(numberOfAlertsToBeClosed); + + cy.get(SELECTED_ALERTS).should('have.text', `Selected ${numberOfAlertsToBeClosed} alerts`); + + closeAlerts(); + waitForAlerts(); + + const expectedNumberOfAlertsAfterClosing = +numberOfAlerts - numberOfAlertsToBeClosed; + cy.get(ALERTS_COUNT).should('have.text', `${expectedNumberOfAlertsAfterClosing} alerts`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should( + 'have.text', + `${expectedNumberOfAlertsAfterClosing}` + ); + + goToClosedAlerts(); + waitForAlerts(); + + cy.get(ALERTS_COUNT).should('have.text', `${numberOfAlertsToBeClosed} alerts`); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts index c5e015b6382c..0e4dbc9a95f9 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts @@ -33,7 +33,7 @@ import { openJsonView, openThreatIndicatorDetails } from '../../tasks/alerts_det import { ALERTS_URL } from '../../urls/navigation'; import { addsFieldsToTimeline } from '../../tasks/rule_details'; -describe.skip('CTI Enrichment', () => { +describe('CTI Enrichment', () => { before(() => { cleanKibana(); esArchiverLoad('threat_indicator'); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/investigate_in_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/investigate_in_timeline.spec.ts index 7ea11017dd6e..95d7de20aa15 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/investigate_in_timeline.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/investigate_in_timeline.spec.ts @@ -17,7 +17,7 @@ import { refreshPage } from '../../tasks/security_header'; import { ALERTS_URL } from '../../urls/navigation'; -describe.skip('Alerts timeline', () => { +describe('Alerts timeline', () => { beforeEach(() => { cleanKibana(); loginAndWaitForPage(ALERTS_URL); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/missing_privileges_callout.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/missing_privileges_callout.spec.ts index 64377c03af6c..cf74ca338220 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/missing_privileges_callout.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/missing_privileges_callout.spec.ts @@ -72,24 +72,7 @@ describe('Detections > Callouts', () => { }); }); - context('On Rules Management page', () => { - beforeEach(() => { - loadPageAsReadOnlyUser(DETECTIONS_RULE_MANAGEMENT_URL); - }); - - it('We show one primary callout', () => { - waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary'); - }); - - context('When a user clicks Dismiss on the callout', () => { - it('We hide it and persist the dismissal', () => { - waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary'); - dismissCallOut(MISSING_PRIVILEGES_CALLOUT); - reloadPage(); - getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist'); - }); - }); - }); + // FYI: Rules Management check moved to ../detection_rules/all_rules_read_only.spec.ts context('On Rule Details page', () => { beforeEach(() => { diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts index 6ad8c28595c6..c0312d5f68b6 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts @@ -29,7 +29,7 @@ import { refreshPage } from '../../tasks/security_header'; import { ALERTS_URL } from '../../urls/navigation'; -describe.skip('Opening alerts', () => { +describe('Opening alerts', () => { beforeEach(() => { cleanKibana(); loginAndWaitForPage(ALERTS_URL); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/all_rules_read_only.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/all_rules_read_only.spec.ts new file mode 100644 index 000000000000..fbb5d9e4b26d --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/all_rules_read_only.spec.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 { ROLES } from '../../../common/test'; +import { getNewRule } from '../../objects/rule'; +import { + COLLAPSED_ACTION_BTN, + RULE_CHECKBOX, + RULE_NAME, +} from '../../screens/alerts_detection_rules'; +import { VALUE_LISTS_MODAL_ACTIVATOR } from '../../screens/lists'; +import { waitForRulesTableToBeLoaded } from '../../tasks/alerts_detection_rules'; +import { createCustomRule } from '../../tasks/api_calls/rules'; +import { cleanKibana } from '../../tasks/common'; +import { dismissCallOut, getCallOut, waitForCallOutToBeShown } from '../../tasks/common/callouts'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { SECURITY_DETECTIONS_RULES_URL } from '../../urls/navigation'; + +const MISSING_PRIVILEGES_CALLOUT = 'missing-user-privileges'; + +describe('All rules - read only', () => { + before(() => { + cleanKibana(); + createCustomRule(getNewRule(), '1'); + loginAndWaitForPageWithoutDateRange(SECURITY_DETECTIONS_RULES_URL, ROLES.reader); + waitForRulesTableToBeLoaded(); + cy.get(RULE_NAME).should('have.text', getNewRule().name); + }); + + it('Does not display select boxes for rules', () => { + cy.get(RULE_CHECKBOX).should('not.exist'); + }); + + it('Disables value lists upload', () => { + cy.get(VALUE_LISTS_MODAL_ACTIVATOR).should('be.disabled'); + }); + + it('Does not display action options', () => { + // These are the 3 dots at the end of the row that opens up + // options to take action on the rule + cy.get(COLLAPSED_ACTION_BTN).should('not.exist'); + }); + + it('Displays missing privileges primary callout', () => { + waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary'); + }); + + context('When a user clicks Dismiss on the callouts', () => { + it('We hide them and persist the dismissal', () => { + waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary'); + + dismissCallOut(MISSING_PRIVILEGES_CALLOUT); + cy.reload(); + cy.get(RULE_NAME).should('have.text', getNewRule().name); + + getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts index 73b915dfff64..195db69b3fe3 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts @@ -105,7 +105,7 @@ import { enablesRule, getDetails } from '../../tasks/rule_details'; import { RULE_CREATION, DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; -describe.skip('Custom detection rules creation', () => { +describe('Custom detection rules creation', () => { const expectedUrls = getNewRule().referenceUrls.join(''); const expectedFalsePositives = getNewRule().falsePositivesExamples.join(''); const expectedTags = getNewRule().tags.join(''); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts index 0ae68553f50c..e8c5ebaac170 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts @@ -63,7 +63,7 @@ import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { RULE_CREATION } from '../../urls/navigation'; -describe.skip('Detection rules, EQL', () => { +describe('Detection rules, EQL', () => { const expectedUrls = getEqlRule().referenceUrls.join(''); const expectedFalsePositives = getEqlRule().falsePositivesExamples.join(''); const expectedTags = getEqlRule().tags.join(''); @@ -159,7 +159,7 @@ describe.skip('Detection rules, EQL', () => { }); }); -describe.skip('Detection rules, sequence EQL', () => { +describe('Detection rules, sequence EQL', () => { const expectedNumberOfRules = 1; const expectedNumberOfSequenceAlerts = '1 alert'; diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts index 0314c0c3a66b..7b84845d4632 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts @@ -6,7 +6,10 @@ */ import { expectedExportedRule, getNewRule } from '../../objects/rule'; -import { exportFirstRule, getRulesImportExportToast } from '../../tasks/alerts_detection_rules'; + +import { TOASTER } from '../../screens/alerts_detection_rules'; + +import { exportFirstRule } from '../../tasks/alerts_detection_rules'; import { createCustomRule } from '../../tasks/api_calls/rules'; import { cleanKibana } from '../../tasks/common'; import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; @@ -28,9 +31,10 @@ describe('Export rules', () => { exportFirstRule(); cy.wait('@export').then(({ response }) => { cy.wrap(response?.body).should('eql', expectedExportedRule(this.ruleResponse)); - getRulesImportExportToast([ - 'Successfully exported 1 of 1 rule. Prebuilt rules were excluded from the resulting file.', - ]); + cy.get(TOASTER).should( + 'have.text', + 'Successfully exported 1 of 1 rule. Prebuilt rules were excluded from the resulting file.' + ); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/import_rules.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/import_rules.spec.ts index c82d2c680525..5a2a58e89c3d 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/import_rules.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/import_rules.spec.ts @@ -5,11 +5,8 @@ * 2.0. */ -import { - getRulesImportExportToast, - importRules, - importRulesWithOverwriteAll, -} from '../../tasks/alerts_detection_rules'; +import { TOASTER } from '../../screens/alerts_detection_rules'; +import { importRules, importRulesWithOverwriteAll } from '../../tasks/alerts_detection_rules'; import { cleanKibana, reload } from '../../tasks/common'; import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; @@ -27,10 +24,10 @@ describe('Import rules', () => { cy.wait('@import').then(({ response }) => { cy.wrap(response?.statusCode).should('eql', 200); - getRulesImportExportToast([ - 'Successfully imported 1 rule', - 'Successfully imported 2 exceptions.', - ]); + cy.get(TOASTER).should( + 'have.text', + 'Successfully imported 1 ruleSuccessfully imported 2 exceptions.' + ); }); }); @@ -46,7 +43,7 @@ describe('Import rules', () => { cy.wait('@import').then(({ response }) => { cy.wrap(response?.statusCode).should('eql', 200); - getRulesImportExportToast(['Failed to import 1 rule', 'Failed to import 2 exceptions']); + cy.get(TOASTER).should('have.text', 'Failed to import 1 ruleFailed to import 2 exceptions'); }); }); @@ -62,10 +59,10 @@ describe('Import rules', () => { cy.wait('@import').then(({ response }) => { cy.wrap(response?.statusCode).should('eql', 200); - getRulesImportExportToast([ - 'Successfully imported 1 rule', - 'Successfully imported 2 exceptions.', - ]); + cy.get(TOASTER).should( + 'have.text', + 'Successfully imported 1 ruleSuccessfully imported 2 exceptions.' + ); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts index 9978045835f4..13d939875b1f 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts @@ -114,7 +114,7 @@ import { goBackToAllRulesTable, getDetails } from '../../tasks/rule_details'; import { ALERTS_URL, RULE_CREATION } from '../../urls/navigation'; const DEFAULT_THREAT_MATCH_QUERY = '@timestamp >= "now-30d/d"'; -describe.skip('indicator match', () => { +describe('indicator match', () => { describe('Detection rules, Indicator Match', () => { const expectedUrls = getNewThreatIndicatorRule().referenceUrls.join(''); const expectedFalsePositives = getNewThreatIndicatorRule().falsePositivesExamples.join(''); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/machine_learning_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/machine_learning_rule.spec.ts index d47ff6f98cd1..163e418268cb 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/machine_learning_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/machine_learning_rule.spec.ts @@ -57,7 +57,7 @@ import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { RULE_CREATION } from '../../urls/navigation'; -describe.skip('Detection rules, machine learning', () => { +describe('Detection rules, machine learning', () => { const expectedUrls = getMachineLearningRule().referenceUrls.join(''); const expectedFalsePositives = getMachineLearningRule().falsePositivesExamples.join(''); const expectedTags = getMachineLearningRule().tags.join(''); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts index 2dcfae29b615..13f2a361883b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts @@ -73,7 +73,7 @@ import { getDetails } from '../../tasks/rule_details'; import { RULE_CREATION } from '../../urls/navigation'; -describe.skip('Detection rules, override', () => { +describe('Detection rules, override', () => { const expectedUrls = getNewOverrideRule().referenceUrls.join(''); const expectedFalsePositives = getNewOverrideRule().falsePositivesExamples.join(''); const expectedTags = getNewOverrideRule().tags.join(''); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts index ea83b66ffb95..0d670a447e9d 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts @@ -18,7 +18,6 @@ import { } from '../../screens/alerts_detection_rules'; import { - changeRowsPerPageTo100, deleteFirstRule, deleteSelectedRules, loadPrebuiltDetectionRules, @@ -97,8 +96,6 @@ describe('Actions with prebuilt rules', () => { }); it('Does not allow to delete one rule when more than one is selected', () => { - changeRowsPerPageTo100(); - const numberOfRulesToBeSelected = 2; selectNumberOfRules(numberOfRulesToBeSelected); @@ -108,14 +105,10 @@ describe('Actions with prebuilt rules', () => { }); it('Deletes and recovers one rule', () => { - changeRowsPerPageTo100(); - const expectedNumberOfRulesAfterDeletion = totalNumberOfPrebuiltRules - 1; const expectedNumberOfRulesAfterRecovering = totalNumberOfPrebuiltRules; deleteFirstRule(); - cy.reload(); - changeRowsPerPageTo100(); cy.get(ELASTIC_RULES_BTN).should( 'have.text', @@ -128,9 +121,6 @@ describe('Actions with prebuilt rules', () => { cy.get(RELOAD_PREBUILT_RULES_BTN).should('not.exist'); - cy.reload(); - changeRowsPerPageTo100(); - cy.get(ELASTIC_RULES_BTN).should( 'have.text', `Elastic rules (${expectedNumberOfRulesAfterRecovering})` @@ -138,16 +128,12 @@ describe('Actions with prebuilt rules', () => { }); it('Deletes and recovers more than one rule', () => { - changeRowsPerPageTo100(); - const numberOfRulesToBeSelected = 2; const expectedNumberOfRulesAfterDeletion = totalNumberOfPrebuiltRules - 2; const expectedNumberOfRulesAfterRecovering = totalNumberOfPrebuiltRules; selectNumberOfRules(numberOfRulesToBeSelected); deleteSelectedRules(); - cy.reload(); - changeRowsPerPageTo100(); cy.get(RELOAD_PREBUILT_RULES_BTN).should('exist'); cy.get(RELOAD_PREBUILT_RULES_BTN).should( @@ -163,9 +149,6 @@ describe('Actions with prebuilt rules', () => { cy.get(RELOAD_PREBUILT_RULES_BTN).should('not.exist'); - cy.reload(); - changeRowsPerPageTo100(); - cy.get(ELASTIC_RULES_BTN).should( 'have.text', `Elastic rules (${expectedNumberOfRulesAfterRecovering})` diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts index 80fbfc6a7bf1..e20fa3866742 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts @@ -77,7 +77,7 @@ import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { RULE_CREATION } from '../../urls/navigation'; -describe.skip('Detection rules, threshold', () => { +describe('Detection rules, threshold', () => { let rule = getNewThresholdRule(); const expectedUrls = getNewThresholdRule().referenceUrls.join(''); const expectedFalsePositives = getNewThresholdRule().falsePositivesExamples.join(''); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/all_exception_lists_read_only.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/all_exception_lists_read_only.spec.ts new file mode 100644 index 000000000000..e332019f2754 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/all_exception_lists_read_only.spec.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ROLES } from '../../../common/test'; +import { getExceptionList } from '../../objects/exception'; +import { EXCEPTIONS_TABLE_SHOWING_LISTS } from '../../screens/exceptions'; +import { createExceptionList } from '../../tasks/api_calls/exceptions'; +import { cleanKibana } from '../../tasks/common'; +import { dismissCallOut, getCallOut, waitForCallOutToBeShown } from '../../tasks/common/callouts'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { EXCEPTIONS_URL } from '../../urls/navigation'; + +const MISSING_PRIVILEGES_CALLOUT = 'missing-user-privileges'; + +describe('All exception lists - read only', () => { + before(() => { + cleanKibana(); + + // Create exception list not used by any rules + createExceptionList(getExceptionList(), getExceptionList().list_id); + + loginAndWaitForPageWithoutDateRange(EXCEPTIONS_URL, ROLES.reader); + + cy.reload(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); + }); + + it('Displays missing privileges primary callout', () => { + waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary'); + }); + + context('When a user clicks Dismiss on the callouts', () => { + it('We hide them and persist the dismissal', () => { + waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary'); + + dismissCallOut(MISSING_PRIVILEGES_CALLOUT); + cy.reload(); + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); + + getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_flyout.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_flyout.spec.ts index d68def128446..60202a4f6a52 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_flyout.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_flyout.spec.ts @@ -29,10 +29,21 @@ import { EXCEPTION_ITEM_CONTAINER, ADD_EXCEPTIONS_BTN, EXCEPTION_FIELD_LIST, + EDIT_EXCEPTIONS_BTN, + EXCEPTION_EDIT_FLYOUT_SAVE_BTN, + EXCEPTION_FLYOUT_VERSION_CONFLICT, + EXCEPTION_FLYOUT_LIST_DELETED_ERROR, } from '../../screens/exceptions'; import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; import { cleanKibana, reload } from '../../tasks/common'; +import { + createExceptionList, + createExceptionListItem, + updateExceptionListItem, + deleteExceptionList, +} from '../../tasks/api_calls/exceptions'; +import { getExceptionList } from '../../objects/exception'; // NOTE: You might look at these tests and feel they're overkill, // but the exceptions flyout has a lot of logic making it difficult @@ -42,18 +53,28 @@ import { cleanKibana, reload } from '../../tasks/common'; describe('Exceptions flyout', () => { before(() => { cleanKibana(); - loginAndWaitForPageWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - createCustomRule({ ...getNewRule(), index: ['exceptions-*'] }); - reload(); - goToRuleDetails(); - - cy.get(RULE_STATUS).should('have.text', '—'); - // this is a made-up index that has just the necessary // mappings to conduct tests, avoiding loading large // amounts of data like in auditbeat_exceptions esArchiverLoad('exceptions'); - + loginAndWaitForPageWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); + createExceptionList(getExceptionList(), getExceptionList().list_id).then((response) => + createCustomRule({ + ...getNewRule(), + index: ['exceptions-*'], + exceptionLists: [ + { + id: response.body.id, + list_id: getExceptionList().list_id, + type: getExceptionList().type, + namespace_type: getExceptionList().namespace_type, + }, + ], + }) + ); + reload(); + goToRuleDetails(); + cy.get(RULE_STATUS).should('have.text', '—'); goToExceptionsTab(); }); @@ -62,7 +83,12 @@ describe('Exceptions flyout', () => { }); it('Does not overwrite values and-ed together', () => { - cy.get(ADD_EXCEPTIONS_BTN).click({ force: true }); + cy.root() + .pipe(($el) => { + $el.find(ADD_EXCEPTIONS_BTN).trigger('click'); + return $el.find(ADD_AND_BTN); + }) + .should('be.visible'); // add multiple entries with invalid field values addExceptionEntryFieldValue('agent.name', 0); @@ -80,8 +106,12 @@ describe('Exceptions flyout', () => { }); it('Does not overwrite values or-ed together', () => { - cy.get(ADD_EXCEPTIONS_BTN).click({ force: true }); - + cy.root() + .pipe(($el) => { + $el.find(ADD_EXCEPTIONS_BTN).trigger('click'); + return $el.find(ADD_AND_BTN); + }) + .should('be.visible'); // exception item 1 addExceptionEntryFieldValueOfItemX('agent.name', 0, 0); cy.get(ADD_AND_BTN).click(); @@ -197,11 +227,89 @@ describe('Exceptions flyout', () => { }); it('Contains custom index fields', () => { - cy.get(ADD_EXCEPTIONS_BTN).click({ force: true }); - + cy.root() + .pipe(($el) => { + $el.find(ADD_EXCEPTIONS_BTN).trigger('click'); + return $el.find(ADD_AND_BTN); + }) + .should('be.visible'); cy.get(FIELD_INPUT).eq(0).click({ force: true }); cy.get(EXCEPTION_FIELD_LIST).contains('unique_value.test'); closeExceptionBuilderFlyout(); }); + + describe('flyout errors', () => { + before(() => { + // create exception item via api + createExceptionListItem(getExceptionList().list_id, { + list_id: getExceptionList().list_id, + item_id: 'simple_list_item', + tags: [], + type: 'simple', + description: 'Test exception item', + name: 'Sample Exception List Item', + namespace_type: 'single', + entries: [ + { + field: 'host.name', + operator: 'included', + type: 'match_any', + value: ['some host', 'another host'], + }, + ], + }); + + reload(); + cy.get(RULE_STATUS).should('have.text', '—'); + goToExceptionsTab(); + }); + + context('When updating an item with version conflict', () => { + it('Displays version conflict error', () => { + cy.get(EDIT_EXCEPTIONS_BTN).should('be.visible'); + cy.get(EDIT_EXCEPTIONS_BTN).click({ force: true }); + + // update exception item via api + updateExceptionListItem('simple_list_item', { + name: 'Updated item name', + item_id: 'simple_list_item', + tags: [], + type: 'simple', + description: 'Test exception item', + namespace_type: 'single', + entries: [ + { + field: 'host.name', + operator: 'included', + type: 'match_any', + value: ['some host', 'another host'], + }, + ], + }); + + // try to save and see version conflict error + cy.get(EXCEPTION_EDIT_FLYOUT_SAVE_BTN).click({ force: true }); + + cy.get(EXCEPTION_FLYOUT_VERSION_CONFLICT).should('be.visible'); + + closeExceptionBuilderFlyout(); + }); + }); + + context('When updating an item for a list that has since been deleted', () => { + it('Displays missing exception list error', () => { + cy.get(EDIT_EXCEPTIONS_BTN).should('be.visible'); + cy.get(EDIT_EXCEPTIONS_BTN).click({ force: true }); + + // delete exception list via api + deleteExceptionList(getExceptionList().list_id, getExceptionList().namespace_type); + + // try to save and see error + cy.get(EXCEPTION_EDIT_FLYOUT_SAVE_BTN).click({ force: true }); + + cy.get(EXCEPTION_FLYOUT_LIST_DELETED_ERROR).should('be.visible'); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts index 538fa3a008a1..d2578f917203 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts @@ -5,30 +5,18 @@ * 2.0. */ -import { - getException, - getExceptionList, - expectedExportedExceptionList, -} from '../../objects/exception'; +import { ROLES } from '../../../common/test'; +import { getExceptionList, expectedExportedExceptionList } from '../../objects/exception'; import { getNewRule } from '../../objects/rule'; -import { RULE_STATUS } from '../../screens/create_new_rule'; - import { createCustomRule } from '../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../tasks/alerts_detection_rules'; -import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange, waitForPageWithoutDateRange, } from '../../tasks/login'; -import { - addsExceptionFromRuleSettings, - goBackToAllRulesTable, - goToExceptionsTab, -} from '../../tasks/rule_details'; import { DETECTIONS_RULE_MANAGEMENT_URL, EXCEPTIONS_URL } from '../../urls/navigation'; -import { cleanKibana, reload } from '../../tasks/common'; +import { cleanKibana } from '../../tasks/common'; import { deleteExceptionListWithRuleReference, deleteExceptionListWithoutRuleReference, @@ -38,35 +26,53 @@ import { clearSearchSelection, } from '../../tasks/exceptions_table'; import { + EXCEPTIONS_TABLE_DELETE_BTN, EXCEPTIONS_TABLE_LIST_NAME, EXCEPTIONS_TABLE_SHOWING_LISTS, } from '../../screens/exceptions'; import { createExceptionList } from '../../tasks/api_calls/exceptions'; +const getExceptionList1 = () => ({ + ...getExceptionList(), + name: 'Test a new list 1', + list_id: 'exception_list_1', +}); +const getExceptionList2 = () => ({ + ...getExceptionList(), + name: 'Test list 2', + list_id: 'exception_list_2', +}); + describe('Exceptions Table', () => { before(() => { cleanKibana(); loginAndWaitForPageWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - createCustomRule(getNewRule()); - reload(); - goToRuleDetails(); - - cy.get(RULE_STATUS).should('have.text', '—'); - esArchiverLoad('auditbeat_for_exceptions'); - - // Add a detections exception list - goToExceptionsTab(); - addsExceptionFromRuleSettings(getException()); + // Create exception list associated with a rule + createExceptionList(getExceptionList2(), getExceptionList2().list_id).then((response) => + createCustomRule({ + ...getNewRule(), + exceptionLists: [ + { + id: response.body.id, + list_id: getExceptionList2().list_id, + type: getExceptionList2().type, + namespace_type: getExceptionList2().namespace_type, + }, + ], + }) + ); // Create exception list not used by any rules - createExceptionList(getExceptionList(), getExceptionList().list_id).as('exceptionListResponse'); + createExceptionList(getExceptionList1(), getExceptionList1().list_id).as( + 'exceptionListResponse' + ); - goBackToAllRulesTable(); - }); + waitForPageWithoutDateRange(EXCEPTIONS_URL); - after(() => { - esArchiverUnload('auditbeat_for_exceptions'); + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); }); it('Exports exception list', function () { @@ -87,60 +93,99 @@ describe('Exceptions Table', () => { waitForPageWithoutDateRange(EXCEPTIONS_URL); waitForExceptionsTableToBeLoaded(); - cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 3 lists`); + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); // Single word search searchForExceptionList('Endpoint'); - cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'Endpoint Security Exception List'); // Multi word search clearSearchSelection(); - searchForExceptionList('New Rule Test'); + searchForExceptionList('test'); - cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 2 lists`); - cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(0).should('have.text', 'Test exception list'); - cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(1).should('have.text', 'New Rule Test'); + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(1).should('have.text', 'Test list 2'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(0).should('have.text', 'Test a new list 1'); // Exact phrase search clearSearchSelection(); - searchForExceptionList('"New Rule Test"'); + searchForExceptionList(`"${getExceptionList1().name}"`); - cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); - cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'New Rule Test'); + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', getExceptionList1().name); // Field search clearSearchSelection(); searchForExceptionList('list_id:endpoint_list'); - cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'Endpoint Security Exception List'); clearSearchSelection(); - cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 3 lists`); + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); }); it('Deletes exception list without rule reference', () => { waitForPageWithoutDateRange(EXCEPTIONS_URL); waitForExceptionsTableToBeLoaded(); - cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 3 lists`); + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); deleteExceptionListWithoutRuleReference(); - cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 2 lists`); + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2'); }); it('Deletes exception list with rule reference', () => { waitForPageWithoutDateRange(EXCEPTIONS_URL); waitForExceptionsTableToBeLoaded(); - cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 2 lists`); + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2'); deleteExceptionListWithRuleReference(); + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); + }); +}); + +describe('Exceptions Table - read only', () => { + before(() => { + // First we login as a privileged user to create exception list + cleanKibana(); + loginAndWaitForPageWithoutDateRange(EXCEPTIONS_URL, ROLES.platform_engineer); + createExceptionList(getExceptionList(), getExceptionList().list_id); + + // Then we login as read-only user to test. + loginAndWaitForPageWithoutDateRange(EXCEPTIONS_URL, ROLES.reader); + waitForExceptionsTableToBeLoaded(); + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); }); + + it('Delete icon is not shown', () => { + cy.get(EXCEPTIONS_TABLE_DELETE_BTN).should('not.exist'); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/from_alert.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/from_alert.spec.ts index 189b95754e83..a9fa5f96f3fa 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/from_alert.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/from_alert.spec.ts @@ -9,7 +9,6 @@ import { getException } from '../../objects/exception'; import { getNewRule } from '../../objects/rule'; import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../screens/alerts'; -import { RULE_STATUS } from '../../screens/create_new_rule'; import { addExceptionFromFirstAlert, goToClosedAlerts, goToOpenedAlerts } from '../../tasks/alerts'; import { createCustomRule } from '../../tasks/api_calls/rules'; @@ -34,14 +33,11 @@ describe.skip('From alert', () => { beforeEach(() => { cleanKibana(); + esArchiverLoad('auditbeat_for_exceptions'); loginAndWaitForPageWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); createCustomRule({ ...getNewRule(), index: ['exceptions-*'] }, 'rule_testing'); reload(); goToRuleDetails(); - - cy.get(RULE_STATUS).should('have.text', '—'); - - esArchiverLoad('auditbeat_for_exceptions'); enablesRule(); waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); @@ -74,6 +70,7 @@ describe.skip('From alert', () => { goToExceptionsTab(); removeException(); + esArchiverLoad('auditbeat_for_exceptions2'); goToAlertsTab(); waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/from_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/from_rule.spec.ts index cc4d6ec0b2e5..0dddd143a130 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/from_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/from_rule.spec.ts @@ -9,7 +9,6 @@ import { getException } from '../../objects/exception'; import { getNewRule } from '../../objects/rule'; import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../screens/alerts'; -import { RULE_STATUS } from '../../screens/create_new_rule'; import { goToClosedAlerts, goToOpenedAlerts } from '../../tasks/alerts'; import { createCustomRule } from '../../tasks/api_calls/rules'; @@ -25,7 +24,6 @@ import { removeException, waitForTheRuleToBeExecuted, } from '../../tasks/rule_details'; -import { refreshPage } from '../../tasks/security_header'; import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; import { cleanKibana, reload } from '../../tasks/common'; @@ -34,18 +32,14 @@ describe.skip('From rule', () => { const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1'; beforeEach(() => { cleanKibana(); + esArchiverLoad('auditbeat_for_exceptions'); loginAndWaitForPageWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); createCustomRule({ ...getNewRule(), index: ['exceptions-*'] }, 'rule_testing'); reload(); goToRuleDetails(); - - cy.get(RULE_STATUS).should('have.text', '—'); - - esArchiverLoad('auditbeat_for_exceptions'); enablesRule(); waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); - refreshPage(); cy.get(ALERTS_COUNT).should('exist'); cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS} alert`); @@ -77,6 +71,7 @@ describe.skip('From rule', () => { goToExceptionsTab(); removeException(); + esArchiverLoad('auditbeat_for_exceptions2'); goToAlertsTab(); waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); diff --git a/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts index 048efd00d276..47e71345ff0c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts @@ -8,7 +8,7 @@ import { FIELDS_BROWSER_CHECKBOX, FIELDS_BROWSER_CONTAINER, - FIELDS_BROWSER_SELECTED_CATEGORY_TITLE, + FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES, } from '../../screens/fields_browser'; import { HOST_GEO_CITY_NAME_HEADER, @@ -17,7 +17,11 @@ import { SERVER_SIDE_EVENT_COUNT, } from '../../screens/hosts/events'; -import { closeFieldsBrowser, filterFieldsBrowser } from '../../tasks/fields_browser'; +import { + closeFieldsBrowser, + filterFieldsBrowser, + toggleCategory, +} from '../../tasks/fields_browser'; import { loginAndWaitForPage } from '../../tasks/login'; import { openEvents } from '../../tasks/hosts/main'; import { @@ -60,11 +64,13 @@ describe('Events Viewer', () => { cy.get(FIELDS_BROWSER_CONTAINER).should('not.exist'); }); - it('displays the `default ECS` category (by default)', () => { - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE).should('have.text', 'default ECS'); + it('displays all categories (by default)', () => { + cy.get(FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES).should('be.empty'); }); it('displays a checked checkbox for all of the default events viewer columns that are also in the default ECS category', () => { + const category = 'default ECS'; + toggleCategory(category); defaultHeadersInDefaultEcsCategory.forEach((header) => cy.get(FIELDS_BROWSER_CHECKBOX(header.id)).should('be.checked') ); @@ -108,7 +114,6 @@ describe('Events Viewer', () => { it('resets all fields in the events viewer when `Reset Fields` is clicked', () => { const filterInput = 'host.geo.c'; - filterFieldsBrowser(filterInput); cy.get(HOST_GEO_COUNTRY_NAME_HEADER).should('not.exist'); addsHostGeoCountryNameToHeader(); diff --git a/x-pack/plugins/security_solution/cypress/integration/hosts/host_risk_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/hosts/host_risk_tab.spec.ts index 38a639e19c6b..3af77036649a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/hosts/host_risk_tab.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/hosts/host_risk_tab.spec.ts @@ -7,14 +7,20 @@ import { cleanKibana } from '../../tasks/common'; import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; -import { navigateToHostRiskDetailTab } from '../../tasks/host_risk'; +import { + navigateToHostRiskDetailTab, + openRiskTableFilterAndSelectTheCriticalOption, + removeCritialFilter, + selectFiveItemsPerPageOption, +} from '../../tasks/host_risk'; import { HOST_BY_RISK_TABLE_CELL, - HOST_BY_RISK_TABLE_FILTER, - HOST_BY_RISK_TABLE_FILTER_CRITICAL, + HOST_BY_RISK_TABLE_HOSTNAME_CELL, + HOST_BY_RISK_TABLE_NEXT_PAGE_BUTTON, } from '../../screens/hosts/host_risk'; import { loginAndWaitForPage } from '../../tasks/login'; import { HOSTS_URL } from '../../urls/navigation'; +import { clearSearchBar, kqlSearch } from '../../tasks/security_header'; describe('risk tab', () => { before(() => { @@ -29,15 +35,30 @@ describe('risk tab', () => { }); it('renders the table', () => { + kqlSearch('host.name: "siem-kibana" {enter}'); cy.get(HOST_BY_RISK_TABLE_CELL).eq(3).should('have.text', 'siem-kibana'); cy.get(HOST_BY_RISK_TABLE_CELL).eq(4).should('have.text', '21.00'); cy.get(HOST_BY_RISK_TABLE_CELL).eq(5).should('have.text', 'Low'); + clearSearchBar(); }); it('filters the table', () => { - cy.get(HOST_BY_RISK_TABLE_FILTER).click(); - cy.get(HOST_BY_RISK_TABLE_FILTER_CRITICAL).click(); + openRiskTableFilterAndSelectTheCriticalOption(); cy.get(HOST_BY_RISK_TABLE_CELL).eq(3).should('not.have.text', 'siem-kibana'); + + removeCritialFilter(); + }); + + it('should be able to change items count per page', () => { + selectFiveItemsPerPageOption(); + + cy.get(HOST_BY_RISK_TABLE_HOSTNAME_CELL).should('have.length', 5); + }); + + it('should not allow page change when page is empty', () => { + kqlSearch('host.name: "nonexistent_host" {enter}'); + cy.get(HOST_BY_RISK_TABLE_NEXT_PAGE_BUTTON).should(`not.exist`); + clearSearchBar(); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/overview/cti_link_panel.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview/cti_link_panel.spec.ts index 75ff13b66b29..92daa295d270 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview/cti_link_panel.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview/cti_link_panel.spec.ts @@ -9,9 +9,7 @@ import { OVERVIEW_CTI_ENABLE_MODULE_BUTTON, OVERVIEW_CTI_LINKS, OVERVIEW_CTI_LINKS_ERROR_INNER_PANEL, - OVERVIEW_CTI_LINKS_INFO_INNER_PANEL, OVERVIEW_CTI_TOTAL_EVENT_COUNT, - OVERVIEW_CTI_ENABLE_INTEGRATIONS_BUTTON, } from '../../screens/overview'; import { loginAndWaitForPage } from '../../tasks/login'; @@ -47,14 +45,13 @@ describe('CTI Link Panel', () => { loginAndWaitForPage( `${OVERVIEW_URL}?sourcerer=(timerange:(from:%272021-07-08T04:00:00.000Z%27,kind:absolute,to:%272021-07-09T03:59:59.999Z%27))` ); - cy.get(`${OVERVIEW_CTI_LINKS} ${OVERVIEW_CTI_LINKS_INFO_INNER_PANEL}`).should('exist'); + cy.get(`${OVERVIEW_CTI_LINKS}`).should('exist'); cy.get(`${OVERVIEW_CTI_TOTAL_EVENT_COUNT}`).should('have.text', 'Showing: 0 indicators'); }); it('renders dashboard module as expected when there are events in the selected time period', () => { loginAndWaitForPage(OVERVIEW_URL); - cy.get(`${OVERVIEW_CTI_LINKS} ${OVERVIEW_CTI_LINKS_INFO_INNER_PANEL}`).should('exist'); - cy.get(`${OVERVIEW_CTI_LINKS} ${OVERVIEW_CTI_ENABLE_INTEGRATIONS_BUTTON}`).should('exist'); + cy.get(`${OVERVIEW_CTI_LINKS}`).should('exist'); cy.get(OVERVIEW_CTI_LINKS).should('not.contain.text', 'Anomali'); cy.get(OVERVIEW_CTI_LINKS).should('contain.text', 'AbuseCH malware'); cy.get(`${OVERVIEW_CTI_TOTAL_EVENT_COUNT}`).should('have.text', 'Showing: 1 indicator'); diff --git a/x-pack/plugins/security_solution/cypress/integration/overview/risky_hosts_panel.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview/risky_hosts_panel.spec.ts index 1c55a38b3249..652b3c1118b3 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview/risky_hosts_panel.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview/risky_hosts_panel.spec.ts @@ -69,7 +69,7 @@ describe('Risky Hosts Link Panel', () => { `${OVERVIEW_RISKY_HOSTS_LINKS} ${OVERVIEW_RISKY_HOSTS_LINKS_WARNING_INNER_PANEL}` ).should('not.exist'); cy.get(`${OVERVIEW_RISKY_HOSTS_VIEW_DASHBOARD_BUTTON}`).should('be.disabled'); - cy.get(`${OVERVIEW_RISKY_HOSTS_TOTAL_EVENT_COUNT}`).should('have.text', 'Showing: 1 host'); + cy.get(`${OVERVIEW_RISKY_HOSTS_TOTAL_EVENT_COUNT}`).should('have.text', 'Showing: 6 hosts'); changeSpace(testSpaceName); cy.visit(`/s/${testSpaceName}${OVERVIEW_URL}`); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts index be726f0323d4..89a9fc4c0c6b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts @@ -8,14 +8,13 @@ import { FIELDS_BROWSER_CATEGORIES_COUNT, FIELDS_BROWSER_FIELDS_COUNT, - FIELDS_BROWSER_HOST_CATEGORIES_COUNT, FIELDS_BROWSER_HOST_GEO_CITY_NAME_HEADER, FIELDS_BROWSER_HEADER_HOST_GEO_CONTINENT_NAME_HEADER, FIELDS_BROWSER_MESSAGE_HEADER, - FIELDS_BROWSER_SELECTED_CATEGORY_TITLE, - FIELDS_BROWSER_SELECTED_CATEGORY_COUNT, - FIELDS_BROWSER_SYSTEM_CATEGORIES_COUNT, FIELDS_BROWSER_FILTER_INPUT, + FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER, + FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES, + FIELDS_BROWSER_CATEGORY_BADGE, } from '../../screens/fields_browser'; import { TIMELINE_FIELDS_BUTTON } from '../../screens/timeline'; import { cleanKibana } from '../../tasks/common'; @@ -26,8 +25,10 @@ import { clearFieldsBrowser, closeFieldsBrowser, filterFieldsBrowser, + toggleCategoryFilter, removesMessageField, resetFields, + toggleCategory, } from '../../tasks/fields_browser'; import { loginAndWaitForPage } from '../../tasks/login'; import { openTimelineUsingToggle } from '../../tasks/security_main'; @@ -60,21 +61,8 @@ describe('Fields Browser', () => { clearFieldsBrowser(); }); - it('displays the `default ECS` category (by default)', () => { - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE).should('have.text', 'default ECS'); - }); - - it('the `defaultECS` (selected) category count matches the default timeline header count', () => { - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT).should( - 'have.text', - `${defaultHeaders.length}` - ); - }); - - it('displays a checked checkbox for all of the default timeline columns', () => { - defaultHeaders.forEach((header) => - cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked') - ); + it('displays all categories (by default)', () => { + cy.get(FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES).should('be.empty'); }); it('displays the expected count of categories that match the filter input', () => { @@ -82,34 +70,50 @@ describe('Fields Browser', () => { filterFieldsBrowser(filterInput); - cy.get(FIELDS_BROWSER_CATEGORIES_COUNT).should('have.text', '2 categories'); + cy.get(FIELDS_BROWSER_CATEGORIES_COUNT).should('have.text', '2'); }); it('displays a search results label with the expected count of fields matching the filter input', () => { const filterInput = 'host.mac'; - filterFieldsBrowser(filterInput); - cy.get(FIELDS_BROWSER_HOST_CATEGORIES_COUNT) - .invoke('text') - .then((hostCategoriesCount) => { - cy.get(FIELDS_BROWSER_SYSTEM_CATEGORIES_COUNT) - .invoke('text') - .then((systemCategoriesCount) => { - cy.get(FIELDS_BROWSER_FIELDS_COUNT).should( - 'have.text', - `${+hostCategoriesCount + +systemCategoriesCount} fields` - ); - }); - }); - }); - - it('displays a count of only the fields in the selected category that match the filter input', () => { - const filterInput = 'host.geo.c'; + cy.get(FIELDS_BROWSER_FIELDS_COUNT).should('contain.text', '2'); + }); - filterFieldsBrowser(filterInput); + it('the `default ECS` category matches the default timeline header fields', () => { + const category = 'default ECS'; + toggleCategory(category); + cy.get(FIELDS_BROWSER_FIELDS_COUNT).should('contain.text', `${defaultHeaders.length}`); + + defaultHeaders.forEach((header) => { + cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked'); + }); + toggleCategory(category); + }); + + it('creates the category badge when it is selected', () => { + const category = 'host'; + + cy.get(FIELDS_BROWSER_CATEGORY_BADGE(category)).should('not.exist'); + toggleCategory(category); + cy.get(FIELDS_BROWSER_CATEGORY_BADGE(category)).should('exist'); + toggleCategory(category); + }); + + it('search a category should match the category in the category filter', () => { + const category = 'host'; - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT).should('have.text', '5'); + filterFieldsBrowser(category); + toggleCategoryFilter(); + cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER).should('contain.text', category); + }); + + it('search a category should filter out non matching categories in the category filter', () => { + const category = 'host'; + const categoryCheck = 'event'; + filterFieldsBrowser(category); + toggleCategoryFilter(); + cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER).should('not.contain.text', categoryCheck); }); }); @@ -136,18 +140,15 @@ describe('Fields Browser', () => { cy.get(FIELDS_BROWSER_MESSAGE_HEADER).should('not.exist'); }); - it('selects a search results label with the expected count of categories matching the filter input', () => { - const category = 'host'; - filterFieldsBrowser(category); - - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE).should('have.text', category); - }); - it('adds a field to the timeline when the user clicks the checkbox', () => { const filterInput = 'host.geo.c'; - filterFieldsBrowser(filterInput); + closeFieldsBrowser(); cy.get(FIELDS_BROWSER_HOST_GEO_CITY_NAME_HEADER).should('not.exist'); + + openTimelineFieldsBrowser(); + + filterFieldsBrowser(filterInput); addsHostGeoCityNameToTimeline(); closeFieldsBrowser(); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/local_storage.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/local_storage.spec.ts index 617f04697c95..b3139d94aa62 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/local_storage.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/local_storage.spec.ts @@ -14,7 +14,7 @@ import { waitsForEventsToBeLoaded } from '../../tasks/hosts/events'; import { removeColumn } from '../../tasks/timeline'; // TODO: Fix bug in persisting the columns of timeline -describe('persistent timeline', () => { +describe.skip('persistent timeline', () => { beforeEach(() => { cleanKibana(); loginAndWaitForPage(HOSTS_URL); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts index 2219339d0577..ffb9e8b61c0b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts @@ -87,7 +87,9 @@ describe('Row renderers', () => { }); describe('Suricata', () => { - it('Signature tooltips do not overlap', () => { + // This test has become very flaky over time and was blocking a lot of PRs. + // A follw-up ticket to tackle this issue has been created. + it.skip('Signature tooltips do not overlap', () => { // Hover the signature to show the tooltips cy.get(TIMELINE_ROW_RENDERERS_SURICATA_SIGNATURE) .parents('.euiPopover__anchor') diff --git a/x-pack/plugins/security_solution/cypress/integration/users/all_users_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/users/all_users_tab.spec.ts new file mode 100644 index 000000000000..070afc12cf53 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/users/all_users_tab.spec.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HEADER_SUBTITLE, USER_NAME_CELL } from '../../screens/users/all_users'; +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPage } from '../../tasks/login'; + +import { USERS_URL } from '../../urls/navigation'; + +describe('Users stats and tables', () => { + before(() => { + cleanKibana(); + + loginAndWaitForPage(USERS_URL); + }); + + it(`renders all users`, () => { + const totalUsers = 35; + const usersPerPage = 10; + + cy.get(HEADER_SUBTITLE).should('have.text', `Showing: ${totalUsers} users`); + cy.get(USER_NAME_CELL).should('have.length', usersPerPage); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/users/inspect.spec.ts b/x-pack/plugins/security_solution/cypress/integration/users/inspect.spec.ts index fc7a68f9730c..f092949ec56a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/users/inspect.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/users/inspect.spec.ts @@ -10,7 +10,7 @@ import { ALL_USERS_TABLE } from '../../screens/users/all_users'; import { cleanKibana } from '../../tasks/common'; import { clickInspectButton, closesModal } from '../../tasks/inspect'; -import { loginAndWaitForUsersDetailsPage, loginAndWaitForPage } from '../../tasks/login'; +import { loginAndWaitForPage } from '../../tasks/login'; import { USERS_URL } from '../../urls/navigation'; @@ -31,18 +31,4 @@ describe('Inspect', () => { cy.get(INSPECT_MODAL).should('be.visible'); }); }); - - context('Users details', () => { - before(() => { - loginAndWaitForUsersDetailsPage(); - }); - afterEach(() => { - closesModal(); - }); - - it(`inspects user details all users table`, () => { - clickInspectButton(ALL_USERS_TABLE); - cy.get(INSPECT_MODAL).should('be.visible'); - }); - }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts b/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts index 9e3446a7d071..5e3ea6fee151 100644 --- a/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts @@ -20,7 +20,7 @@ import { } from '../../tasks/alerts'; import { USER_COLUMN } from '../../screens/alerts'; -describe.skip('user details flyout', () => { +describe('user details flyout', () => { beforeEach(() => { cleanKibana(); loginAndWaitForPageWithoutDateRange(ALERTS_URL); diff --git a/x-pack/plugins/security_solution/cypress/integration/users/users_anomalies_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/users/users_anomalies_tab.spec.ts new file mode 100644 index 000000000000..bb269e378c8b --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/users/users_anomalies_tab.spec.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ANOMALIES_TAB, ANOMALIES_TAB_CONTENT } from '../../screens/users/user_anomalies'; +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPage } from '../../tasks/login'; + +import { USERS_URL } from '../../urls/navigation'; + +describe('Users anomalies tab', () => { + before(() => { + cleanKibana(); + loginAndWaitForPage(USERS_URL); + }); + + it(`renders anomalies tab`, () => { + cy.get(ANOMALIES_TAB).click(); + + cy.get(ANOMALIES_TAB_CONTENT).should('exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/users/users_risk_score_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/users/users_risk_score_tab.spec.ts new file mode 100644 index 000000000000..c2d0751d6d11 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/users/users_risk_score_tab.spec.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RISK_SCORE_TAB_CONTENT, RISK_SCORE_TAB } from '../../screens/users/user_risk_score'; +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPage } from '../../tasks/login'; + +import { USERS_URL } from '../../urls/navigation'; + +describe('Users risk tab', () => { + before(() => { + cleanKibana(); + loginAndWaitForPage(USERS_URL); + }); + + it(`renders users risk tab`, () => { + cy.get(RISK_SCORE_TAB).click(); + + cy.get(RISK_SCORE_TAB_CONTENT).should('exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/objects/exception.ts b/x-pack/plugins/security_solution/cypress/objects/exception.ts index 1a70bb103832..637adc9fc013 100644 --- a/x-pack/plugins/security_solution/cypress/objects/exception.ts +++ b/x-pack/plugins/security_solution/cypress/objects/exception.ts @@ -22,6 +22,17 @@ export interface ExceptionList { type: 'detection' | 'endpoint'; } +export interface ExceptionListItem { + description: string; + list_id: string; + item_id: string; + name: string; + namespace_type: 'single' | 'agnostic'; + tags: string[]; + type: 'simple'; + entries: Array<{ field: string; operator: string; type: string; value: string[] }>; +} + export const getExceptionList = (): ExceptionList => ({ description: 'Test exception list description', list_id: 'test_exception_list', @@ -41,5 +52,5 @@ export const expectedExportedExceptionList = ( exceptionListResponse: Cypress.Response ): string => { const jsonrule = exceptionListResponse.body; - return `{"_version":"${jsonrule._version}","created_at":"${jsonrule.created_at}","created_by":"elastic","description":"${jsonrule.description}","id":"${jsonrule.id}","immutable":false,"list_id":"test_exception_list","name":"Test exception list","namespace_type":"single","os_types":[],"tags":[],"tie_breaker_id":"${jsonrule.tie_breaker_id}","type":"detection","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","version":1}\n{"exported_exception_list_count":1,"exported_exception_list_item_count":0,"missing_exception_list_item_count":0,"missing_exception_list_items":[],"missing_exception_lists":[],"missing_exception_lists_count":0}\n`; + return `{"_version":"${jsonrule._version}","created_at":"${jsonrule.created_at}","created_by":"elastic","description":"${jsonrule.description}","id":"${jsonrule.id}","immutable":false,"list_id":"${jsonrule.list_id}","name":"${jsonrule.name}","namespace_type":"single","os_types":[],"tags":[],"tie_breaker_id":"${jsonrule.tie_breaker_id}","type":"${jsonrule.type}","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","version":1}\n{"exported_exception_list_count":1,"exported_exception_list_item_count":0,"missing_exception_list_item_count":0,"missing_exception_list_items":[],"missing_exception_lists":[],"missing_exception_lists_count":0}\n`; }; diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 2f81c160f280..65e61c48ec64 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -59,6 +59,7 @@ export interface CustomRule { timeline: CompleteTimeline; maxSignals: number; buildingBlockType?: string; + exceptionLists?: Array<{ id: string; list_id: string; type: string; namespace_type: string }>; } export interface ThresholdRule extends CustomRule { diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index fc24202d38b5..acecb0a7f474 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -84,6 +84,6 @@ export const TIMELINE_CONTEXT_MENU_BTN = '[data-test-subj="timeline-context-menu export const USER_NAME = '[data-test-subj^=formatted-field][data-test-subj$=user\\.name]'; -export const ATTACH_ALERT_TO_CASE_BUTTON = '[data-test-subj="add-existing-case-menu-item"]'; +export const ATTACH_ALERT_TO_CASE_BUTTON = '[data-test-subj="add-to-existing-case-action"]'; export const USER_COLUMN = '[data-gridcell-column-id="user.name"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts index e1b9e0639dfa..c94c2be8b976 100644 --- a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts @@ -5,6 +5,8 @@ * 2.0. */ +export const EDIT_EXCEPTIONS_BTN = '[data-test-subj="exceptionsViewerEditBtn"]'; + export const ADD_EXCEPTIONS_BTN = '[data-test-subj="exceptionsHeaderAddExceptionBtn"]'; export const CLOSE_ALERTS_CHECKBOX = @@ -61,3 +63,10 @@ export const EXCEPTION_FIELD_LIST = '[data-test-subj="comboBoxOptionsList fieldAutocompleteComboBox-optionsList"]'; export const EXCEPTION_FLYOUT_TITLE = '[data-test-subj="exception-flyout-title"]'; + +export const EXCEPTION_EDIT_FLYOUT_SAVE_BTN = '[data-test-subj="edit-exception-confirm-button"]'; + +export const EXCEPTION_FLYOUT_VERSION_CONFLICT = + '[data-test-subj="exceptionsFlyoutVersionConflict"]'; + +export const EXCEPTION_FLYOUT_LIST_DELETED_ERROR = '[data-test-subj="errorCalloutContainer"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts b/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts index 4a5f813c301d..66a7ba50c807 100644 --- a/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts +++ b/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts @@ -7,20 +7,16 @@ export const CLOSE_BTN = '[data-test-subj="close"]'; -export const FIELDS_BROWSER_CATEGORIES_COUNT = '[data-test-subj="categories-count"]'; +export const FIELDS_BROWSER_CONTAINER = '[data-test-subj="fields-browser-container"]'; export const FIELDS_BROWSER_CHECKBOX = (id: string) => { - return `[data-test-subj="category-table-container"] [data-test-subj="field-${id}-checkbox"]`; + return `${FIELDS_BROWSER_CONTAINER} [data-test-subj="field-${id}-checkbox"]`; }; -export const FIELDS_BROWSER_CONTAINER = '[data-test-subj="fields-browser-container"]'; - export const FIELDS_BROWSER_FIELDS_COUNT = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="fields-count"]`; export const FIELDS_BROWSER_FILTER_INPUT = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="field-search"]`; -export const FIELDS_BROWSER_HOST_CATEGORIES_COUNT = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="host-category-count"]`; - export const FIELDS_BROWSER_HOST_GEO_CITY_NAME_CHECKBOX = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="field-host.geo.city_name-checkbox"]`; export const FIELDS_BROWSER_HOST_GEO_CITY_NAME_HEADER = @@ -38,8 +34,22 @@ export const FIELDS_BROWSER_MESSAGE_HEADER = export const FIELDS_BROWSER_RESET_FIELDS = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="reset-fields"]`; -export const FIELDS_BROWSER_SELECTED_CATEGORY_COUNT = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="selected-category-count-badge"]`; +export const FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="categories-filter-button"]`; +export const FIELDS_BROWSER_SELECTED_CATEGORY_COUNT = `${FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON} span.euiNotificationBadge`; +export const FIELDS_BROWSER_CATEGORIES_COUNT = `${FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON} span.euiNotificationBadge`; -export const FIELDS_BROWSER_SELECTED_CATEGORY_TITLE = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="selected-category-title"]`; +export const FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="category-badges"]`; +export const FIELDS_BROWSER_CATEGORY_BADGE = (id: string) => { + return `${FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES} [data-test-subj="category-badge-${id}"]`; +}; + +export const FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER = + '[data-test-subj="categories-selector-container"]'; +export const FIELDS_BROWSER_CATEGORIES_FILTER_SEARCH = + '[data-test-subj="categories-selector-search"]'; +export const FIELDS_BROWSER_CATEGORY_FILTER_OPTION = (id: string) => { + const idAttr = id.replace(/\s/g, ''); + return `${FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER} [data-test-subj="categories-selector-option-${idAttr}"]`; +}; export const FIELDS_BROWSER_SYSTEM_CATEGORIES_COUNT = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="system-category-count"]`; diff --git a/x-pack/plugins/security_solution/cypress/screens/hosts/host_risk.ts b/x-pack/plugins/security_solution/cypress/screens/hosts/host_risk.ts index 58331518255d..e6e0bcaf1e99 100644 --- a/x-pack/plugins/security_solution/cypress/screens/hosts/host_risk.ts +++ b/x-pack/plugins/security_solution/cypress/screens/hosts/host_risk.ts @@ -20,7 +20,17 @@ export const HOST_BY_RISK_TABLE = '.table-hostRisk-loading-false'; export const HOST_BY_RISK_TABLE_CELL = '[data-test-subj="table-hostRisk-loading-false"] .euiTableCellContent'; -export const HOST_BY_RISK_TABLE_FILTER = '[data-test-subj="host-risk-filter-button"]'; +export const HOST_BY_RISK_TABLE_FILTER = '[data-test-subj="risk-filter-button"]'; -export const HOST_BY_RISK_TABLE_FILTER_CRITICAL = - '[data-test-subj="host-risk-filter-item-Critical"]'; +export const HOST_BY_RISK_TABLE_FILTER_CRITICAL = '[data-test-subj="risk-filter-item-Critical"]'; + +export const HOST_BY_RISK_TABLE_PERPAGE_BUTTON = + '[data-test-subj="loadingMoreSizeRowPopover"] button'; + +export const HOST_BY_RISK_TABLE_PERPAGE_OPTIONS = + '[data-test-subj="loadingMorePickSizeRow"] button'; + +export const HOST_BY_RISK_TABLE_NEXT_PAGE_BUTTON = + '[data-test-subj="numberedPagination"] [data-test-subj="pagination-button-next"]'; + +export const HOST_BY_RISK_TABLE_HOSTNAME_CELL = '[data-test-subj="render-content-host.name"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/overview.ts b/x-pack/plugins/security_solution/cypress/screens/overview.ts index bc335ff6680e..e478f16e7284 100644 --- a/x-pack/plugins/security_solution/cypress/screens/overview.ts +++ b/x-pack/plugins/security_solution/cypress/screens/overview.ts @@ -150,9 +150,6 @@ export const OVERVIEW_REVENT_TIMELINES = '[data-test-subj="overview-recent-timel export const OVERVIEW_CTI_LINKS = '[data-test-subj="cti-dashboard-links"]'; export const OVERVIEW_CTI_LINKS_ERROR_INNER_PANEL = '[data-test-subj="cti-inner-panel-danger"]'; -export const OVERVIEW_CTI_LINKS_INFO_INNER_PANEL = '[data-test-subj="cti-inner-panel-info"]'; -export const OVERVIEW_CTI_ENABLE_INTEGRATIONS_BUTTON = - '[data-test-subj="cti-enable-integrations-button"]'; export const OVERVIEW_CTI_TOTAL_EVENT_COUNT = `${OVERVIEW_CTI_LINKS} [data-test-subj="header-panel-subtitle"]`; export const OVERVIEW_CTI_ENABLE_MODULE_BUTTON = '[data-test-subj="cti-enable-module-button"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts index a057f27df428..a9134e5b124e 100644 --- a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts @@ -33,6 +33,8 @@ export const DETAILS_TITLE = '.euiDescriptionList__title'; export const EXCEPTIONS_TAB = '[data-test-subj="exceptionsTab"]'; +export const EXCEPTIONS_TAB_SEARCH = '[data-test-subj="exceptionsHeaderSearch"]'; + export const FALSE_POSITIVES_DETAILS = 'False positive examples'; export const INDEX_PATTERNS_DETAILS = 'Index patterns'; diff --git a/x-pack/plugins/security_solution/cypress/screens/users/all_users.ts b/x-pack/plugins/security_solution/cypress/screens/users/all_users.ts index f77c9036970c..a1d6e9edebf5 100644 --- a/x-pack/plugins/security_solution/cypress/screens/users/all_users.ts +++ b/x-pack/plugins/security_solution/cypress/screens/users/all_users.ts @@ -6,3 +6,7 @@ */ export const ALL_USERS_TABLE = '[data-test-subj="table-authentications-loading-false"]'; + +export const HEADER_SUBTITLE = '[data-test-subj="header-panel-subtitle"]'; + +export const USER_NAME_CELL = '[data-test-subj="render-content-user.name"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/users/user_anomalies.ts b/x-pack/plugins/security_solution/cypress/screens/users/user_anomalies.ts new file mode 100644 index 000000000000..ce5b44dda19a --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/users/user_anomalies.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 const ANOMALIES_TAB = '[data-test-subj="navigation-anomalies"]'; +export const ANOMALIES_TAB_CONTENT = '[data-test-subj="user-anomalies-tab"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/users/user_risk_score.ts b/x-pack/plugins/security_solution/cypress/screens/users/user_risk_score.ts new file mode 100644 index 000000000000..816334d7fc19 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/users/user_risk_score.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 const RISK_SCORE_TAB = '[data-test-subj="navigation-userRisk"]'; +export const RISK_SCORE_TAB_CONTENT = '[data-test-subj="table-userRisk-loading-false"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 8475ef7247c2..8d125c242be3 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -42,7 +42,6 @@ import { RULE_IMPORT_MODAL_BUTTON, RULE_IMPORT_MODAL, INPUT_FILE, - TOASTER, RULE_IMPORT_OVERWRITE_CHECKBOX, RULE_IMPORT_OVERWRITE_EXCEPTIONS_CHECKBOX, RULES_TAGS_POPOVER_BTN, @@ -233,7 +232,7 @@ export const changeRowsPerPageTo = (rowsCount: number) => { cy.get(PAGINATION_POPOVER_BTN).click({ force: true }); cy.get(rowsPerPageSelector(rowsCount)) .pipe(($el) => $el.trigger('click')) - .should('not.be.visible'); + .should('not.exist'); }; export const changeRowsPerPageTo100 = () => { @@ -253,24 +252,6 @@ export const importRules = (rulesFile: string) => { cy.get(INPUT_FILE).should('not.exist'); }; -export const getRulesImportExportToast = (headers: string[]) => { - cy.get(TOASTER) - .should('exist') - .then(($els) => { - const arrayOfText = Cypress.$.makeArray($els).map((el) => el.innerText); - - return headers.reduce((areAllIncluded, header) => { - const isContained = arrayOfText.includes(header); - if (!areAllIncluded) { - return false; - } else { - return isContained; - } - }, true); - }) - .should('be.true'); -}; - export const selectOverwriteRulesImport = () => { cy.get(RULE_IMPORT_OVERWRITE_CHECKBOX) .pipe(($el) => $el.trigger('click')) diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts index 7363bd5991b1..ab6c649c7c61 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ExceptionList } from '../../objects/exception'; +import { ExceptionList, ExceptionListItem } from '../../objects/exception'; export const createExceptionList = ( exceptionList: ExceptionList, @@ -23,3 +23,58 @@ export const createExceptionList = ( headers: { 'kbn-xsrf': 'cypress-creds' }, failOnStatusCode: false, }); + +export const createExceptionListItem = ( + exceptionListId: string, + exceptionListItem?: ExceptionListItem +) => + cy.request({ + method: 'POST', + url: '/api/exception_lists/items', + body: { + list_id: exceptionListItem?.list_id ?? exceptionListId, + item_id: exceptionListItem?.item_id ?? 'simple_list_item', + tags: exceptionListItem?.tags ?? ['user added string for a tag', 'malware'], + type: exceptionListItem?.type ?? 'simple', + description: exceptionListItem?.description ?? 'This is a sample endpoint type exception', + name: exceptionListItem?.name ?? 'Sample Exception List Item', + entries: exceptionListItem?.entries ?? [ + { + field: 'actingProcess.file.signer', + operator: 'excluded', + type: 'exists', + }, + { + field: 'host.name', + operator: 'included', + type: 'match_any', + value: ['some host', 'another host'], + }, + ], + }, + headers: { 'kbn-xsrf': 'cypress-creds' }, + failOnStatusCode: false, + }); + +export const updateExceptionListItem = ( + exceptionListItemId: string, + exceptionListItemUpdate?: Partial +) => + cy.request({ + method: 'PUT', + url: '/api/exception_lists/items', + body: { + item_id: exceptionListItemId, + ...exceptionListItemUpdate, + }, + headers: { 'kbn-xsrf': 'cypress-creds' }, + failOnStatusCode: false, + }); + +export const deleteExceptionList = (listId: string, namespaceType: string) => + cy.request({ + method: 'DELETE', + url: `/api/exception_lists?list_id=${listId}&namespace_type=${namespaceType}`, + headers: { 'kbn-xsrf': 'cypress-creds' }, + failOnStatusCode: false, + }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts index 13ba3af59be9..405c11814039 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts @@ -24,6 +24,7 @@ export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing', inte query: rule.customQuery, language: 'kuery', enabled: false, + exceptions_list: rule.exceptionLists ?? [], }, headers: { 'kbn-xsrf': 'cypress-creds' }, failOnStatusCode: false, diff --git a/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts b/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts index ee8bdb3b023d..04b59305b591 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts @@ -13,6 +13,9 @@ import { FIELDS_BROWSER_RESET_FIELDS, FIELDS_BROWSER_CHECKBOX, CLOSE_BTN, + FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON, + FIELDS_BROWSER_CATEGORY_FILTER_OPTION, + FIELDS_BROWSER_CATEGORIES_FILTER_SEARCH, } from '../screens/fields_browser'; export const addsFields = (fields: string[]) => { @@ -34,17 +37,32 @@ export const addsHostGeoContinentNameToTimeline = () => { }; export const clearFieldsBrowser = () => { - cy.get(FIELDS_BROWSER_FILTER_INPUT).type('{selectall}{backspace}'); + cy.get(FIELDS_BROWSER_FILTER_INPUT) + .type('{selectall}{backspace}') + .waitUntil((subject) => !subject.hasClass('euiFieldSearch-isLoading')); }; export const closeFieldsBrowser = () => { cy.get(CLOSE_BTN).click({ force: true }); + cy.get(FIELDS_BROWSER_FILTER_INPUT).should('not.exist'); }; export const filterFieldsBrowser = (fieldName: string) => { cy.get(FIELDS_BROWSER_FILTER_INPUT) + .clear() .type(fieldName) - .should('not.have.class', 'euiFieldSearch-isLoading'); + .waitUntil((subject) => !subject.hasClass('euiFieldSearch-isLoading')); +}; + +export const toggleCategoryFilter = () => { + cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON).click({ force: true }); +}; + +export const toggleCategory = (category: string) => { + toggleCategoryFilter(); + cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_SEARCH).clear().type(category); + cy.get(FIELDS_BROWSER_CATEGORY_FILTER_OPTION(category)).click({ force: true }); + toggleCategoryFilter(); }; export const removesMessageField = () => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/host_risk.ts b/x-pack/plugins/security_solution/cypress/tasks/host_risk.ts index 7a357e8a5c7f..afa04bb6de0c 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/host_risk.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/host_risk.ts @@ -5,7 +5,15 @@ * 2.0. */ -import { LOADING_TABLE, RISK_DETAILS_NAV, RISK_FLYOUT_TRIGGER } from '../screens/hosts/host_risk'; +import { + HOST_BY_RISK_TABLE_FILTER, + HOST_BY_RISK_TABLE_FILTER_CRITICAL, + HOST_BY_RISK_TABLE_PERPAGE_BUTTON, + HOST_BY_RISK_TABLE_PERPAGE_OPTIONS, + LOADING_TABLE, + RISK_DETAILS_NAV, + RISK_FLYOUT_TRIGGER, +} from '../screens/hosts/host_risk'; export const navigateToHostRiskDetailTab = () => cy.get(RISK_DETAILS_NAV).click(); @@ -15,3 +23,15 @@ export const waitForTableToLoad = () => { cy.get(LOADING_TABLE).should('exist'); cy.get(LOADING_TABLE).should('not.exist'); }; + +export const openRiskTableFilterAndSelectTheCriticalOption = () => { + cy.get(HOST_BY_RISK_TABLE_FILTER).click(); + cy.get(HOST_BY_RISK_TABLE_FILTER_CRITICAL).click(); +}; +export const removeCritialFilter = () => { + cy.get(HOST_BY_RISK_TABLE_FILTER_CRITICAL).click(); +}; +export const selectFiveItemsPerPageOption = () => { + cy.get(HOST_BY_RISK_TABLE_PERPAGE_BUTTON).click(); + cy.get(HOST_BY_RISK_TABLE_PERPAGE_OPTIONS).first().click(); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index e69c217c4a76..d42ebcf9da68 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -91,7 +91,12 @@ export const goToAlertsTab = () => { }; export const goToExceptionsTab = () => { - cy.get(EXCEPTIONS_TAB).click(); + cy.root() + .pipe(($el) => { + $el.find(EXCEPTIONS_TAB).trigger('click'); + return $el.find(ADD_EXCEPTIONS_BTN); + }) + .should('be.visible'); }; export const removeException = () => { diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index fa2a1a99712d..01b38fc70679 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -256,6 +256,24 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [ }), ], order: 9004, + deepLinks: [ + { + id: SecurityPageName.usersAnomalies, + title: i18n.translate('xpack.securitySolution.search.users.anomalies', { + defaultMessage: 'Anomalies', + }), + path: `${USERS_PATH}/anomalies`, + isPremium: true, + }, + { + id: SecurityPageName.usersRisk, + title: i18n.translate('xpack.securitySolution.search.users.risk', { + defaultMessage: 'Risk', + }), + path: `${USERS_PATH}/userRisk`, + isPremium: true, + }, + ], }, ], }, diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.tsx index ba592f0ccc61..5b5e45556341 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/index.tsx @@ -43,7 +43,7 @@ const TimelineDetailsPanel = () => { }; const CaseContainerComponent: React.FC = () => { - const { cases: casesUi } = useKibana().services; + const { cases } = useKibana().services; const { getAppUrl, navigateTo } = useNavigation(); const userPermissions = useGetUserCasesPermissions(); const dispatch = useDispatch(); @@ -98,7 +98,7 @@ const CaseContainerComponent: React.FC = () => { return ( - {casesUi.getCases({ + {cases.ui.getCases({ basePath: CASES_PATH, owner: [APP_ID], features: { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.tsx index dbf73f7a6654..b429a8a9f234 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.tsx @@ -11,9 +11,9 @@ import { FormattedMessage } from '@kbn/i18n-react'; import * as i18n from './translations'; import { RISKY_HOSTS_DOC_LINK } from '../../../../overview/components/overview_risky_host_links/risky_hosts_disabled_module'; import { EnrichedDataRow, ThreatSummaryPanelHeader } from './threat_summary_view'; -import { HostRisk } from '../../../containers/hosts_risk/types'; -import { HostRiskScore } from '../../../../hosts/components/common/host_risk_score'; -import { HostRiskSeverity } from '../../../../../common/search_strategy'; +import { RiskScore } from '../../severity/common'; +import { RiskSeverity } from '../../../../../common/search_strategy'; +import { HostRisk } from '../../../../risk_score/containers'; const HostRiskSummaryComponent: React.FC<{ hostRisk: HostRisk; @@ -56,10 +56,7 @@ const HostRiskSummaryComponent: React.FC<{ + } /> diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx index 4da090bfa106..5de2ea5c6235 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx @@ -30,7 +30,7 @@ import { } from '../../../../../common/search_strategy'; import { HostRiskSummary } from './host_risk_summary'; import { EnrichmentSummary } from './enrichment_summary'; -import { HostRisk } from '../../../containers/hosts_risk/types'; +import { HostRisk } from '../../../../risk_score/containers'; export interface ThreatSummaryDescription { browserField: BrowserField; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 13eadfc53ae4..3eb7bd935a9f 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -40,7 +40,7 @@ import { EnrichmentRangePicker } from './cti_details/enrichment_range_picker'; import { Reason } from './reason'; import { InvestigationGuideView } from './investigation_guide_view'; import { Overview } from './overview'; -import { HostRisk } from '../../containers/hosts_risk/types'; +import { HostRisk } from '../../../risk_score/containers'; type EventViewTab = EuiTabbedContentTab; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx index 2ecae4448790..cdc9cc9b6f32 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx @@ -34,7 +34,7 @@ jest.mock('../../../timelines/containers', () => ({ jest.mock('../../components/url_state/normalize_time_range.ts'); const mockUseCreateFieldButton = jest.fn().mockReturnValue(<>); -jest.mock('../../../timelines/components/create_field_button', () => ({ +jest.mock('../../../timelines/components/fields_browser/create_field_button', () => ({ useCreateFieldButton: (...params: unknown[]) => mockUseCreateFieldButton(...params), })); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index a9fd9a5d9d44..5f88bb3f9aab 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -13,7 +13,7 @@ import type { Filter } from '@kbn/es-query'; import { inputsModel, inputsSelectors, State } from '../../store'; import { inputsActions } from '../../store/actions'; import { ControlColumnProps, RowRenderer, TimelineId } from '../../../../common/types/timeline'; -import { APP_UI_ID } from '../../../../common/constants'; +import { APP_ID, APP_UI_ID } from '../../../../common/constants'; import { timelineSelectors, timelineActions } from '../../../timelines/store/timeline'; import type { SubsetTimelineModel, TimelineModel } from '../../../timelines/store/timeline/model'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; @@ -27,12 +27,12 @@ import { TGridCellAction } from '../../../../../timelines/common/types'; import { DetailsPanel } from '../../../timelines/components/side_panel'; import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; import { FIELDS_WITHOUT_CELL_ACTIONS } from '../../lib/cell_actions/constants'; -import { useKibana } from '../../lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../lib/kibana'; import { GraphOverlay } from '../../../timelines/components/graph_overlay'; import { + useFieldBrowserOptions, CreateFieldEditorActions, - useCreateFieldButton, -} from '../../../timelines/components/create_field_button'; +} from '../../../timelines/components/fields_browser'; const EMPTY_CONTROL_COLUMNS: ControlColumnProps[] = []; @@ -109,7 +109,7 @@ const StatefulEventsViewerComponent: React.FC = ({ unit, }) => { const dispatch = useDispatch(); - const { timelines: timelinesUi } = useKibana().services; + const { timelines: timelinesUi, cases } = useKibana().services; const { browserFields, dataViewId, @@ -177,65 +177,74 @@ const StatefulEventsViewerComponent: React.FC = ({ }, [id, timelineQuery, globalQuery]); const bulkActions = useMemo(() => ({ onAlertStatusActionSuccess }), [onAlertStatusActionSuccess]); - const createFieldComponent = useCreateFieldButton(scopeId, id, editorActionsRef); + const fieldBrowserOptions = useFieldBrowserOptions({ + sourcererScope: scopeId, + timelineId: id, + editorActionsRef, + }); + + const casesPermissions = useGetUserCasesPermissions(); + const CasesContext = cases.ui.getCasesContext(); return ( <> - - - {timelinesUi.getTGrid<'embedded'>({ - additionalFilters, - appId: APP_UI_ID, - browserFields, - bulkActions, - columns, - dataProviders, - dataViewId, - defaultCellActions, - deletedEventIds, - disabledCellActions: FIELDS_WITHOUT_CELL_ACTIONS, - docValueFields, - end, - entityType, - filters: globalFilters, - filterStatus: currentFilter, - globalFullScreen, - graphEventId, - graphOverlay, - hasAlertsCrud, - id, - indexNames: selectedPatterns, - indexPattern, - isLive, - isLoadingIndexPattern, - itemsPerPage, - itemsPerPageOptions, - kqlMode, - leadingControlColumns, - onRuleChange, - query, - renderCellValue, - rowRenderers, - runtimeMappings, - setQuery, - sort, - start, - tGridEventRenderedViewEnabled, - trailingControlColumns, - type: 'embedded', - unit, - createFieldComponent, - })} - - - + + + + {timelinesUi.getTGrid<'embedded'>({ + additionalFilters, + appId: APP_UI_ID, + browserFields, + bulkActions, + columns, + dataProviders, + dataViewId, + defaultCellActions, + deletedEventIds, + disabledCellActions: FIELDS_WITHOUT_CELL_ACTIONS, + docValueFields, + end, + entityType, + fieldBrowserOptions, + filters: globalFilters, + filterStatus: currentFilter, + globalFullScreen, + graphEventId, + graphOverlay, + hasAlertsCrud, + id, + indexNames: selectedPatterns, + indexPattern, + isLive, + isLoadingIndexPattern, + itemsPerPage, + itemsPerPageOptions, + kqlMode, + leadingControlColumns, + onRuleChange, + query, + renderCellValue, + rowRenderers, + runtimeMappings, + setQuery, + sort, + start, + tGridEventRenderedViewEnabled, + trailingControlColumns, + type: 'embedded', + unit, + })} + + + + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_flyout/index.tsx index 6d01908732ec..0ed810b4ad26 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_flyout/index.tsx @@ -410,27 +410,35 @@ export const EditExceptionFlyout = memo(function EditExceptionFlyout({ )} - {updateError != null && ( - - - - )} - {hasVersionConflict && ( - - -

    {i18n.VERSION_CONFLICT_ERROR_DESCRIPTION}

    -
    -
    - )} - {updateError == null && ( - + + + {hasVersionConflict && ( + <> + +

    {i18n.VERSION_CONFLICT_ERROR_DESCRIPTION}

    +
    + + + )} + {updateError != null && ( + <> + + + + )} + {updateError === null && ( {i18n.CANCEL} @@ -446,8 +454,8 @@ export const EditExceptionFlyout = memo(function EditExceptionFlyout({ {i18n.EDIT_EXCEPTION_SAVE_BUTTON} -
    - )} + )} +
    ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.tsx index eca259a905af..ab76c848ce16 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.tsx @@ -57,6 +57,8 @@ const UserDetailsLinkComponent: React.FC<{ isButton?: boolean; onClick?: (e: SyntheticEvent) => void; }> = ({ children, Component, userName, isButton, onClick, title }) => { + const encodedUserName = encodeURIComponent(userName); + const { formatUrl, search } = useFormatUrl(SecurityPageName.users); const { navigateToApp } = useKibana().services.application; const goToUsersDetails = useCallback( @@ -64,19 +66,19 @@ const UserDetailsLinkComponent: React.FC<{ ev.preventDefault(); navigateToApp(APP_UI_ID, { deepLinkId: SecurityPageName.users, - path: getUsersDetailsUrl(encodeURIComponent(userName), search), + path: getUsersDetailsUrl(encodedUserName, search), }); }, - [userName, navigateToApp, search] + [encodedUserName, navigateToApp, search] ); return isButton ? ( {children ? children : userName} @@ -84,7 +86,7 @@ const UserDetailsLinkComponent: React.FC<{ {children ? children : userName} diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx index f3ee7bb89e4c..cfc24f1fd2ba 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx @@ -65,7 +65,7 @@ const AnomaliesUserTableComponent: React.FC = ({ return null; } else { return ( - + { ); expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeFalsy(); }); + + test('Should hide pagination if totalCount is zero', () => { + const wrapper = mount( + + {'My test supplement.'}

    } + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={DEFAULT_MAX_TABLE_QUERY_SIZE} + loading={false} + loadPage={loadPage} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={0} + updateActivePage={updateActivePage} + updateLimitPagination={(limit) => updateLimitPagination({ limit })} + /> +
    + ); + + expect(wrapper.find('[data-test-subj="numberedPagination"]').exists()).toBeFalsy(); + }); }); describe('Events', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx index 6100c03d38bf..310ab039057c 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx @@ -303,12 +303,14 @@ const PaginatedTableComponent: FC = ({ - + {totalCount > 0 && ( + + )} {(isInspect || myLoading) && ( diff --git a/x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.test.tsx b/x-pack/plugins/security_solution/public/common/components/severity/common/index.test.tsx similarity index 70% rename from x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.test.tsx rename to x-pack/plugins/security_solution/public/common/components/severity/common/index.test.tsx index d7e099316cb1..4bfb0eccdf6b 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/severity/common/index.test.tsx @@ -7,13 +7,14 @@ import { render } from '@testing-library/react'; import React from 'react'; -import { HostRiskSeverity } from '../../../../common/search_strategy'; -import { TestProviders } from '../../../common/mock'; -import { HostRiskScore } from './host_risk_score'; + +import { TestProviders } from '../../../mock'; import { EuiHealth, EuiHealthProps } from '@elastic/eui'; import { euiThemeVars } from '@kbn/ui-theme'; +import { RiskSeverity } from '../../../../../common/search_strategy'; +import { RiskScore } from '.'; jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -23,16 +24,16 @@ jest.mock('@elastic/eui', () => { }; }); -describe('HostRiskScore', () => { +describe('RiskScore', () => { const context = {}; it('renders critical severity risk score', () => { const { container } = render( - + ); - expect(container).toHaveTextContent(HostRiskSeverity.critical); + expect(container).toHaveTextContent(RiskSeverity.critical); expect(EuiHealth as jest.Mock).toHaveBeenLastCalledWith( expect.objectContaining({ color: euiThemeVars.euiColorDanger }), @@ -43,11 +44,11 @@ describe('HostRiskScore', () => { it('renders hight severity risk score', () => { const { container } = render( - + ); - expect(container).toHaveTextContent(HostRiskSeverity.high); + expect(container).toHaveTextContent(RiskSeverity.high); expect(EuiHealth as jest.Mock).toHaveBeenLastCalledWith( expect.objectContaining({ color: euiThemeVars.euiColorVis9_behindText }), @@ -58,11 +59,11 @@ describe('HostRiskScore', () => { it('renders moderate severity risk score', () => { const { container } = render( - + ); - expect(container).toHaveTextContent(HostRiskSeverity.moderate); + expect(container).toHaveTextContent(RiskSeverity.moderate); expect(EuiHealth as jest.Mock).toHaveBeenLastCalledWith( expect.objectContaining({ color: euiThemeVars.euiColorWarning }), @@ -73,11 +74,11 @@ describe('HostRiskScore', () => { it('renders low severity risk score', () => { const { container } = render( - + ); - expect(container).toHaveTextContent(HostRiskSeverity.low); + expect(container).toHaveTextContent(RiskSeverity.low); expect(EuiHealth as jest.Mock).toHaveBeenLastCalledWith( expect.objectContaining({ color: euiThemeVars.euiColorVis0 }), @@ -88,11 +89,11 @@ describe('HostRiskScore', () => { it('renders unknown severity risk score', () => { const { container } = render( - + ); - expect(container).toHaveTextContent(HostRiskSeverity.unknown); + expect(container).toHaveTextContent(RiskSeverity.unknown); expect(EuiHealth as jest.Mock).toHaveBeenLastCalledWith( expect.objectContaining({ color: euiThemeVars.euiColorMediumShade }), @@ -103,10 +104,10 @@ describe('HostRiskScore', () => { it("doesn't render background-color when hideBackgroundColor is true", () => { const { queryByTestId } = render( - + ); - expect(queryByTestId('host-risk-score')).toHaveStyleRule('background-color', undefined); + expect(queryByTestId('risk-score')).toHaveStyleRule('background-color', undefined); }); }); diff --git a/x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.tsx b/x-pack/plugins/security_solution/public/common/components/severity/common/index.tsx similarity index 62% rename from x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.tsx rename to x-pack/plugins/security_solution/public/common/components/severity/common/index.tsx index 39909b736a61..a8bc7c20f7fc 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.tsx +++ b/x-pack/plugins/security_solution/public/common/components/severity/common/index.tsx @@ -11,18 +11,19 @@ import { EuiHealth, transparentize } from '@elastic/eui'; import styled, { css } from 'styled-components'; import { euiLightVars } from '@kbn/ui-theme'; -import { HostRiskSeverity } from '../../../../common/search_strategy'; -import { WithHoverActions } from '../../../common/components/with_hover_actions'; -export const HOST_RISK_SEVERITY_COLOUR: { [k in HostRiskSeverity]: string } = { - [HostRiskSeverity.unknown]: euiLightVars.euiColorMediumShade, - [HostRiskSeverity.low]: euiLightVars.euiColorVis0, - [HostRiskSeverity.moderate]: euiLightVars.euiColorWarning, - [HostRiskSeverity.high]: euiLightVars.euiColorVis9_behindText, - [HostRiskSeverity.critical]: euiLightVars.euiColorDanger, +import { WithHoverActions } from '../../with_hover_actions'; +import { RiskSeverity } from '../../../../../common/search_strategy'; + +export const RISK_SEVERITY_COLOUR: { [k in RiskSeverity]: string } = { + [RiskSeverity.unknown]: euiLightVars.euiColorMediumShade, + [RiskSeverity.low]: euiLightVars.euiColorVis0, + [RiskSeverity.moderate]: euiLightVars.euiColorWarning, + [RiskSeverity.high]: euiLightVars.euiColorVis9_behindText, + [RiskSeverity.critical]: euiLightVars.euiColorDanger, }; -const HostRiskBadge = styled.div<{ $severity: HostRiskSeverity; $hideBackgroundColor: boolean }>` +const RiskBadge = styled.div<{ $severity: RiskSeverity; $hideBackgroundColor: boolean }>` ${({ theme, $severity, $hideBackgroundColor }) => css` width: fit-content; padding-right: ${theme.eui.paddingSizes.s}; @@ -39,22 +40,22 @@ const HostRiskBadge = styled.div<{ $severity: HostRiskSeverity; $hideBackgroundC const TooltipContainer = styled.div` padding: ${({ theme }) => theme.eui.paddingSizes.s}; `; -export const HostRiskScore: React.FC<{ - severity: HostRiskSeverity; +export const RiskScore: React.FC<{ + severity: RiskSeverity; hideBackgroundColor?: boolean; toolTipContent?: JSX.Element; }> = ({ severity, hideBackgroundColor = false, toolTipContent }) => { const badge = ( - - + {severity} - + ); if (toolTipContent != null) { diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/severity_badges.tsx b/x-pack/plugins/security_solution/public/common/components/severity/severity_badges.tsx similarity index 74% rename from x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/severity_badges.tsx rename to x-pack/plugins/security_solution/public/common/components/severity/severity_badges.tsx index 655a11a8da42..4a95303a1492 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/severity_badges.tsx +++ b/x-pack/plugins/security_solution/public/common/components/severity/severity_badges.tsx @@ -7,9 +7,9 @@ import { EuiFlexGroup, EuiNotificationBadge, EuiFlexItem } from '@elastic/eui'; import React from 'react'; -import { HOST_RISK_SEVERITY_COLOUR, HostRiskScore } from '../common/host_risk_score'; -import { HostRiskSeverity } from '../../../../common/search_strategy'; -import { SeverityCount } from '../../containers/kpi_hosts/risky_hosts'; +import { RiskSeverity } from '../../../../common/search_strategy'; +import { RiskScore, RISK_SEVERITY_COLOUR } from './common'; +import { SeverityCount } from './types'; export const SeverityBadges: React.FC<{ severityCount: SeverityCount; @@ -22,7 +22,7 @@ export const SeverityBadges: React.FC<{ - {(Object.keys(HOST_RISK_SEVERITY_COLOUR) as HostRiskSeverity[]).map((status) => ( + {(Object.keys(RISK_SEVERITY_COLOUR) as RiskSeverity[]).map((status) => ( @@ -34,11 +34,11 @@ export const SeverityBadges: React.FC<{ SeverityBadges.displayName = 'SeverityBadges'; -const SeverityBadge: React.FC<{ status: HostRiskSeverity; count: number }> = React.memo( +const SeverityBadge: React.FC<{ status: RiskSeverity; count: number }> = React.memo( ({ status, count }) => ( - + diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/severity_bar.tsx b/x-pack/plugins/security_solution/public/common/components/severity/severity_bar.tsx similarity index 76% rename from x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/severity_bar.tsx rename to x-pack/plugins/security_solution/public/common/components/severity/severity_bar.tsx index 9522e84333e3..69e0863ea8e0 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/severity_bar.tsx +++ b/x-pack/plugins/security_solution/public/common/components/severity/severity_bar.tsx @@ -9,9 +9,9 @@ import styled from 'styled-components'; import { EuiColorPaletteDisplay } from '@elastic/eui'; import React, { useMemo } from 'react'; -import { HostRiskSeverity } from '../../../../common/search_strategy'; -import { HOST_RISK_SEVERITY_COLOUR } from '../common/host_risk_score'; -import { SeverityCount } from '../../containers/kpi_hosts/risky_hosts'; +import { RiskSeverity } from '../../../../common/search_strategy'; +import { RISK_SEVERITY_COLOUR } from './common'; +import { SeverityCount } from './types'; const StyledEuiColorPaletteDisplay = styled(EuiColorPaletteDisplay)` &.risk-score-severity-bar { @@ -33,12 +33,12 @@ export const SeverityBar: React.FC<{ }> = ({ severityCount }) => { const palette = useMemo( () => - (Object.keys(HOST_RISK_SEVERITY_COLOUR) as HostRiskSeverity[]).reduce( - (acc: PalletteArray, status: HostRiskSeverity) => { + (Object.keys(RISK_SEVERITY_COLOUR) as RiskSeverity[]).reduce( + (acc: PalletteArray, status: RiskSeverity) => { const previousStop = acc.length > 0 ? acc[acc.length - 1].stop : 0; const newEntry: PalletteObject = { stop: previousStop + (severityCount[status] || 0), - color: HOST_RISK_SEVERITY_COLOUR[status], + color: RISK_SEVERITY_COLOUR[status], }; acc.push(newEntry); return acc; diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/severity_filter_group.tsx b/x-pack/plugins/security_solution/public/common/components/severity/severity_filter_group.tsx similarity index 57% rename from x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/severity_filter_group.tsx rename to x-pack/plugins/security_solution/public/common/components/severity/severity_filter_group.tsx index 656129aec3e5..7922aebe07c8 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/severity_filter_group.tsx +++ b/x-pack/plugins/security_solution/public/common/components/severity/severity_filter_group.tsx @@ -14,28 +14,24 @@ import { FilterChecked, useGeneratedHtmlId, } from '@elastic/eui'; -import { useDispatch } from 'react-redux'; -import { HostRiskSeverity } from '../../../../common/search_strategy'; -import * as i18n from './translations'; -import { hostsActions, hostsModel, hostsSelectors } from '../../store'; -import { SeverityCount } from '../../containers/kpi_hosts/risky_hosts'; -import { HostRiskScore } from '../common/host_risk_score'; -import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; -import { State } from '../../../common/store'; + +import { RiskSeverity } from '../../../../common/search_strategy'; +import { SeverityCount } from './types'; +import { RiskScore } from './common'; interface SeverityItems { - risk: HostRiskSeverity; + risk: RiskSeverity; count: number; checked?: FilterChecked; } export const SeverityFilterGroup: React.FC<{ severityCount: SeverityCount; - type: hostsModel.HostsType; -}> = ({ severityCount, type }) => { + selectedSeverities: RiskSeverity[]; + onSelect: (newSelection: RiskSeverity[]) => void; + title: string; +}> = ({ severityCount, selectedSeverities, onSelect, title }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const dispatch = useDispatch(); - const onButtonClick = useCallback(() => { setIsPopoverOpen(!isPopoverOpen); }, [isPopoverOpen]); @@ -47,40 +43,29 @@ export const SeverityFilterGroup: React.FC<{ const filterGroupPopoverId = useGeneratedHtmlId({ prefix: 'filterGroupPopover', }); - const getHostRiskScoreFilterQuerySelector = useMemo( - () => hostsSelectors.hostRiskScoreSeverityFilterSelector(), - [] - ); - const severitySelectionRedux = useDeepEqualSelector((state: State) => - getHostRiskScoreFilterQuerySelector(state, type) - ); const items: SeverityItems[] = useMemo(() => { const checked: FilterChecked = 'on'; - return (Object.keys(severityCount) as HostRiskSeverity[]).map((k) => ({ + return (Object.keys(severityCount) as RiskSeverity[]).map((k) => ({ risk: k, count: severityCount[k], - checked: severitySelectionRedux.includes(k) ? checked : undefined, + checked: selectedSeverities.includes(k) ? checked : undefined, })); - }, [severityCount, severitySelectionRedux]); + }, [severityCount, selectedSeverities]); const updateSeverityFilter = useCallback( - (selectedSeverity: HostRiskSeverity) => { - const currentSelection = severitySelectionRedux ?? []; + (selectedSeverity: RiskSeverity) => { + const currentSelection = selectedSeverities ?? []; const newSelection = currentSelection.includes(selectedSeverity) ? currentSelection.filter((s) => s !== selectedSeverity) : [...currentSelection, selectedSeverity]; - dispatch( - hostsActions.updateHostRiskScoreSeverityFilter({ - severitySelection: newSelection, - hostsType: type, - }) - ); + + onSelect(newSelection); }, - [dispatch, severitySelectionRedux, type] + [selectedSeverities, onSelect] ); - const totalActiveHosts = useMemo( + const totalActiveItem = useMemo( () => items.reduce((total, item) => (item.checked === 'on' ? total + item.count : total), 0), [items] ); @@ -88,17 +73,17 @@ export const SeverityFilterGroup: React.FC<{ const button = useMemo( () => ( item.checked === 'on')} iconType="arrowDown" isSelected={isPopoverOpen} - numActiveFilters={totalActiveHosts} + numActiveFilters={totalActiveItem} onClick={onButtonClick} > - {i18n.HOST_RISK} + {title} ), - [isPopoverOpen, items, onButtonClick, totalActiveHosts] + [isPopoverOpen, items, onButtonClick, totalActiveItem, title] ); return ( @@ -113,12 +98,12 @@ export const SeverityFilterGroup: React.FC<{
    {items.map((item, index) => ( updateSeverityFilter(item.risk)} > - + ))}
    diff --git a/x-pack/plugins/security_solution/public/common/components/severity/types.ts b/x-pack/plugins/security_solution/public/common/components/severity/types.ts new file mode 100644 index 000000000000..94911ec749a5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/severity/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RiskSeverity } from '../../../../common/search_strategy'; + +export type SeverityCount = { + [k in RiskSeverity]: number; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/truncatable_text/truncatable_text.tsx b/x-pack/plugins/security_solution/public/common/components/truncatable_text/truncatable_text.tsx index cc1c53d10710..27369dadb8a3 100644 --- a/x-pack/plugins/security_solution/public/common/components/truncatable_text/truncatable_text.tsx +++ b/x-pack/plugins/security_solution/public/common/components/truncatable_text/truncatable_text.tsx @@ -16,7 +16,7 @@ import { EuiToolTip } from '@elastic/eui'; * Note: Requires a parent container with a defined width or max-width. */ -const EllipsisText = styled.span` +export const EllipsisText = styled.span` &, & * { display: inline-block; diff --git a/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/index.tsx b/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/index.tsx index 78fd8410817f..73fba86da653 100644 --- a/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/index.tsx @@ -31,6 +31,7 @@ const AnomaliesQueryTabBodyComponent: React.FC = ({ flowTarget, ip, hostName, + userName, indexNames, }) => { const { jobs } = useInstalledSecurityJobs(); @@ -74,6 +75,7 @@ const AnomaliesQueryTabBodyComponent: React.FC = ({ flowTarget={flowTarget} ip={ip} hostName={hostName} + userName={userName} /> ); diff --git a/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/types.ts b/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/types.ts index ee3ace3819fd..97b0f34db965 100644 --- a/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/types.ts +++ b/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/types.ts @@ -35,4 +35,5 @@ export type AnomaliesQueryTabBodyProps = QueryTabBodyProps & { hideHistogramIfEmpty?: boolean; ip?: string; hostName?: string; + userName?: string; }; diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts index aacc1dc95169..b76b5ee99843 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts @@ -18,6 +18,7 @@ import { createWithKibanaMock, } from '../kibana_react.mock'; import { APP_UI_ID } from '../../../../../common/constants'; +import { mockCasesContract } from '../../../../../../cases/public/mocks'; const mockStartServicesMock = createStartServicesMock(); export const KibanaServices = { get: jest.fn(), getKibanaVersion: jest.fn(() => '8.0.0') }; @@ -28,6 +29,7 @@ export const useKibana = jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn(), }, + cases: mockCasesContract(), data: { ...mockStartServicesMock.data, search: { diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts index b0765f3abaf5..9adc946bc397 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts @@ -38,6 +38,8 @@ type SingleResponseProvider new Promise(r => setTimeout(r, 500)) * ) */ - mockDelay: jest.MockedFunction<() => Promise>; + mockDelay: jest.MockedFunction<(options: HttpFetchOptionsWithPath) => Promise>; }; /** @@ -99,9 +101,9 @@ interface RouteMock Promise; + delay?: (options: HttpFetchOptionsWithPath) => Promise; } export type ApiHandlerMockFactoryProps< @@ -134,14 +136,10 @@ export const httpHandlerMockFactory = void> = []; - const markApiCallAsHandled = async (delay?: RouteMock['delay']) => { + const markApiCallAsInFlight = () => { inflightApiCalls++; - - // If a delay was defined, then await that first - if (delay) { - await delay(); - } - + }; + const markApiCallAsHandled = async () => { // We always wait at least 1ms await new Promise((r) => setTimeout(r, 1)); @@ -200,10 +198,7 @@ export const httpHandlerMockFactory = {'Add to case'}
    ), - getAddToCaseAction: jest.fn(), - getAddToExistingCaseButton: jest.fn().mockReturnValue( -
    - {'Add to existing case'} -
    - ), - getAddToNewCaseButton: jest.fn().mockReturnValue( -
    - {'Add to new case'} -
    - ), }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 1499e803fdf3..c52d7a55d844 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -99,7 +99,6 @@ export const AlertsTableComponent: React.FC = ({ const { browserFields, indexPattern: indexPatterns, - loading: indexPatternsLoading, selectedPatterns, } = useSourcererDataView(SourcererScopeName.detections); const kibana = useKibana(); @@ -358,9 +357,9 @@ export const AlertsTableComponent: React.FC = ({ const leadingControlColumns = useMemo(() => getDefaultControlColumn(ACTION_BUTTON_COUNT), []); const casesPermissions = useGetUserCasesPermissions(); - const CasesContext = kibana.services.cases.getCasesContext(); + const CasesContext = kibana.services.cases.ui.getCasesContext(); - if (loading || indexPatternsLoading || isEmpty(selectedPatterns)) { + if (loading || isEmpty(selectedPatterns)) { return null; } diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx index 3c9d2115f7ef..d5fc54c5cbac 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx @@ -12,6 +12,7 @@ import { TestProviders } from '../../../../common/mock'; import React from 'react'; import { Ecs } from '../../../../../common/ecs'; import { mockTimelines } from '../../../../common/mock/mock_timelines_plugin'; +import { mockCasesContract } from '../../../../../../cases/public/mocks'; const ecsRowData: Ecs = { _id: '1', @@ -51,6 +52,7 @@ jest.mock('../../../../common/lib/kibana', () => ({ application: { capabilities: { siem: { crud_alerts: true, read_alerts: true } }, }, + cases: mockCasesContract(), }, }), useGetUserCasesPermissions: jest.fn().mockReturnValue({ diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index b5e630de50f7..3d1fe638eab5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -14,6 +14,7 @@ import { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; import { get } from 'lodash/fp'; import { useRouteSpy } from '../../../../common/utils/route/use_route_spy'; import { buildGetAlertByIdQuery } from '../../../../common/components/exceptions/helpers'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { EventsTdContent } from '../../../../timelines/components/timeline/styles'; import { DEFAULT_ACTION_BUTTON_WIDTH } from '../../../../../../timelines/public'; import { Ecs } from '../../../../../common/ecs'; @@ -33,7 +34,6 @@ import { useExceptionFlyout } from './use_add_exception_flyout'; import { useExceptionActions } from './use_add_exception_actions'; import { useEventFilterModal } from './use_event_filter_modal'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { useKibana } from '../../../../common/lib/kibana'; import { ATTACH_ALERT_TO_CASE_FOR_ROW } from '../../../../timelines/components/timeline/body/translations'; import { useEventFilterAction } from './use_event_filter_action'; import { useAddToCaseActions } from './use_add_to_case_actions'; @@ -65,20 +65,26 @@ const AlertContextMenuComponent: React.FC { + const onMenuItemClick = useCallback(() => { setPopover(false); }, []); const ruleId = get(0, ecsRowData?.kibana?.alert?.rule?.uuid); const ruleName = get(0, ecsRowData?.kibana?.alert?.rule?.name); - const { timelines: timelinesUi } = useKibana().services; - const { addToCaseActionProps, addToCaseActionItems } = useAddToCaseActions({ + const { addToCaseActionItems } = useAddToCaseActions({ ecsData: ecsRowData, - afterCaseSelection: afterItemSelection, + onMenuItemClick, timelineId, ariaLabel: ATTACH_ALERT_TO_CASE_FOR_ROW({ ariaRowindex, columnValues }), }); + const { loading: canAccessEndpointManagementLoading, canAccessEndpointManagement } = + useUserPrivileges().endpointPrivileges; + const canCreateEndpointEventFilters = useMemo( + () => !canAccessEndpointManagementLoading && canAccessEndpointManagement, + [canAccessEndpointManagement, canAccessEndpointManagementLoading] + ); + const alertStatus = get(0, ecsRowData?.kibana?.alert?.workflow_status) as Status | undefined; const isEvent = useMemo(() => indexOf(ecsRowData.event?.kind, 'event') !== -1, [ecsRowData]); @@ -167,7 +173,7 @@ const AlertContextMenuComponent: React.FC @@ -186,7 +192,6 @@ const AlertContextMenuComponent: React.FC - {addToCaseActionProps && timelinesUi.getAddToCaseAction(addToCaseActionProps)} {items.length > 0 && (
    diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx index cc0ef8d4e8b7..2ca4525c7e1a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx @@ -5,16 +5,19 @@ * 2.0. */ -import { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import { EuiContextMenuItem } from '@elastic/eui'; +import { CommentType } from '../../../../../../cases/common'; +import { CaseAttachments } from '../../../../../../cases/public'; import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana'; import type { TimelineNonEcsData } from '../../../../../common/search_strategy'; import { TimelineId } from '../../../../../common/types'; -import { APP_ID, APP_UI_ID } from '../../../../../common/constants'; -import { useInsertTimeline } from '../../../../cases/components/use_insert_timeline'; +import { APP_ID } from '../../../../../common/constants'; import { Ecs } from '../../../../../common/ecs'; +import { ADD_TO_EXISTING_CASE, ADD_TO_NEW_CASE } from '../translations'; export interface UseAddToCaseActions { - afterCaseSelection: () => void; + onMenuItemClick: () => void; ariaLabel?: string; ecsData?: Ecs; nonEcsData?: TimelineNonEcsData[]; @@ -22,51 +25,92 @@ export interface UseAddToCaseActions { } export const useAddToCaseActions = ({ - afterCaseSelection, + onMenuItemClick, ariaLabel, ecsData, nonEcsData, timelineId, }: UseAddToCaseActions) => { - const { timelines: timelinesUi } = useKibana().services; + const { cases: casesUi } = useKibana().services; const casePermissions = useGetUserCasesPermissions(); - const insertTimelineHook = useInsertTimeline; + const hasWritePermissions = casePermissions?.crud ?? false; - const addToCaseActionProps = useMemo( - () => - ecsData?._id - ? { - ariaLabel, - event: { data: nonEcsData ?? [], ecs: ecsData, _id: ecsData?._id }, - useInsertTimeline: insertTimelineHook, - casePermissions, - appId: APP_UI_ID, + const caseAttachments: CaseAttachments = useMemo(() => { + return ecsData?._id + ? [ + { + alertId: ecsData?._id ?? '', + index: ecsData?._index ?? '', owner: APP_ID, - onClose: afterCaseSelection, - } - : null, - [ecsData, ariaLabel, nonEcsData, insertTimelineHook, casePermissions, afterCaseSelection] - ); - const hasWritePermissions = casePermissions?.crud ?? false; - const addToCaseActionItems = useMemo( - () => + type: CommentType.alert, + rule: casesUi.helpers.getRuleIdFromEvent({ ecs: ecsData, data: nonEcsData ?? [] }), + }, + ] + : []; + }, [casesUi.helpers, ecsData, nonEcsData]); + + const createCaseFlyout = casesUi.hooks.getUseCasesAddToNewCaseFlyout({ + attachments: caseAttachments, + onClose: onMenuItemClick, + }); + + const selectCaseModal = casesUi.hooks.getUseCasesAddToExistingCaseModal({ + attachments: caseAttachments, + onClose: onMenuItemClick, + }); + + const handleAddToNewCaseClick = useCallback(() => { + // TODO rename this, this is really `closePopover()` + onMenuItemClick(); + createCaseFlyout.open(); + }, [onMenuItemClick, createCaseFlyout]); + + const handleAddToExistingCaseClick = useCallback(() => { + // TODO rename this, this is really `closePopover()` + onMenuItemClick(); + selectCaseModal.open(); + }, [onMenuItemClick, selectCaseModal]); + + const addToCaseActionItems = useMemo(() => { + if ( [ TimelineId.detectionsPage, TimelineId.detectionsRulesDetailsPage, TimelineId.active, ].includes(timelineId as TimelineId) && - hasWritePermissions && - addToCaseActionProps - ? [ - timelinesUi.getAddToExistingCaseButton(addToCaseActionProps), - timelinesUi.getAddToNewCaseButton(addToCaseActionProps), - ] - : [], - [addToCaseActionProps, hasWritePermissions, timelineId, timelinesUi] - ); + hasWritePermissions + ) { + return [ + // add to existing case menu item + + {ADD_TO_EXISTING_CASE} + , + // add to new case menu item + + {ADD_TO_NEW_CASE} + , + ]; + } + return []; + }, [ + ariaLabel, + handleAddToExistingCaseClick, + handleAddToNewCaseClick, + hasWritePermissions, + timelineId, + ]); return { addToCaseActionItems, - addToCaseActionProps, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx index 8da4ce1c3ed7..b87cea700844 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx @@ -31,7 +31,7 @@ export const useAlertsActions = ({ refetch, }: Props) => { const dispatch = useDispatch(); - const { hasIndexWrite, hasKibanaCRUD } = useAlertsPrivileges(); + const { hasIndexWrite } = useAlertsPrivileges(); const onStatusUpdate = useCallback(() => { closePopover(); @@ -66,6 +66,6 @@ export const useAlertsActions = ({ }); return { - actionItems: hasIndexWrite && hasKibanaCRUD ? actionItems : [], + actionItems: hasIndexWrite ? actionItems : [], }; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index 590b5759ecae..bdddd8ab4620 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -285,3 +285,17 @@ export const TRIGGERED = i18n.translate( defaultMessage: 'Triggered', } ); + +export const ADD_TO_EXISTING_CASE = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.actions.addToCase', + { + defaultMessage: 'Add to existing case', + } +); + +export const ADD_TO_NEW_CASE = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.actions.addToNewCase', + { + defaultMessage: 'Add to new case', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx index 05dd0ff12c55..2dac0b68b029 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx @@ -92,6 +92,7 @@ const RulePreviewComponent: React.FC = ({ previewId, logs, hasNoiseWarning, + isAborted, } = usePreviewRoute({ index, isDisabled, @@ -159,7 +160,7 @@ const RulePreviewComponent: React.FC = ({ index={index} /> )} - + ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_logs.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_logs.tsx index 726c2b5df964..45fa4f2e20af 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_logs.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_logs.tsx @@ -13,9 +13,11 @@ import * as i18n from './translations'; interface PreviewLogsComponentProps { logs: RulePreviewLogs[]; hasNoiseWarning: boolean; + isAborted: boolean; } interface SortedLogs { + duration: number; startedAt?: string; logs: string[]; } @@ -25,12 +27,25 @@ interface LogAccordionProps { isError?: boolean; } -const addLogs = (startedAt: string | undefined, logs: string[], allLogs: SortedLogs[]) => - logs.length ? [{ startedAt, logs }, ...allLogs] : allLogs; +const CustomWarning: React.FC<{ message: string }> = ({ message }) => ( + + +

    {message}

    +
    +
    +); + +const addLogs = ( + startedAt: string | undefined, + logs: string[], + duration: number, + allLogs: SortedLogs[] +) => (logs.length ? [{ startedAt, logs, duration }, ...allLogs] : allLogs); export const PreviewLogsComponent: React.FC = ({ logs, hasNoiseWarning, + isAborted, }) => { const sortedLogs = useMemo( () => @@ -39,8 +54,8 @@ export const PreviewLogsComponent: React.FC = ({ warnings: SortedLogs[]; }>( ({ errors, warnings }, curr) => ({ - errors: addLogs(curr.startedAt, curr.errors, errors), - warnings: addLogs(curr.startedAt, curr.warnings, warnings), + errors: addLogs(curr.startedAt, curr.errors, curr.duration, errors), + warnings: addLogs(curr.startedAt, curr.warnings, curr.duration, warnings), }), { errors: [], warnings: [] } ), @@ -49,19 +64,32 @@ export const PreviewLogsComponent: React.FC = ({ return ( <> - {hasNoiseWarning ?? } + {hasNoiseWarning ?? } - + + {isAborted && } + ); }; -const LogAccordion: React.FC = ({ logs, isError }) => { +const LogAccordion: React.FC = ({ logs, isError, children }) => { const firstLog = logs[0]; - const restOfLogs = logs.slice(1); - return firstLog ? ( + if (!(children || firstLog)) return null; + + const restOfLogs = children ? logs : logs.slice(1); + const bannerElement = children ?? ( + + ); + + return ( <> - + {bannerElement} {restOfLogs.length > 0 ? ( = ({ logs, isError }) => { key={`accordion-log-${key}`} logs={log.logs} startedAt={log.startedAt} + duration={log.duration} isError={isError} /> ))} @@ -81,14 +110,15 @@ const LogAccordion: React.FC = ({ logs, isError }) => { ) : null} - ) : null; + ); }; export const CalloutGroup: React.FC<{ logs: string[]; + duration: number; startedAt?: string; isError?: boolean; -}> = ({ logs, startedAt, isError }) => { +}> = ({ logs, startedAt, isError, duration }) => { return logs.length > 0 ? ( <> {logs.map((log, i) => ( @@ -97,7 +127,7 @@ export const CalloutGroup: React.FC<{ color={isError ? 'danger' : 'warning'} iconType="alert" data-test-subj={isError ? 'preview-error' : 'preview-warning'} - title={startedAt != null ? `[${startedAt}]` : null} + title={`${startedAt ? `[${startedAt}] ` : ''}[${duration}ms]`} >

    {log}

    diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/translations.ts index 23fcf62f32a6..58a90fba13dc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/translations.ts @@ -30,6 +30,13 @@ export const QUERY_PREVIEW_BUTTON = i18n.translate( } ); +export const PREVIEW_TIMEOUT_WARNING = i18n.translate( + 'xpack.securitySolution.stepDefineRule.previewTimeoutWarning', + { + defaultMessage: 'Preview timed out after 60 seconds', + } +); + export const QUERY_PREVIEW_SELECT_ARIA = i18n.translate( 'xpack.securitySolution.stepDefineRule.previewQueryAriaLabel', { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_route.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_route.tsx index 2e51090f37a9..d5278144cf70 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_route.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_route.tsx @@ -45,10 +45,12 @@ export const usePreviewRoute = ({ const { isLoading, response, rule, setRule } = usePreviewRule(timeFrame); const [logs, setLogs] = useState(response.logs ?? []); + const [isAborted, setIsAborted] = useState(!!response.isAborted); const [hasNoiseWarning, setHasNoiseWarning] = useState(false); useEffect(() => { setLogs(response.logs ?? []); + setIsAborted(!!response.isAborted); }, [response]); const addNoiseWarning = useCallback(() => { @@ -58,6 +60,7 @@ export const usePreviewRoute = ({ const clearPreview = useCallback(() => { setRule(null); setLogs([]); + setIsAborted(false); setIsRequestTriggered(false); setHasNoiseWarning(false); }, [setRule]); @@ -120,5 +123,6 @@ export const usePreviewRoute = ({ isPreviewRequestInProgress: isLoading, previewId: response.previewId ?? '', logs, + isAborted, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index 69d4d400f12a..2f1d214f0457 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -6,7 +6,7 @@ */ import { EuiButtonEmpty, EuiFormRow, EuiSpacer } from '@elastic/eui'; -import React, { FC, memo, useCallback, useState, useEffect, useMemo } from 'react'; +import React, { FC, memo, useCallback, useState, useEffect } from 'react'; import styled from 'styled-components'; import { isEqual } from 'lodash'; @@ -190,7 +190,6 @@ const StepDefineRuleComponent: FC = ({ const machineLearningJobId = formMachineLearningJobId ?? initialState.machineLearningJobId; const anomalyThreshold = formAnomalyThreshold ?? initialState.anomalyThreshold; const ruleType = formRuleType || initialState.ruleType; - const isPreviewRouteEnabled = useMemo(() => ruleType !== 'threat_match', [ruleType]); const [indexPatternsLoading, { browserFields, indexPatterns }] = useFetchIndex(index); const aggregatableFields = Object.entries(browserFields).reduce( (groupAcc, [groupName, groupValue]) => { @@ -504,31 +503,27 @@ const StepDefineRuleComponent: FC = ({ }} /> - {isPreviewRouteEnabled && ( - <> - - - - )} + + {!isUpdateView && ( diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx index 0c525a2d7770..9546ef231992 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx @@ -17,6 +17,11 @@ import { TestProviders } from '../../../common/mock'; import { mockTimelines } from '../../../common/mock/mock_timelines_plugin'; import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock'; import { useKibana } from '../../../common/lib/kibana'; +import { mockCasesContract } from '../../../../../cases/public/mocks'; +import { initialUserPrivilegesState as mockInitialUserPrivilegesState } from '../../../common/components/user_privileges/user_privileges_context'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; + +jest.mock('../../../common/components/user_privileges'); jest.mock('../user_info', () => ({ useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]), @@ -82,6 +87,7 @@ describe('take action dropdown', () => { services: { ...mockStartServicesMock, timelines: { ...mockTimelines }, + cases: mockCasesContract(), application: { capabilities: { siem: { crud_alerts: true, read_alerts: true } }, }, @@ -230,6 +236,28 @@ describe('take action dropdown', () => { }); }); + test('should disable the "Add Endpoint event filter" button if no endpoint management privileges', async () => { + (useUserPrivileges as jest.Mock).mockReturnValue({ + ...mockInitialUserPrivilegesState(), + endpointPrivileges: { loading: false, canAccessEndpointManagement: false }, + }); + wrapper = mount( + + + + ); + wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click'); + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="add-event-filter-menu-item"]').first().getDOMNode() + ).toBeDisabled(); + }); + }); + test('should hide the "Add Endpoint event filter" button if provided no event from endpoint', async () => { wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index 8ad76c70247b..d4373501eedd 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -21,6 +21,7 @@ import type { Ecs } from '../../../../common/ecs'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { isAlertFromEndpointAlert } from '../../../common/utils/endpoint_alert_check'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; import { useAddToCaseActions } from '../alerts_table/timeline_actions/use_add_to_case_actions'; interface ActionsData { alertStatus: Status; @@ -59,6 +60,13 @@ export const TakeActionDropdown = React.memo( timelineId, }: TakeActionDropdownProps) => { const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); + const { loading: canAccessEndpointManagementLoading, canAccessEndpointManagement } = + useUserPrivileges().endpointPrivileges; + + const canCreateEndpointEventFilters = useMemo( + () => !canAccessEndpointManagementLoading && canAccessEndpointManagement, + [canAccessEndpointManagement, canAccessEndpointManagementLoading] + ); const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -134,10 +142,10 @@ export const TakeActionDropdown = React.memo( const { eventFilterActionItems } = useEventFilterAction({ onAddEventFilterClick: handleOnAddEventFilterClick, - disabled: !isEndpointEvent, + disabled: !isEndpointEvent || !canCreateEndpointEventFilters, }); - const afterCaseSelection = useCallback(() => { + const onMenuItemClick = useCallback(() => { closePopoverHandler(); }, [closePopoverHandler]); @@ -175,7 +183,7 @@ export const TakeActionDropdown = React.memo( const { addToCaseActionItems } = useAddToCaseActions({ ecsData, nonEcsData: detailsData?.map((d) => ({ field: d.field, value: d.values })) ?? [], - afterCaseSelection, + onMenuItemClick, timelineId, }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_preview_rule.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_preview_rule.ts index 4b0ad0507263..43572ddaf4d3 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_preview_rule.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_preview_rule.ts @@ -22,6 +22,7 @@ import { transformOutput } from './transforms'; const emptyPreviewRule: PreviewResponse = { previewId: undefined, logs: [], + isAborted: false, }; export const usePreviewRule = (timeframe: Unit = 'h') => { diff --git a/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts b/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts index 3009ad8cdd01..c6cad1e5b75a 100644 --- a/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts +++ b/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts @@ -5,6 +5,10 @@ * 2.0. */ +// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY +// Please modify the 'extract_tactics_techniques_mitre.js' script directly and +// run 'yarn extract-mitre-attacks' from the root 'security_solution' plugin directory + import { i18n } from '@kbn/i18n'; import { MitreTacticsOptions, MitreTechniquesOptions, MitreSubtechniquesOptions } from './types'; @@ -268,6 +272,12 @@ export const technique = [ reference: 'https://attack.mitre.org/techniques/T1595', tactics: ['reconnaissance'], }, + { + name: 'Adversary-in-the-Middle', + id: 'T1557', + reference: 'https://attack.mitre.org/techniques/T1557', + tactics: ['credential-access', 'collection'], + }, { name: 'Application Layer Protocol', id: 'T1071', @@ -334,6 +344,12 @@ export const technique = [ reference: 'https://attack.mitre.org/techniques/T1176', tactics: ['persistence'], }, + { + name: 'Browser Session Hijacking', + id: 'T1185', + reference: 'https://attack.mitre.org/techniques/T1185', + tactics: ['collection'], + }, { name: 'Brute Force', id: 'T1110', @@ -370,6 +386,12 @@ export const technique = [ reference: 'https://attack.mitre.org/techniques/T1526', tactics: ['discovery'], }, + { + name: 'Cloud Storage Object Discovery', + id: 'T1619', + reference: 'https://attack.mitre.org/techniques/T1619', + tactics: ['discovery'], + }, { name: 'Command and Scripting Interpreter', id: 'T1059', @@ -760,6 +782,12 @@ export const technique = [ reference: 'https://attack.mitre.org/techniques/T1061', tactics: ['execution'], }, + { + name: 'Group Policy Discovery', + id: 'T1615', + reference: 'https://attack.mitre.org/techniques/T1615', + tactics: ['discovery'], + }, { name: 'Hardware Additions', id: 'T1200', @@ -850,18 +878,6 @@ export const technique = [ reference: 'https://attack.mitre.org/techniques/T1570', tactics: ['lateral-movement'], }, - { - name: 'Man in the Browser', - id: 'T1185', - reference: 'https://attack.mitre.org/techniques/T1185', - tactics: ['collection'], - }, - { - name: 'Man-in-the-Middle', - id: 'T1557', - reference: 'https://attack.mitre.org/techniques/T1557', - tactics: ['credential-access', 'collection'], - }, { name: 'Masquerading', id: 'T1036', @@ -1054,6 +1070,12 @@ export const technique = [ reference: 'https://attack.mitre.org/techniques/T1108', tactics: ['defense-evasion', 'persistence'], }, + { + name: 'Reflective Code Loading', + id: 'T1620', + reference: 'https://attack.mitre.org/techniques/T1620', + tactics: ['defense-evasion'], + }, { name: 'Remote Access Software', id: 'T1219', @@ -1482,6 +1504,17 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ tactics: 'reconnaissance', value: 'activeScanning', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.adversaryInTheMiddleDescription', + { defaultMessage: 'Adversary-in-the-Middle (T1557)' } + ), + id: 'T1557', + name: 'Adversary-in-the-Middle', + reference: 'https://attack.mitre.org/techniques/T1557', + tactics: 'credential-access,collection', + value: 'adversaryInTheMiddle', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.applicationLayerProtocolDescription', @@ -1603,6 +1636,17 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ tactics: 'persistence', value: 'browserExtensions', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.browserSessionHijackingDescription', + { defaultMessage: 'Browser Session Hijacking (T1185)' } + ), + id: 'T1185', + name: 'Browser Session Hijacking', + reference: 'https://attack.mitre.org/techniques/T1185', + tactics: 'collection', + value: 'browserSessionHijacking', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.bruteForceDescription', @@ -1669,6 +1713,17 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ tactics: 'discovery', value: 'cloudServiceDiscovery', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.cloudStorageObjectDiscoveryDescription', + { defaultMessage: 'Cloud Storage Object Discovery (T1619)' } + ), + id: 'T1619', + name: 'Cloud Storage Object Discovery', + reference: 'https://attack.mitre.org/techniques/T1619', + tactics: 'discovery', + value: 'cloudStorageObjectDiscovery', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.commandAndScriptingInterpreterDescription', @@ -2384,6 +2439,17 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ tactics: 'execution', value: 'graphicalUserInterface', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.groupPolicyDiscoveryDescription', + { defaultMessage: 'Group Policy Discovery (T1615)' } + ), + id: 'T1615', + name: 'Group Policy Discovery', + reference: 'https://attack.mitre.org/techniques/T1615', + tactics: 'discovery', + value: 'groupPolicyDiscovery', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.hardwareAdditionsDescription', @@ -2549,28 +2615,6 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ tactics: 'lateral-movement', value: 'lateralToolTransfer', }, - { - label: i18n.translate( - 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.manInTheBrowserDescription', - { defaultMessage: 'Man in the Browser (T1185)' } - ), - id: 'T1185', - name: 'Man in the Browser', - reference: 'https://attack.mitre.org/techniques/T1185', - tactics: 'collection', - value: 'manInTheBrowser', - }, - { - label: i18n.translate( - 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.manInTheMiddleDescription', - { defaultMessage: 'Man-in-the-Middle (T1557)' } - ), - id: 'T1557', - name: 'Man-in-the-Middle', - reference: 'https://attack.mitre.org/techniques/T1557', - tactics: 'credential-access,collection', - value: 'manInTheMiddle', - }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.masqueradingDescription', @@ -2923,6 +2967,17 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ tactics: 'defense-evasion,persistence', value: 'redundantAccess', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.reflectiveCodeLoadingDescription', + { defaultMessage: 'Reflective Code Loading (T1620)' } + ), + id: 'T1620', + name: 'Reflective Code Loading', + reference: 'https://attack.mitre.org/techniques/T1620', + tactics: 'defense-evasion', + value: 'reflectiveCodeLoading', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.remoteAccessSoftwareDescription', @@ -3879,6 +3934,13 @@ export const subtechniques = [ tactics: ['credential-access'], techniqueId: 'T1552', }, + { + name: 'Code Repositories', + id: 'T1213.003', + reference: 'https://attack.mitre.org/techniques/T1213/003', + tactics: ['collection'], + techniqueId: 'T1213', + }, { name: 'Code Signing', id: 'T1553.002', @@ -4320,6 +4382,20 @@ export const subtechniques = [ tactics: ['resource-development'], techniqueId: 'T1584', }, + { + name: 'Double File Extension', + id: 'T1036.007', + reference: 'https://attack.mitre.org/techniques/T1036/007', + tactics: ['defense-evasion'], + techniqueId: 'T1036', + }, + { + name: 'Downgrade Attack', + id: 'T1562.010', + reference: 'https://attack.mitre.org/techniques/T1562/010', + tactics: ['defense-evasion'], + techniqueId: 'T1562', + }, { name: 'Downgrade System Image', id: 'T1601.002', @@ -4404,6 +4480,13 @@ export const subtechniques = [ tactics: ['collection'], techniqueId: 'T1114', }, + { + name: 'Email Hiding Rules', + id: 'T1564.008', + reference: 'https://attack.mitre.org/techniques/T1564/008', + tactics: ['defense-evasion'], + techniqueId: 'T1564', + }, { name: 'Emond', id: 'T1546.014', @@ -4586,6 +4669,13 @@ export const subtechniques = [ tactics: ['credential-access'], techniqueId: 'T1552', }, + { + name: 'HTML Smuggling', + id: 'T1027.006', + reference: 'https://attack.mitre.org/techniques/T1027/006', + tactics: ['defense-evasion'], + techniqueId: 'T1027', + }, { name: 'Hardware', id: 'T1592.001', @@ -4621,6 +4711,13 @@ export const subtechniques = [ tactics: ['defense-evasion'], techniqueId: 'T1564', }, + { + name: 'IIS Components', + id: 'T1505.004', + reference: 'https://attack.mitre.org/techniques/T1505/004', + tactics: ['persistence'], + techniqueId: 'T1505', + }, { name: 'IP Addresses', id: 'T1590.005', @@ -4880,6 +4977,13 @@ export const subtechniques = [ tactics: ['discovery'], techniqueId: 'T1069', }, + { + name: 'Login Items', + id: 'T1547.015', + reference: 'https://attack.mitre.org/techniques/T1547/015', + tactics: ['persistence', 'privilege-escalation'], + techniqueId: 'T1547', + }, { name: 'Logon Script (Mac)', id: 'T1037.002', @@ -4894,6 +4998,13 @@ export const subtechniques = [ tactics: ['persistence', 'privilege-escalation'], techniqueId: 'T1037', }, + { + name: 'MMC', + id: 'T1218.014', + reference: 'https://attack.mitre.org/techniques/T1218/014', + tactics: ['defense-evasion'], + techniqueId: 'T1218', + }, { name: 'MSBuild', id: 'T1127.001', @@ -4971,6 +5082,13 @@ export const subtechniques = [ tactics: ['defense-evasion'], techniqueId: 'T1036', }, + { + name: 'Mavinject', + id: 'T1218.013', + reference: 'https://attack.mitre.org/techniques/T1218/013', + tactics: ['defense-evasion'], + techniqueId: 'T1218', + }, { name: 'Mshta', id: 'T1218.005', @@ -5440,6 +5558,13 @@ export const subtechniques = [ tactics: ['defense-evasion'], techniqueId: 'T1036', }, + { + name: 'Resource Forking', + id: 'T1564.009', + reference: 'https://attack.mitre.org/techniques/T1564/009', + tactics: ['defense-evasion'], + techniqueId: 'T1564', + }, { name: 'Revert Cloud Instance', id: 'T1578.004', @@ -5538,6 +5663,13 @@ export const subtechniques = [ tactics: ['lateral-movement'], techniqueId: 'T1563', }, + { + name: 'Safe Mode Boot', + id: 'T1562.009', + reference: 'https://attack.mitre.org/techniques/T1562/009', + tactics: ['defense-evasion'], + techniqueId: 'T1562', + }, { name: 'Scan Databases', id: 'T1596.005', @@ -5818,6 +5950,13 @@ export const subtechniques = [ tactics: ['persistence', 'defense-evasion'], techniqueId: 'T1542', }, + { + name: 'System Language Discovery', + id: 'T1614.001', + reference: 'https://attack.mitre.org/techniques/T1614/001', + tactics: ['discovery'], + techniqueId: 'T1614', + }, { name: 'Systemd Service', id: 'T1543.002', @@ -6676,6 +6815,18 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1552', value: 'cloudInstanceMetadataApi', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.codeRepositoriesT1213Description', + { defaultMessage: 'Code Repositories (T1213.003)' } + ), + id: 'T1213.003', + name: 'Code Repositories', + reference: 'https://attack.mitre.org/techniques/T1213/003', + tactics: 'collection', + techniqueId: 'T1213', + value: 'codeRepositories', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.codeSigningT1553Description', @@ -7432,6 +7583,30 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1584', value: 'domains', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.doubleFileExtensionT1036Description', + { defaultMessage: 'Double File Extension (T1036.007)' } + ), + id: 'T1036.007', + name: 'Double File Extension', + reference: 'https://attack.mitre.org/techniques/T1036/007', + tactics: 'defense-evasion', + techniqueId: 'T1036', + value: 'doubleFileExtension', + }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.downgradeAttackT1562Description', + { defaultMessage: 'Downgrade Attack (T1562.010)' } + ), + id: 'T1562.010', + name: 'Downgrade Attack', + reference: 'https://attack.mitre.org/techniques/T1562/010', + tactics: 'defense-evasion', + techniqueId: 'T1562', + value: 'downgradeAttack', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.downgradeSystemImageT1601Description', @@ -7576,6 +7751,18 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1114', value: 'emailForwardingRule', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.emailHidingRulesT1564Description', + { defaultMessage: 'Email Hiding Rules (T1564.008)' } + ), + id: 'T1564.008', + name: 'Email Hiding Rules', + reference: 'https://attack.mitre.org/techniques/T1564/008', + tactics: 'defense-evasion', + techniqueId: 'T1564', + value: 'emailHidingRules', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.emondT1546Description', @@ -7888,6 +8075,18 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1552', value: 'groupPolicyPreferences', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.htmlSmugglingT1027Description', + { defaultMessage: 'HTML Smuggling (T1027.006)' } + ), + id: 'T1027.006', + name: 'HTML Smuggling', + reference: 'https://attack.mitre.org/techniques/T1027/006', + tactics: 'defense-evasion', + techniqueId: 'T1027', + value: 'htmlSmuggling', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.hardwareT1592Description', @@ -7948,6 +8147,18 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1564', value: 'hiddenWindow', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.iisComponentsT1505Description', + { defaultMessage: 'IIS Components (T1505.004)' } + ), + id: 'T1505.004', + name: 'IIS Components', + reference: 'https://attack.mitre.org/techniques/T1505/004', + tactics: 'persistence', + techniqueId: 'T1505', + value: 'iisComponents', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.ipAddressesT1590Description', @@ -8392,6 +8603,18 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1069', value: 'localGroups', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.loginItemsT1547Description', + { defaultMessage: 'Login Items (T1547.015)' } + ), + id: 'T1547.015', + name: 'Login Items', + reference: 'https://attack.mitre.org/techniques/T1547/015', + tactics: 'persistence,privilege-escalation', + techniqueId: 'T1547', + value: 'loginItems', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.logonScriptMacT1037Description', @@ -8416,6 +8639,18 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1037', value: 'logonScriptWindows', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.mmcT1218Description', + { defaultMessage: 'MMC (T1218.014)' } + ), + id: 'T1218.014', + name: 'MMC', + reference: 'https://attack.mitre.org/techniques/T1218/014', + tactics: 'defense-evasion', + techniqueId: 'T1218', + value: 'mmc', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.msBuildT1127Description', @@ -8548,6 +8783,18 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1036', value: 'matchLegitimateNameOrLocation', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.mavinjectT1218Description', + { defaultMessage: 'Mavinject (T1218.013)' } + ), + id: 'T1218.013', + name: 'Mavinject', + reference: 'https://attack.mitre.org/techniques/T1218/013', + tactics: 'defense-evasion', + techniqueId: 'T1218', + value: 'mavinject', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.mshtaT1218Description', @@ -9352,6 +9599,18 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1036', value: 'renameSystemUtilities', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.resourceForkingT1564Description', + { defaultMessage: 'Resource Forking (T1564.009)' } + ), + id: 'T1564.009', + name: 'Resource Forking', + reference: 'https://attack.mitre.org/techniques/T1564/009', + tactics: 'defense-evasion', + techniqueId: 'T1564', + value: 'resourceForking', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.revertCloudInstanceT1578Description', @@ -9520,6 +9779,18 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1563', value: 'sshHijacking', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.safeModeBootT1562Description', + { defaultMessage: 'Safe Mode Boot (T1562.009)' } + ), + id: 'T1562.009', + name: 'Safe Mode Boot', + reference: 'https://attack.mitre.org/techniques/T1562/009', + tactics: 'defense-evasion', + techniqueId: 'T1562', + value: 'safeModeBoot', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.scanDatabasesT1596Description', @@ -10000,6 +10271,18 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1542', value: 'systemFirmware', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.systemLanguageDiscoveryT1614Description', + { defaultMessage: 'System Language Discovery (T1614.001)' } + ), + id: 'T1614.001', + name: 'System Language Discovery', + reference: 'https://attack.mitre.org/techniques/T1614/001', + tactics: 'discovery', + techniqueId: 'T1614', + value: 'systemLanguageDiscovery', + }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.systemdServiceT1543Description', diff --git a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx index 18952feee528..3cf344c691cc 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect } from 'react'; +import React from 'react'; import { Route, Switch } from 'react-router-dom'; import { ALERTS_PATH, SecurityPageName } from '../../../../common/constants'; @@ -13,9 +13,8 @@ import { NotFoundPage } from '../../../app/404'; import * as i18n from './translations'; import { TrackApplicationView } from '../../../../../../../src/plugins/usage_collection/public'; import { DetectionEnginePage } from '../../pages/detection_engine/detection_engine'; -import { useKibana } from '../../../common/lib/kibana'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; -import { useAlertsPrivileges } from '../../containers/detection_engine/alerts/use_alerts_privileges'; +import { useReadonlyHeader } from '../../../use_readonly_header'; const AlertsRoute = () => ( @@ -25,24 +24,7 @@ const AlertsRoute = () => ( ); const AlertsContainerComponent: React.FC = () => { - const { chrome } = useKibana().services; - const { hasIndexRead, hasIndexWrite } = useAlertsPrivileges(); - - useEffect(() => { - // if the user is read only then display the glasses badge in the global navigation header - if (!hasIndexWrite && hasIndexRead) { - chrome.setBadge({ - text: i18n.READ_ONLY_BADGE_TEXT, - tooltip: i18n.READ_ONLY_BADGE_TOOLTIP, - iconType: 'glasses', - }); - } - - // remove the icon after the component unmounts - return () => { - chrome.setBadge(); - }; - }, [chrome, hasIndexRead, hasIndexWrite]); + useReadonlyHeader(i18n.READ_ONLY_BADGE_TOOLTIP); return ( diff --git a/x-pack/plugins/security_solution/public/detections/pages/alerts/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/alerts/translations.ts index 734e93925e53..de0b6a5f37d9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/alerts/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/alerts/translations.ts @@ -7,13 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const READ_ONLY_BADGE_TEXT = i18n.translate( - 'xpack.securitySolution.alerts.badge.readOnly.text', - { - defaultMessage: 'Read only', - } -); - export const READ_ONLY_BADGE_TOOLTIP = i18n.translate( 'xpack.securitySolution.alerts.badge.readOnly.tooltip', { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index 4b6cbb6f7e16..1f3cacbefcc3 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -24,7 +24,7 @@ import { createStore, State } from '../../../common/store'; import { mockHistory, Router } from '../../../common/mock/router'; import { mockTimelines } from '../../../common/mock/mock_timelines_plugin'; import { mockBrowserFields } from '../../../common/containers/source/mock'; -import { mockCasesContext } from '../../../common/mock/mock_cases_context'; +import { mockCasesContext } from '../../../../../cases/public/mocks/mock_cases_context'; // Test will fail because we will to need to mock some core services to make the test work // For now let's forget about SiemSearchBar and QueryBar @@ -74,7 +74,7 @@ jest.mock('../../../common/lib/kibana', () => { }, }, cases: { - getCasesContext: mockCasesContext, + ui: { getCasesContext: mockCasesContext }, }, uiSettings: { get: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index e4f51b05ad6d..5bd6875032e0 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -123,7 +123,6 @@ const DetectionEnginePageComponent: React.FC = ({ signalIndexName, hasIndexWrite = false, hasIndexMaintenance = false, - canUserCRUD = false, canUserREAD, hasIndexRead, }, @@ -140,7 +139,7 @@ const DetectionEnginePageComponent: React.FC = ({ const { formatUrl } = useFormatUrl(SecurityPageName.rules); const [showBuildingBlockAlerts, setShowBuildingBlockAlerts] = useState(false); const [showOnlyThreatIndicatorAlerts, setShowOnlyThreatIndicatorAlerts] = useState(false); - const loading = userInfoLoading || listsConfigLoading || isLoadingIndexPattern; + const loading = userInfoLoading || listsConfigLoading; const { application: { navigateToUrl }, timelines: timelinesUi, @@ -341,24 +340,32 @@ const DetectionEnginePageComponent: React.FC = ({ - + {isLoadingIndexPattern ? ( + + ) : ( + + )} - + {isLoadingIndexPattern ? ( + + ) : ( + + )} @@ -368,8 +375,8 @@ const DetectionEnginePageComponent: React.FC = ({ () => void, onDelete: (arg: { id: string; listId: string; namespaceType: NamespaceType }) => () => void, formatUrl: FormatUrl, - navigateToUrl: (url: string) => Promise + navigateToUrl: (url: string) => Promise, + isKibanaReadOnly: boolean ): AllExceptionListsColumns[] => [ { align: 'left', @@ -155,7 +156,7 @@ export const getAllExceptionListsColumns = ( }, { render: ({ id, list_id: listId, namespace_type: namespaceType }: ExceptionListInfo) => { - return listId === 'endpoint_list' ? ( + return listId === 'endpoint_list' || isKibanaReadOnly ? ( <> ) : ( ({ - useUserData: jest.fn().mockReturnValue([ - { - loading: false, - canUserCRUD: false, - }, - ]), -})); - describe('ExceptionListsTable', () => { const exceptionList1 = getExceptionListSchemaMock(); const exceptionList2 = { ...getExceptionListSchemaMock(), list_id: 'not_endpoint_list', id: '2' }; @@ -86,9 +79,17 @@ describe('ExceptionListsTable', () => { endpoint_list: exceptionList1, }, ]); + + (useUserData as jest.Mock).mockReturnValue([ + { + loading: false, + canUserCRUD: false, + canUserREAD: false, + }, + ]); }); - it('does not render delete option disabled if list is "endpoint_list"', async () => { + it('does not render delete option if list is "endpoint_list"', async () => { const wrapper = mount( @@ -106,4 +107,25 @@ describe('ExceptionListsTable', () => { wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button').at(0).prop('disabled') ).toBeFalsy(); }); + + it('does not render delete option if user is read only', async () => { + (useUserData as jest.Mock).mockReturnValue([ + { + loading: false, + canUserCRUD: false, + canUserREAD: true, + }, + ]); + + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsTableListId"]').at(1).text()).toEqual( + 'not_endpoint_list' + ); + expect(wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button')).toHaveLength(0); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx index 4a7c71a1084a..65684a7c7d9d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -39,6 +39,7 @@ import { useUserData } from '../../../../../components/user_info'; import { userHasPermissions } from '../../helpers'; import { useListsConfig } from '../../../../../containers/detection_engine/lists/use_lists_config'; import { ExceptionsTableItem } from './types'; +import { MissingPrivilegesCallOut } from '../../../../../components/callouts/missing_privileges_callout'; export type Func = () => Promise; @@ -60,7 +61,7 @@ const exceptionReferenceModalInitialState: ReferenceModalState = { export const ExceptionListsTable = React.memo(() => { const { formatUrl } = useFormatUrl(SecurityPageName.rules); - const [{ loading: userInfoLoading, canUserCRUD }] = useUserData(); + const [{ loading: userInfoLoading, canUserCRUD, canUserREAD }] = useUserData(); const hasPermissions = userHasPermissions(canUserCRUD); const { loading: listsConfigLoading } = useListsConfig(); @@ -193,8 +194,16 @@ export const ExceptionListsTable = React.memo(() => { ); const exceptionsColumns = useMemo((): AllExceptionListsColumns[] => { - return getAllExceptionListsColumns(handleExport, handleDelete, formatUrl, navigateToUrl); - }, [handleExport, handleDelete, formatUrl, navigateToUrl]); + // Defaulting to true to default to the lower privilege first + const isKibanaReadOnly = (canUserREAD && !canUserCRUD) ?? true; + return getAllExceptionListsColumns( + handleExport, + handleDelete, + formatUrl, + navigateToUrl, + isKibanaReadOnly + ); + }, [handleExport, handleDelete, formatUrl, navigateToUrl, canUserREAD, canUserCRUD]); const handleRefresh = useCallback((): void => { if (refreshExceptions != null) { @@ -341,6 +350,7 @@ export const ExceptionListsTable = React.memo(() => { return ( <> + ( onChange={tableOnChangeCallback} pagination={paginationMemo} ref={tableRef} - selection={euiBasicTableSelectionProps} + selection={hasPermissions ? euiBasicTableSelectionProps : undefined} sorting={{ sort: { // EuiBasicTable has incorrect `sort.field` types which accept only `keyof Item` and reject fields in dot notation diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx index f241a3df8732..378820300823 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx @@ -196,7 +196,7 @@ export const useRulesColumns = ({ hasPermissions }: ColumnsProps): TableColumn[] {value}
    ), - sortable: !!isInMemorySorting, + sortable: true, truncateText: true, width: '85px', }, @@ -204,7 +204,7 @@ export const useRulesColumns = ({ hasPermissions }: ColumnsProps): TableColumn[] field: 'severity', name: i18n.COLUMN_SEVERITY, render: (value: Rule['severity']) => , - sortable: !!isInMemorySorting, + sortable: true, truncateText: true, width: '12%', }, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx index b2b80e7945e2..548e23b438bd 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx @@ -195,7 +195,7 @@ const RulesPageComponent: React.FC = () => { {i18n.UPLOAD_VALUE_LISTS} diff --git a/x-pack/plugins/security_solution/public/exceptions/routes.tsx b/x-pack/plugins/security_solution/public/exceptions/routes.tsx index a5b95ffa64d4..262db114ae84 100644 --- a/x-pack/plugins/security_solution/public/exceptions/routes.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/routes.tsx @@ -7,11 +7,13 @@ import React from 'react'; import { Route, Switch } from 'react-router-dom'; +import * as i18n from './translations'; import { TrackApplicationView } from '../../../../../src/plugins/usage_collection/public'; import { EXCEPTIONS_PATH, SecurityPageName } from '../../common/constants'; import { ExceptionListsTable } from '../detections/pages/detection_engine/rules/all/exceptions/exceptions_table'; import { SpyRoute } from '../common/utils/route/spy_routes'; import { NotFoundPage } from '../app/404'; +import { useReadonlyHeader } from '../use_readonly_header'; const ExceptionsRoutes = () => { return ( @@ -22,7 +24,9 @@ const ExceptionsRoutes = () => { ); }; -const renderExceptionsRoutes = () => { +const ExceptionsContainerComponent: React.FC = () => { + useReadonlyHeader(i18n.READ_ONLY_BADGE_TOOLTIP); + return ( @@ -31,6 +35,10 @@ const renderExceptionsRoutes = () => { ); }; +const Exceptions = React.memo(ExceptionsContainerComponent); + +const renderExceptionsRoutes = () => ; + export const routes = [ { path: EXCEPTIONS_PATH, diff --git a/x-pack/plugins/security_solution/public/exceptions/translations.ts b/x-pack/plugins/security_solution/public/exceptions/translations.ts new file mode 100644 index 000000000000..780ed23a64ff --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const READ_ONLY_BADGE_TOOLTIP = i18n.translate( + 'xpack.securitySolution.exceptions.badge.readOnly.tooltip', + { + defaultMessage: 'Unable to create, edit or delete exceptions', + } +); diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/index.tsx index 8abfbb59965e..e6008028094a 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/index.tsx @@ -26,19 +26,21 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import React from 'react'; -import { HostRiskSeverity } from '../../../../common/search_strategy'; + import { RISKY_HOSTS_DOC_LINK } from '../../../overview/components/overview_risky_host_links/risky_hosts_disabled_module'; -import { HostRiskScore } from '../common/host_risk_score'; + import * as i18n from './translations'; import { useOnOpenCloseHandler } from '../../../helper_hooks'; +import { RiskScore } from '../../../common/components/severity/common'; +import { RiskSeverity } from '../../../../common/search_strategy'; const tableColumns: Array> = [ { field: 'classification', name: i18n.INFORMATION_CLASSIFICATION_HEADER, - render: (riskScore?: HostRiskSeverity) => { + render: (riskScore?: RiskSeverity) => { if (riskScore != null) { - return ; + return ; } }, }, @@ -50,15 +52,15 @@ const tableColumns: Array> = [ interface TableItem { range?: string; - classification: HostRiskSeverity; + classification: RiskSeverity; } const tableItems: TableItem[] = [ - { classification: HostRiskSeverity.critical, range: i18n.CRITICAL_RISK_DESCRIPTION }, - { classification: HostRiskSeverity.high, range: '70 - 90 ' }, - { classification: HostRiskSeverity.moderate, range: '40 - 70' }, - { classification: HostRiskSeverity.low, range: '20 - 40' }, - { classification: HostRiskSeverity.unknown, range: i18n.UNKNOWN_RISK_DESCRIPTION }, + { classification: RiskSeverity.critical, range: i18n.CRITICAL_RISK_DESCRIPTION }, + { classification: RiskSeverity.high, range: '70 - 90 ' }, + { classification: RiskSeverity.moderate, range: '40 - 70' }, + { classification: RiskSeverity.low, range: '20 - 40' }, + { classification: RiskSeverity.unknown, range: i18n.UNKNOWN_RISK_DESCRIPTION }, ]; export const HOST_RISK_INFO_BUTTON_CLASS = 'HostRiskInformation__button'; diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/columns.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/columns.tsx index cab6dd08ef01..cfa2c20ec060 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/columns.tsx @@ -19,14 +19,14 @@ import { Provider } from '../../../timelines/components/timeline/data_providers/ import { HostRiskScoreColumns } from '.'; import * as i18n from './translations'; -import { HostRiskScore } from '../common/host_risk_score'; -import { HostRiskSeverity } from '../../../../common/search_strategy'; import { HostsTableType } from '../../store/model'; +import { RiskSeverity } from '../../../../common/search_strategy'; +import { RiskScore } from '../../../common/components/severity/common'; export const getHostRiskScoreColumns = ({ dispatchSeverityUpdate, }: { - dispatchSeverityUpdate: (s: HostRiskSeverity) => void; + dispatchSeverityUpdate: (s: RiskSeverity) => void; }): HostRiskScoreColumns => [ { field: 'host.name', @@ -96,7 +96,7 @@ export const getHostRiskScoreColumns = ({ render: (risk) => { if (risk != null) { return ( - dispatchSeverityUpdate(risk)}> {i18n.VIEW_HOSTS_BY_SEVERITY(risk.toLowerCase())} diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx index 9994a03b1e66..e4130eee2190 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx @@ -20,17 +20,20 @@ import { hostsActions, hostsModel, hostsSelectors } from '../../store'; import { getHostRiskScoreColumns } from './columns'; import type { HostsRiskScore, - HostRiskScoreItem, - HostRiskScoreSortField, + RiskScoreItem, + RiskScoreSortField, + RiskSeverity, } from '../../../../common/search_strategy'; -import { HostRiskScoreFields, HostRiskSeverity } from '../../../../common/search_strategy'; +import { RiskScoreFields } from '../../../../common/search_strategy'; import { State } from '../../../common/store'; import * as i18n from '../hosts_table/translations'; import * as i18nHosts from './translations'; -import { SeverityBar } from './severity_bar'; -import { SeverityBadges } from './severity_badges'; -import { SeverityFilterGroup } from './severity_filter_group'; -import { SeverityCount } from '../../containers/kpi_hosts/risky_hosts'; + +import { SeverityBadges } from '../../../common/components/severity/severity_badges'; +import { SeverityBar } from '../../../common/components/severity/severity_bar'; +import { SeverityFilterGroup } from '../../../common/components/severity/severity_filter_group'; + +import { SeverityCount } from '../../../common/components/severity/types'; export const rowItems: ItemsPerRow[] = [ { @@ -57,9 +60,9 @@ interface HostRiskScoreTableProps { } export type HostRiskScoreColumns = [ - Columns, - Columns, - Columns + Columns, + Columns, + Columns ]; const HostRiskScoreTableComponent: React.FC = ({ @@ -108,7 +111,7 @@ const HostRiskScoreTableComponent: React.FC = ({ if (newSort.direction !== sort.direction || newSort.field !== sort.field) { dispatch( hostsActions.updateHostRiskScoreSort({ - sort: newSort as HostRiskScoreSortField, + sort: newSort as RiskScoreSortField, hostsType: type, }) ); @@ -118,7 +121,7 @@ const HostRiskScoreTableComponent: React.FC = ({ [dispatch, sort, type] ); const dispatchSeverityUpdate = useCallback( - (s: HostRiskSeverity) => { + (s: RiskSeverity) => { dispatch( hostsActions.updateHostRiskScoreSeverityFilter({ severitySelection: [s], @@ -158,13 +161,41 @@ const HostRiskScoreTableComponent: React.FC = ({ ); + + const getHostRiskScoreFilterQuerySelector = useMemo( + () => hostsSelectors.hostRiskScoreSeverityFilterSelector(), + [] + ); + const severitySelectionRedux = useDeepEqualSelector((state: State) => + getHostRiskScoreFilterQuerySelector(state, type) + ); + + const onSelect = useCallback( + (newSelection: RiskSeverity[]) => { + dispatch( + hostsActions.updateHostRiskScoreSeverityFilter({ + severitySelection: newSelection, + hostsType: type, + }) + ); + }, + [dispatch, type] + ); + return ( } + headerFilters={ + + } headerSupplement={risk} headerTitle={headerTitle} headerUnit={i18n.UNIT(totalCount)} diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/translations.ts b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/translations.ts index 9a1fc5600f52..07628c90bfb7 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/translations.ts +++ b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/translations.ts @@ -20,6 +20,7 @@ export const HOST_RISK_SCORE = i18n.translate( export const HOST_RISK = i18n.translate('xpack.securitySolution.hostsRiskTable.riskTitle', { defaultMessage: 'Host risk classification', }); + export const HOST_RISK_TOOLTIP = i18n.translate( 'xpack.securitySolution.hostsRiskTable.hostRiskToolTip', { diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.test.tsx index cf0d881b0923..a96ffb577d90 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.test.tsx @@ -9,9 +9,9 @@ import { render } from '@testing-library/react'; import React from 'react'; import { HostRiskScoreOverTime } from '.'; import { TestProviders } from '../../../common/mock'; -import { useHostRiskScore } from '../../containers/host_risk_score'; +import { useHostRiskScore } from '../../../risk_score/containers'; -jest.mock('../../containers/host_risk_score'); +jest.mock('../../../risk_score/containers'); const useHostRiskScoreMock = useHostRiskScore as jest.Mock; describe('Host Risk Flyout', () => { diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.tsx index 02352430b965..52a840e857ff 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.tsx @@ -27,10 +27,10 @@ import { HeaderSection } from '../../../common/components/header_section'; import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; import * as i18n from './translations'; import { PreferenceFormattedDate } from '../../../common/components/formatted_date'; -import { HostRiskScoreQueryId } from '../../../common/containers/hosts_risk/types'; -import { useHostRiskScore } from '../../containers/host_risk_score'; import { useQueryInspector } from '../../../common/components/page/manage_query'; import { HostsComponentsQueryProps } from '../../pages/navigation/types'; +import { buildHostNamesFilter } from '../../../../common/search_strategy/security_solution/risk_score'; +import { HostRiskScoreQueryId, useHostRiskScore } from '../../../risk_score/containers'; export interface HostRiskScoreOverTimeProps extends Pick { @@ -80,7 +80,7 @@ const HostRiskScoreOverTimeComponent: React.FC = ({ const theme = useTheme(); const [loading, { data, refetch, inspect }] = useHostRiskScore({ - hostName, + filterQuery: hostName ? buildHostNamesFilter([hostName]) : undefined, onlyLatest: false, timerange, }); diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/columns.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/columns.tsx index d6f7809cca60..88d041775320 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/columns.tsx @@ -21,13 +21,13 @@ import { DefaultDraggable } from '../../../common/components/draggables'; import { HostsTableColumns } from './'; import * as i18n from './translations'; -import { HostRiskSeverity, Maybe } from '../../../../common/search_strategy'; -import { HostRiskScore } from '../common/host_risk_score'; +import { Maybe, RiskSeverity } from '../../../../common/search_strategy'; import { VIEW_HOSTS_BY_SEVERITY } from '../host_risk_score_table/translations'; +import { RiskScore } from '../../../common/components/severity/common'; export const getHostsColumns = ( showRiskColumn: boolean, - dispatchSeverityUpdate: (s: HostRiskSeverity) => void + dispatchSeverityUpdate: (s: RiskSeverity) => void ): HostsTableColumns => { const columns: HostsTableColumns = [ { @@ -155,10 +155,10 @@ export const getHostsColumns = ( truncateText: false, mobileOptions: { show: true }, sortable: false, - render: (riskScore: HostRiskSeverity) => { + render: (riskScore: RiskSeverity) => { if (riskScore != null) { return ( - dispatchSeverityUpdate(riskScore)}> {VIEW_HOSTS_BY_SEVERITY(riskScore.toLowerCase())} diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx index 2415d83f11fe..01306004844d 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx @@ -25,9 +25,8 @@ import { HostItem, HostsSortField, HostsFields, - HostRiskSeverity, } from '../../../../common/search_strategy/security_solution/hosts'; -import { Direction } from '../../../../common/search_strategy'; +import { Direction, RiskSeverity } from '../../../../common/search_strategy'; import { HostEcs, OsEcs } from '../../../../common/ecs/host'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { SecurityPageName } from '../../../../common/constants'; @@ -53,7 +52,7 @@ export type HostsTableColumns = [ Columns, Columns, Columns, - Columns? + Columns? ]; const rowItems: ItemsPerRow[] = [ @@ -135,7 +134,7 @@ const HostsTableComponent: React.FC = ({ const riskyHostsFeatureEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled'); const dispatchSeverityUpdate = useCallback( - (s: HostRiskSeverity) => { + (s: RiskSeverity) => { dispatch( hostsActions.updateHostRiskScoreSeverityFilter({ severitySelection: [s], diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/index.tsx index 9660aa059e77..4619b300ca05 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/index.tsx @@ -15,7 +15,7 @@ import { HostsKpiProps } from './types'; import { CallOutSwitcher } from '../../../common/components/callouts'; import { RISKY_HOSTS_DOC_LINK } from '../../../overview/components/overview_risky_host_links/risky_hosts_disabled_module'; import * as i18n from './translations'; -import { useHostRiskScore } from '../../containers/host_risk_score'; +import { useHostRiskScore } from '../../../risk_score/containers'; export const HostsKpiComponent = React.memo( ({ filterQuery, from, indexNames, to, setQuery, skip, narrowDateRange }) => { diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.test.tsx index f0e3dcfb69c6..c4fa134bd88e 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.test.tsx @@ -11,9 +11,7 @@ import { render } from '@testing-library/react'; import { RiskyHosts } from './'; import { TestProviders } from '../../../../common/mock'; -import { HostsKpiRiskyHostsStrategyResponse } from '../../../../../common/search_strategy'; - -jest.mock('../../../containers/kpi_hosts/risky_hosts'); +import { KpiRiskScoreStrategyResponse } from '../../../../../common/search_strategy'; describe('RiskyHosts', () => { const defaultProps = { @@ -54,9 +52,9 @@ describe('RiskyHosts', () => { }); test('it displays risky hosts quantity returned by the API', () => { - const data: HostsKpiRiskyHostsStrategyResponse = { - rawResponse: {} as HostsKpiRiskyHostsStrategyResponse['rawResponse'], - riskyHosts: { + const data: KpiRiskScoreStrategyResponse = { + rawResponse: {} as KpiRiskScoreStrategyResponse['rawResponse'], + kpiRiskScore: { Critical: 1, High: 1, Unknown: 0, diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx index 498c5223d61e..d4897702f940 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx @@ -25,19 +25,16 @@ import { import { HostsKpiBaseComponentLoader } from '../common'; import * as i18n from './translations'; -import { - HostRiskSeverity, - HostsKpiRiskyHostsStrategyResponse, -} from '../../../../../common/search_strategy/security_solution/hosts/kpi/risky_hosts'; - import { useInspectQuery } from '../../../../common/hooks/use_inspect_query'; import { useErrorToast } from '../../../../common/hooks/use_error_toast'; -import { HostRiskScore } from '../../common/host_risk_score'; + import { HostRiskInformationButtonIcon, HOST_RISK_INFO_BUTTON_CLASS, } from '../../host_risk_information'; import { HoverVisibilityContainer } from '../../../../common/components/hover_visibility_container'; +import { KpiRiskScoreStrategyResponse, RiskSeverity } from '../../../../../common/search_strategy'; +import { RiskScore } from '../../../../common/components/severity/common'; const QUERY_ID = 'hostsKpiRiskyHostsQuery'; @@ -63,7 +60,7 @@ const RiskScoreContainer = styled(EuiFlexItem)` const RiskyHostsComponent: React.FC<{ error: unknown; loading: boolean; - data?: HostsKpiRiskyHostsStrategyResponse; + data?: KpiRiskScoreStrategyResponse; }> = ({ error, loading, data }) => { useInspectQuery(QUERY_ID, loading, data); useErrorToast(i18n.ERROR_TITLE, error); @@ -72,8 +69,8 @@ const RiskyHostsComponent: React.FC<{ return ; } - const criticalRiskCount = data?.riskyHosts.Critical ?? 0; - const hightlRiskCount = data?.riskyHosts.High ?? 0; + const criticalRiskCount = data?.kpiRiskScore.Critical ?? 0; + const hightlRiskCount = data?.kpiRiskScore.High ?? 0; const totalCount = criticalRiskCount + hightlRiskCount; @@ -118,7 +115,7 @@ const RiskyHostsComponent: React.FC<{ - + @@ -130,7 +127,7 @@ const RiskyHostsComponent: React.FC<{ - + diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx index 8f60ffad7214..2f3a414344cf 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx @@ -9,9 +9,9 @@ import { render } from '@testing-library/react'; import React from 'react'; import { TopHostScoreContributors } from '.'; import { TestProviders } from '../../../common/mock'; -import { useHostRiskScore } from '../../containers/host_risk_score'; +import { useHostRiskScore } from '../../../risk_score/containers'; -jest.mock('../../containers/host_risk_score'); +jest.mock('../../../risk_score/containers'); const useHostRiskScoreMock = useHostRiskScore as jest.Mock; describe('Host Risk Flyout', () => { diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx index cd294f250b4c..042a28edbad4 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx @@ -19,13 +19,14 @@ import { HeaderSection } from '../../../common/components/header_section'; import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; import * as i18n from './translations'; import { Direction } from '../../../../../timelines/common'; -import { HostRiskScoreQueryId } from '../../../common/containers/hosts_risk/types'; -import { HostRiskScoreFields } from '../../../../common/search_strategy'; -import { useHostRiskScore } from '../../containers/host_risk_score'; + +import { buildHostNamesFilter, RiskScoreFields } from '../../../../common/search_strategy'; + import { useQueryInspector } from '../../../common/components/page/manage_query'; import { HostsComponentsQueryProps } from '../../pages/navigation/types'; import { RuleLink } from '../../../detections/pages/detection_engine/rules/all/use_columns'; +import { HostRiskScoreQueryId, useHostRiskScore } from '../../../risk_score/containers'; export interface TopHostScoreContributorsProps extends Pick { @@ -36,7 +37,7 @@ export interface TopHostScoreContributorsProps interface TableItem { rank: number; name: string; - id?: string; // TODO Remove the '?' when the new transform is delivered + id: string; } const columns: Array> = [ @@ -74,13 +75,10 @@ const TopHostScoreContributorsComponent: React.FC [from, to] ); - const sort = useMemo( - () => ({ field: HostRiskScoreFields.timestamp, direction: Direction.desc }), - [] - ); + const sort = useMemo(() => ({ field: RiskScoreFields.timestamp, direction: Direction.desc }), []); const [loading, { data, refetch, inspect }] = useHostRiskScore({ - hostName, + filterQuery: hostName ? buildHostNamesFilter([hostName]) : undefined, timerange, onlyLatest: false, sort, diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/risky_hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/risky_hosts/index.tsx deleted file mode 100644 index 090560842f38..000000000000 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/risky_hosts/index.tsx +++ /dev/null @@ -1,131 +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 type { Observable } from 'rxjs'; -import { filter } from 'rxjs/operators'; -import { useEffect, useMemo, useState } from 'react'; -import { useObservable, withOptionalSignal } from '@kbn/securitysolution-hook-utils'; -import { createFilter } from '../../../../common/containers/helpers'; - -import { - getHostRiskIndex, - HostRiskSeverity, - HostsKpiQueries, - RequestBasicOptions, -} from '../../../../../common/search_strategy'; - -import { - isCompleteResponse, - isErrorResponse, -} from '../../../../../../../../src/plugins/data/common'; -import type { DataPublicPluginStart } from '../../../../../../../../src/plugins/data/public'; -import type { HostsKpiRiskyHostsStrategyResponse } from '../../../../../common/search_strategy/security_solution/hosts/kpi/risky_hosts'; -import { useKibana } from '../../../../common/lib/kibana'; -import { isIndexNotFoundError } from '../../../../common/utils/exceptions'; -import { ESTermQuery } from '../../../../../common/typed_json'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; - -export type RiskyHostsScoreRequestOptions = RequestBasicOptions; - -type GetHostsRiskScoreProps = RiskyHostsScoreRequestOptions & { - data: DataPublicPluginStart; - signal: AbortSignal; -}; - -const getRiskyHosts = ({ - data, - defaultIndex, - timerange, - signal, - filterQuery, -}: GetHostsRiskScoreProps): Observable => - data.search.search( - { - defaultIndex, - factoryQueryType: HostsKpiQueries.kpiRiskyHosts, - filterQuery: createFilter(filterQuery), - timerange, - }, - { - strategy: 'securitySolutionSearchStrategy', - abortSignal: signal, - } - ); - -const getRiskyHostsComplete = ( - props: GetHostsRiskScoreProps -): Observable => { - return getRiskyHosts(props).pipe( - filter((response) => { - return isErrorResponse(response) || isCompleteResponse(response); - }) - ); -}; - -const getRiskyHostsWithOptionalSignal = withOptionalSignal(getRiskyHostsComplete); - -const useRiskyHostsComplete = () => useObservable(getRiskyHostsWithOptionalSignal); - -interface UseRiskyHostProps { - filterQuery?: string | ESTermQuery; - from: string; - to: string; - skip?: boolean; -} -export type SeverityCount = { - [k in HostRiskSeverity]: number; -}; - -interface RiskScoreKpi { - error: unknown; - isModuleDisabled: boolean; - severityCount: SeverityCount; - loading: boolean; -} - -export const useRiskScoreKpi = ({ - filterQuery, - from, - to, - skip, -}: UseRiskyHostProps): RiskScoreKpi => { - const riskyHostsFeatureEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled'); - const { error, result, start, loading } = useRiskyHostsComplete(); - const { data, spaces } = useKibana().services; - const isModuleDisabled = !!error && isIndexNotFoundError(error); - const [spaceId, setSpaceId] = useState(); - - useEffect(() => { - if (spaces) { - spaces.getActiveSpace().then((space) => setSpaceId(space.id)); - } - }, [spaces]); - - useEffect(() => { - if (!skip && spaceId && riskyHostsFeatureEnabled) { - start({ - data, - timerange: { to, from, interval: '' }, - filterQuery, - defaultIndex: [getHostRiskIndex(spaceId)], - }); - } - }, [data, spaceId, start, filterQuery, to, from, skip, riskyHostsFeatureEnabled]); - - const severityCount = useMemo( - () => ({ - [HostRiskSeverity.unknown]: 0, - [HostRiskSeverity.low]: 0, - [HostRiskSeverity.moderate]: 0, - [HostRiskSeverity.high]: 0, - [HostRiskSeverity.critical]: 0, - ...(result?.riskyHosts ?? {}), - }), - [result] - ); - return { error, severityCount, loading, isModuleDisabled }; -}; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx index f2d7c70ab2f0..11a422fa0cd3 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx @@ -7,15 +7,17 @@ import React, { useMemo } from 'react'; import { noop } from 'lodash/fp'; -import { useHostRiskScore } from '../../containers/host_risk_score'; import { HostsComponentsQueryProps } from './types'; import { manageQuery } from '../../../common/components/page/manage_query'; import { HostRiskScoreTable } from '../../components/host_risk_score_table'; -import { useRiskScoreKpi } from '../../containers/kpi_hosts/risky_hosts'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { hostsModel, hostsSelectors } from '../../store'; import { State } from '../../../common/store'; -import { HostRiskScoreQueryId } from '../../../common/containers/hosts_risk/types'; +import { + HostRiskScoreQueryId, + useHostRiskScore, + useHostRiskScoreKpi, +} from '../../../risk_score/containers'; const HostRiskScoreTableManage = manageQuery(HostRiskScoreTable); @@ -48,10 +50,8 @@ export const HostRiskScoreQueryTabBody = ({ sort, }); - const { severityCount, loading: isKpiLoading } = useRiskScoreKpi({ + const { severityCount, loading: isKpiLoading } = useHostRiskScoreKpi({ filterQuery, - from: startDate, - to: endDate, }); return ( diff --git a/x-pack/plugins/security_solution/public/hosts/store/actions.ts b/x-pack/plugins/security_solution/public/hosts/store/actions.ts index 92029913e6fc..c9e6360dc8b4 100644 --- a/x-pack/plugins/security_solution/public/hosts/store/actions.ts +++ b/x-pack/plugins/security_solution/public/hosts/store/actions.ts @@ -6,11 +6,8 @@ */ import actionCreatorFactory from 'typescript-fsa'; -import { - HostRiskSeverity, - HostsSortField, - HostRiskScoreSortField, -} from '../../../common/search_strategy/security_solution/hosts'; +import { RiskScoreSortField, RiskSeverity } from '../../../common/search_strategy'; +import { HostsSortField } from '../../../common/search_strategy/security_solution/hosts'; import { HostsTableType, HostsType } from './model'; const actionCreator = actionCreatorFactory('x-pack/security_solution/local/hosts'); @@ -39,11 +36,11 @@ export const updateHostsSort = actionCreator<{ }>('UPDATE_HOSTS_SORT'); export const updateHostRiskScoreSort = actionCreator<{ - sort: HostRiskScoreSortField; + sort: RiskScoreSortField; hostsType: HostsType; }>('UPDATE_HOST_RISK_SCORE_SORT'); export const updateHostRiskScoreSeverityFilter = actionCreator<{ - severitySelection: HostRiskSeverity[]; + severitySelection: RiskSeverity[]; hostsType: HostsType; }>('UPDATE_HOST_RISK_SCORE_SEVERITY'); diff --git a/x-pack/plugins/security_solution/public/hosts/store/helpers.test.ts b/x-pack/plugins/security_solution/public/hosts/store/helpers.test.ts index b9a194ea07fc..64e4d9088abd 100644 --- a/x-pack/plugins/security_solution/public/hosts/store/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/hosts/store/helpers.test.ts @@ -8,7 +8,7 @@ import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from '../../common/store/constants'; import { HostsModel, HostsTableType, HostsType } from './model'; import { setHostsQueriesActivePageToZero } from './helpers'; -import { Direction, HostsFields, HostRiskScoreFields } from '../../../common/search_strategy'; +import { Direction, HostsFields, RiskScoreFields } from '../../../common/search_strategy'; export const mockHostsState: HostsModel = { page: { @@ -40,7 +40,7 @@ export const mockHostsState: HostsModel = { activePage: DEFAULT_TABLE_ACTIVE_PAGE, limit: DEFAULT_TABLE_LIMIT, sort: { - field: HostRiskScoreFields.riskScore, + field: RiskScoreFields.riskScore, direction: Direction.desc, }, severitySelection: [], @@ -76,7 +76,7 @@ export const mockHostsState: HostsModel = { activePage: DEFAULT_TABLE_ACTIVE_PAGE, limit: DEFAULT_TABLE_LIMIT, sort: { - field: HostRiskScoreFields.riskScore, + field: RiskScoreFields.riskScore, direction: Direction.desc, }, severitySelection: [], diff --git a/x-pack/plugins/security_solution/public/hosts/store/helpers.ts b/x-pack/plugins/security_solution/public/hosts/store/helpers.ts index 4d93aa3b0312..4f09cea7c4f7 100644 --- a/x-pack/plugins/security_solution/public/hosts/store/helpers.ts +++ b/x-pack/plugins/security_solution/public/hosts/store/helpers.ts @@ -5,10 +5,10 @@ * 2.0. */ +import { RiskSeverity } from '../../../common/search_strategy'; import { DEFAULT_TABLE_ACTIVE_PAGE } from '../../common/store/constants'; import { HostsModel, HostsTableType, Queries, HostsType } from './model'; -import { HostRiskSeverity } from '../../../common/search_strategy'; export const setHostPageQueriesActivePageToZero = (state: HostsModel): Queries => ({ ...state.page.queries, @@ -67,7 +67,7 @@ export const setHostsQueriesActivePageToZero = (state: HostsModel, type: HostsTy throw new Error(`HostsType ${type} is unknown`); }; -export const generateSeverityFilter = (severitySelection: HostRiskSeverity[]) => +export const generateSeverityFilter = (severitySelection: RiskSeverity[]) => severitySelection.length > 0 ? [ { diff --git a/x-pack/plugins/security_solution/public/hosts/store/model.ts b/x-pack/plugins/security_solution/public/hosts/store/model.ts index 78e8299cc8de..090a469c5fb7 100644 --- a/x-pack/plugins/security_solution/public/hosts/store/model.ts +++ b/x-pack/plugins/security_solution/public/hosts/store/model.ts @@ -5,12 +5,12 @@ * 2.0. */ +import { Direction } from '../../../common/search_strategy'; import { - Direction, - HostRiskSeverity, - HostRiskScoreSortField, -} from '../../../common/search_strategy'; -import { HostsFields } from '../../../common/search_strategy/security_solution'; + HostsFields, + RiskScoreSortField, + RiskSeverity, +} from '../../../common/search_strategy/security_solution'; export enum HostsType { page = 'page', @@ -38,8 +38,8 @@ export interface HostsQuery extends BasicQueryPaginated { } export interface HostRiskScoreQuery extends BasicQueryPaginated { - sort: HostRiskScoreSortField; - severitySelection: HostRiskSeverity[]; + sort: RiskScoreSortField; + severitySelection: RiskSeverity[]; } export interface Queries { diff --git a/x-pack/plugins/security_solution/public/hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/hosts/store/reducer.ts index 0922ede93594..f413607b85e1 100644 --- a/x-pack/plugins/security_solution/public/hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/hosts/store/reducer.ts @@ -6,7 +6,7 @@ */ import { reducerWithInitialState } from 'typescript-fsa-reducers'; -import { Direction, HostsFields, HostRiskScoreFields } from '../../../common/search_strategy'; +import { Direction, HostsFields, RiskScoreFields } from '../../../common/search_strategy'; import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from '../../common/store/constants'; @@ -57,7 +57,7 @@ export const initialHostsState: HostsState = { activePage: DEFAULT_TABLE_ACTIVE_PAGE, limit: DEFAULT_TABLE_LIMIT, sort: { - field: HostRiskScoreFields.riskScore, + field: RiskScoreFields.riskScore, direction: Direction.desc, }, severitySelection: [], @@ -93,7 +93,7 @@ export const initialHostsState: HostsState = { activePage: DEFAULT_TABLE_ACTIVE_PAGE, limit: DEFAULT_TABLE_LIMIT, sort: { - field: HostRiskScoreFields.riskScore, + field: RiskScoreFields.riskScore, direction: Direction.desc, }, severitySelection: [], diff --git a/x-pack/plugins/security_solution/public/management/common/constants.ts b/x-pack/plugins/security_solution/public/management/common/constants.ts index dd3db8f3352a..ea38414b0cb9 100644 --- a/x-pack/plugins/security_solution/public/management/common/constants.ts +++ b/x-pack/plugins/security_solution/public/management/common/constants.ts @@ -15,6 +15,7 @@ export const MANAGEMENT_ROUTING_POLICY_DETAILS_FORM_PATH = `${MANAGEMENT_PATH}/: export const MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/trustedApps`; export const MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/eventFilters`; export const MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/hostIsolationExceptions`; +export const MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/blocklists`; /** @deprecated use the paths defined above instead */ export const MANAGEMENT_ROUTING_POLICY_DETAILS_PATH_OLD = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId`; export const MANAGEMENT_ROUTING_TRUSTED_APPS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.trustedApps})`; diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 2f5dbc762b5d..d031e50152a6 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -10,6 +10,7 @@ import { isEmpty } from 'lodash/fp'; import querystring from 'querystring'; import { generatePath } from 'react-router-dom'; import { appendSearch } from '../../common/components/link_to/helpers'; +import { ArtifactListPageUrlParams } from '../components/artifact_list_page'; import { EndpointIndexUIQueryParams } from '../pages/endpoint_hosts/types'; import { EventFiltersPageLocation } from '../pages/event_filters/types'; import { HostIsolationExceptionsPageLocation } from '../pages/host_isolation_exceptions/types'; @@ -29,6 +30,8 @@ import { MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH, MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, + MANAGEMENT_ROUTING_BLOCKLIST_PATH, + MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, } from './constants'; // Taken from: https://github.com/microsoft/TypeScript/issues/12936#issuecomment-559034150 @@ -215,6 +218,29 @@ const normalizeEventFiltersPageLocation = ( } }; +const normalizBlocklistsPageLocation = ( + location?: Partial +): Partial => { + if (location) { + return { + ...(!isDefaultOrMissing(location.page, MANAGEMENT_DEFAULT_PAGE) + ? { page: location.page } + : {}), + ...(!isDefaultOrMissing(location.pageSize, MANAGEMENT_DEFAULT_PAGE_SIZE) + ? { pageSize: location.pageSize } + : {}), + ...(!isDefaultOrMissing(location.show, undefined) ? { show: location.show } : {}), + ...(!isDefaultOrMissing(location.itemId, undefined) ? { id: location.itemId } : {}), + ...(!isDefaultOrMissing(location.filter, '') ? { filter: location.filter } : ''), + ...(!isDefaultOrMissing(location.includedPolicies, '') + ? { includedPolicies: location.includedPolicies } + : ''), + }; + } else { + return {}; + } +}; + const normalizeHostIsolationExceptionsPageLocation = ( location?: Partial ): Partial => { @@ -407,3 +433,24 @@ export const getPolicyHostIsolationExceptionsPath = ( querystring.stringify(normalizePolicyDetailsArtifactsListPageLocation(location)) )}`; }; + +export const getBlocklistsListPath = (location?: Partial): string => { + const path = generatePath(MANAGEMENT_ROUTING_BLOCKLIST_PATH, { + tabName: AdministrationSubTab.blocklist, + }); + + return `${path}${appendSearch(querystring.stringify(normalizBlocklistsPageLocation(location)))}`; +}; + +export const getPolicyBlocklistsPath = ( + policyId: string, + location?: Partial +) => { + const path = generatePath(MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, { + tabName: AdministrationSubTab.policies, + policyId, + }); + return `${path}${appendSearch( + querystring.stringify(normalizePolicyDetailsArtifactsListPageLocation(location)) + )}`; +}; diff --git a/x-pack/plugins/security_solution/public/management/common/translations.ts b/x-pack/plugins/security_solution/public/management/common/translations.ts index 8e6fcd4cc951..e79c1c0b3449 100644 --- a/x-pack/plugins/security_solution/public/management/common/translations.ts +++ b/x-pack/plugins/security_solution/public/management/common/translations.ts @@ -6,10 +6,9 @@ */ import { i18n } from '@kbn/i18n'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { ServerApiError } from '../../common/types'; -import { OperatingSystem } from '../../../common/endpoint/types'; - export const ENDPOINTS_TAB = i18n.translate('xpack.securitySolution.endpointsTab', { defaultMessage: 'Endpoints', }); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx index 238fe87c0589..9b656a97a94a 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx @@ -22,7 +22,7 @@ import { OS_MAC, OS_WINDOWS, CONDITION_AND, - CONDITION_OPERATOR_TYPE_WILDCARD, + CONDITION_OPERATOR_TYPE_WILDCARD_MATCHES, CONDITION_OPERATOR_TYPE_NESTED, CONDITION_OPERATOR_TYPE_MATCH, CONDITION_OPERATOR_TYPE_MATCH_ANY, @@ -45,7 +45,7 @@ const OPERATOR_TYPE_LABELS_INCLUDED = Object.freeze({ [ListOperatorTypeEnum.NESTED]: CONDITION_OPERATOR_TYPE_NESTED, [ListOperatorTypeEnum.MATCH_ANY]: CONDITION_OPERATOR_TYPE_MATCH_ANY, [ListOperatorTypeEnum.MATCH]: CONDITION_OPERATOR_TYPE_MATCH, - [ListOperatorTypeEnum.WILDCARD]: CONDITION_OPERATOR_TYPE_WILDCARD, + [ListOperatorTypeEnum.WILDCARD]: CONDITION_OPERATOR_TYPE_WILDCARD_MATCHES, [ListOperatorTypeEnum.EXISTS]: CONDITION_OPERATOR_TYPE_EXISTS, [ListOperatorTypeEnum.LIST]: CONDITION_OPERATOR_TYPE_LIST, }); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/translations.ts b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/translations.ts index 3290a52c1c37..273cda46aa72 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/translations.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/translations.ts @@ -61,8 +61,8 @@ export const CONDITION_OPERATOR_TYPE_NOT_MATCH = i18n.translate( } ); -export const CONDITION_OPERATOR_TYPE_WILDCARD = i18n.translate( - 'xpack.securitySolution.artifactCard.conditions.wildcardOperator', +export const CONDITION_OPERATOR_TYPE_WILDCARD_MATCHES = i18n.translate( + 'xpack.securitySolution.artifactCard.conditions.wildcardMatchesOperator', { defaultMessage: 'MATCHES', } diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.test.tsx new file mode 100644 index 000000000000..5c1b6e5128a4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.test.tsx @@ -0,0 +1,913 @@ +/* + * Copyright 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 { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import React from 'react'; +import { trustedAppsAllHttpMocks, TrustedAppsGetListHttpMocksInterface } from '../../pages/mocks'; +import { ArtifactListPage, ArtifactListPageProps } from './artifact_list_page'; +import { TrustedAppsApiClient } from '../../pages/trusted_apps/service/trusted_apps_api_client'; +import { artifactListPageLabels } from './translations'; +import { act, fireEvent, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ArtifactFormComponentProps } from './types'; +import type { HttpFetchOptionsWithPath } from 'kibana/public'; +import { ExceptionsListItemGenerator } from '../../../../common/endpoint/data_generators/exceptions_list_item_generator'; +import { BY_POLICY_ARTIFACT_TAG_PREFIX } from '../../../../common/endpoint/service/artifacts'; +import { useUserPrivileges as _useUserPrivileges } from '../../../common/components/user_privileges'; +import { getEndpointPrivilegesInitialStateMock } from '../../../common/components/user_privileges/endpoint/mocks'; + +jest.mock('../../../common/components/user_privileges'); +const useUserPrivileges = _useUserPrivileges as jest.Mock; + +describe('When using the ArtifactListPage component', () => { + let render: ( + props?: Partial + ) => ReturnType; + let renderResult: ReturnType; + let history: AppContextTestRender['history']; + let coreStart: AppContextTestRender['coreStart']; + let mockedApi: ReturnType; + let FormComponentMock: jest.Mock>; + + interface DeferredInterface { + promise: Promise; + resolve: (data: T) => void; + reject: (e: Error) => void; + } + + const getDeferred = function (): DeferredInterface { + let resolve: DeferredInterface['resolve']; + let reject: DeferredInterface['reject']; + + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + // @ts-ignore + return { promise, resolve, reject }; + }; + + /** + * Returns the props object that the Form component was last called with + */ + const getLastFormComponentProps = (): ArtifactFormComponentProps => { + return FormComponentMock.mock.calls[FormComponentMock.mock.calls.length - 1][0]; + }; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + ({ history, coreStart } = mockedContext); + mockedApi = trustedAppsAllHttpMocks(coreStart.http); + + const apiClient = new TrustedAppsApiClient(coreStart.http); + const labels = { ...artifactListPageLabels }; + + FormComponentMock = jest.fn((({ mode, error, disabled }: ArtifactFormComponentProps) => { + return ( +
    +
    {`${mode} form`}
    +
    {`Is Disabled: ${disabled}`}
    + {error && ( + <> +
    {error.message}
    +
    {JSON.stringify(error.body)}
    + + )} +
    + ); + }) as unknown as jest.Mock>); + + render = (props: Partial = {}) => { + return (renderResult = mockedContext.render( + + )); + }; + + // Ensure user privileges are reset + useUserPrivileges.mockReturnValue({ + ...useUserPrivileges(), + endpointPrivileges: getEndpointPrivilegesInitialStateMock(), + }); + }); + + it('should display a loader while determining which view to show', async () => { + // Mock a delay into the list results http call + const deferrable = getDeferred(); + mockedApi.responseProvider.trustedAppsList.mockDelay.mockReturnValue(deferrable.promise); + + const { getByTestId } = render(); + const loader = getByTestId('testPage-pageLoader'); + + expect(loader).not.toBeNull(); + + // release the API call + act(() => { + deferrable.resolve(); + }); + + await waitForElementToBeRemoved(loader); + }); + + describe('and NO data exists', () => { + let renderWithNoData: () => ReturnType; + let originalListApiResponseProvider: TrustedAppsGetListHttpMocksInterface['trustedAppsList']; + + beforeEach(() => { + originalListApiResponseProvider = + mockedApi.responseProvider.trustedAppsList.getMockImplementation()!; + + renderWithNoData = () => { + mockedApi.responseProvider.trustedAppsList.mockReturnValue({ + data: [], + page: 1, + per_page: 10, + total: 0, + }); + + render(); + + return renderResult; + }; + }); + + it('should display empty state', async () => { + renderWithNoData(); + + await waitFor(async () => { + expect(renderResult.getByTestId('testPage-emptyState')); + }); + }); + + it('should hide page headers', async () => { + renderWithNoData(); + + expect(renderResult.queryByTestId('header-page-title')).toBe(null); + }); + + it('should open create flyout when primary button is clicked', async () => { + renderWithNoData(); + const addButton = await renderResult.findByTestId('testPage-emptyState-addButton'); + + act(() => { + userEvent.click(addButton); + }); + + expect(renderResult.getByTestId('testPage-flyout')).toBeTruthy(); + expect(history.location.search).toMatch(/show=create/); + }); + + describe('and the first item is created', () => { + it('should show the list after creating first item and remove empty state', async () => { + renderWithNoData(); + const addButton = await renderResult.findByTestId('testPage-emptyState-addButton'); + + act(() => { + userEvent.click(addButton); + }); + + await waitFor(async () => { + expect(renderResult.getByTestId('testPage-flyout')); + }); + + // indicate form is valid + act(() => { + const lastProps = getLastFormComponentProps(); + lastProps.onChange({ item: { ...lastProps.item, name: 'some name' }, isValid: true }); + }); + + mockedApi.responseProvider.trustedAppsList.mockImplementation( + originalListApiResponseProvider + ); + + // Submit form + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + + // wait for the list to show up + await act(async () => { + await waitFor(() => { + expect(renderResult.getByTestId('testPage-list')).toBeTruthy(); + }); + }); + }); + }); + }); + + describe('and the flyout is opened', () => { + let renderAndWaitForFlyout: ( + props?: Partial + ) => Promise>; + + beforeEach(async () => { + history.push('somepage?show=create'); + + renderAndWaitForFlyout = async (...props) => { + render(...props); + + await waitFor(async () => { + expect(renderResult.getByTestId('testPage-flyout')); + }); + + return renderResult; + }; + }); + + it('should display `Cancel` button enabled', async () => { + await renderAndWaitForFlyout(); + + expect(renderResult.getByTestId('testPage-flyout-cancelButton')).toBeEnabled(); + }); + + it('should display `Submit` button as disabled', async () => { + await renderAndWaitForFlyout(); + + expect(renderResult.getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); + }); + + it.each([ + ['Cancel', 'testPage-flyout-cancelButton'], + ['Close', 'euiFlyoutCloseButton'], + ])('should close flyout when `%s` button is clicked', async (_, testId) => { + await renderAndWaitForFlyout(); + + act(() => { + userEvent.click(renderResult.getByTestId(testId)); + }); + + expect(renderResult.queryByTestId('testPage-flyout')).toBeNull(); + expect(history.location.search).toEqual(''); + }); + + it('should pass to the Form component the expected props', async () => { + await renderAndWaitForFlyout(); + + expect(FormComponentMock).toHaveBeenLastCalledWith( + { + disabled: false, + error: undefined, + item: { + comments: [], + description: '', + entries: [], + item_id: undefined, + list_id: 'endpoint_trusted_apps', + meta: expect.any(Object), + name: '', + namespace_type: 'agnostic', + os_types: ['windows'], + tags: ['policy:all'], + type: 'simple', + }, + mode: 'create', + onChange: expect.any(Function), + }, + expect.anything() + ); + }); + + describe('and form data is valid', () => { + beforeEach(async () => { + const _renderAndWaitForFlyout = renderAndWaitForFlyout; + + // Override renderAndWaitForFlyout to also set the form data as "valid" + renderAndWaitForFlyout = async (...props) => { + await _renderAndWaitForFlyout(...props); + + act(() => { + const lastProps = getLastFormComponentProps(); + lastProps.onChange({ item: { ...lastProps.item, name: 'some name' }, isValid: true }); + }); + + return renderResult; + }; + }); + + it('should enable the `Submit` button', async () => { + await renderAndWaitForFlyout(); + + expect(renderResult.getByTestId('testPage-flyout-submitButton')).toBeEnabled(); + }); + + describe('and user clicks submit', () => { + let releaseApiUpdateResponse: () => void; + let getByTestId: typeof renderResult['getByTestId']; + + beforeEach(async () => { + await renderAndWaitForFlyout(); + + getByTestId = renderResult.getByTestId; + + // Mock a delay into the create api http call + const deferrable = getDeferred(); + mockedApi.responseProvider.trustedAppCreate.mockDelay.mockReturnValue(deferrable.promise); + releaseApiUpdateResponse = deferrable.resolve; + + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + }); + + afterEach(() => { + if (releaseApiUpdateResponse) { + releaseApiUpdateResponse(); + } + }); + + it('should disable all buttons while an update is in flight', () => { + expect(getByTestId('testPage-flyout-cancelButton')).not.toBeEnabled(); + expect(getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); + }); + + it('should display loading indicator on Submit while an update is in flight', () => { + expect( + getByTestId('testPage-flyout-submitButton').querySelector('.euiLoadingSpinner') + ).toBeTruthy(); + }); + + it('should pass `disabled=true` to the Form component while an update is in flight', () => { + expect(getLastFormComponentProps().disabled).toBe(true); + }); + }); + + describe('and submit is successful', () => { + beforeEach(async () => { + await renderAndWaitForFlyout(); + + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + + await act(async () => { + await waitFor(() => { + expect(renderResult.queryByTestId('testPage-flyout')).toBeNull(); + }); + }); + }); + + it('should show a success toast', async () => { + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + '"some name" has been added.' + ); + }); + + it('should clear the URL params', () => { + expect(location.search).toBe(''); + }); + }); + + describe('and submit fails', () => { + beforeEach(async () => { + const _renderAndWaitForFlyout = renderAndWaitForFlyout; + + renderAndWaitForFlyout = async (...args) => { + mockedApi.responseProvider.trustedAppCreate.mockImplementation(() => { + throw new Error('oh oh. no good!'); + }); + + await _renderAndWaitForFlyout(...args); + + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + + await act(async () => { + await waitFor(() => + expect(mockedApi.responseProvider.trustedAppCreate).toHaveBeenCalled() + ); + }); + + return renderResult; + }; + }); + + // FIXME:PT investigate test failure + // (I don't understand why its failing... All assertions are successful -- HELP!) + it.skip('should re-enable `Cancel` and `Submit` buttons', async () => { + await renderAndWaitForFlyout(); + + expect(renderResult.getByTestId('testPage-flyout-cancelButton')).not.toBeEnabled(); + + expect(renderResult.getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); + }); + + // FIXME:PT investigate test failure + // (I don't understand why its failing... All assertions are successful -- HELP!) + it.skip('should pass error along to the Form component and reset disabled back to `false`', async () => { + await renderAndWaitForFlyout(); + const lastFormProps = getLastFormComponentProps(); + + expect(lastFormProps.error).toBeInstanceOf(Error); + expect(lastFormProps.disabled).toBe(false); + }); + }); + + describe('and a custom Submit handler is used', () => { + let handleSubmitCallback: jest.Mock; + let releaseSuccessSubmit: () => void; + let releaseFailureSubmit: () => void; + + beforeEach(async () => { + const deferred = getDeferred(); + releaseSuccessSubmit = () => act(() => deferred.resolve()); + releaseFailureSubmit = () => act(() => deferred.reject(new Error('oh oh. No good'))); + + handleSubmitCallback = jest.fn(async (item) => { + await deferred.promise; + + return new ExceptionsListItemGenerator().generateTrustedApp(item); + }); + + await renderAndWaitForFlyout({ onFormSubmit: handleSubmitCallback }); + + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + }); + + afterEach(() => { + if (releaseSuccessSubmit) { + releaseSuccessSubmit(); + } + }); + + it('should use custom submit handler when submit button is used', async () => { + expect(handleSubmitCallback).toHaveBeenCalled(); + + expect(renderResult.getByTestId('testPage-flyout-cancelButton')).not.toBeEnabled(); + + expect(renderResult.getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); + }); + + it('should catch and show error if one is encountered', async () => { + releaseFailureSubmit(); + await waitFor(() => { + expect(renderResult.getByTestId('formError')).toBeTruthy(); + }); + }); + + it('should show a success toast', async () => { + releaseSuccessSubmit(); + + await waitFor(() => { + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalled(); + }); + + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + '"some name" has been added.' + ); + }); + + it('should clear the URL params', () => { + releaseSuccessSubmit(); + + expect(location.search).toBe(''); + }); + }); + }); + + describe('and in Edit mode', () => { + beforeEach(async () => { + history.push('somepage?show=edit&itemId=123'); + }); + + it('should show loader while initializing in edit mode', async () => { + const deferred = getDeferred(); + mockedApi.responseProvider.trustedApp.mockDelay.mockReturnValue(deferred.promise); + + const { getByTestId } = await renderAndWaitForFlyout(); + + // The loader should be shown and the flyout footer should not be shown + expect(getByTestId('testPage-flyout-loader')).toBeTruthy(); + expect(() => getByTestId('testPage-flyout-cancelButton')).toThrow(); + expect(() => getByTestId('testPage-flyout-submitButton')).toThrow(); + + // The Form should not yet have been rendered + expect(FormComponentMock).not.toHaveBeenCalled(); + + act(() => deferred.resolve()); + + // we should call the GET API with the id provided + await waitFor(() => { + expect(mockedApi.responseProvider.trustedApp).toHaveBeenLastCalledWith( + expect.objectContaining({ + path: expect.any(String), + query: expect.objectContaining({ + item_id: '123', + }), + }) + ); + }); + }); + + it('should provide Form component with the item for edit', async () => { + const { getByTestId } = await renderAndWaitForFlyout(); + + await act(async () => { + await waitFor(() => { + expect(getByTestId('formMock')).toBeTruthy(); + }); + }); + + expect(getLastFormComponentProps().item).toEqual({ + ...mockedApi.responseProvider.trustedApp({ + query: { item_id: '123' }, + } as unknown as HttpFetchOptionsWithPath), + created_at: expect.any(String), + }); + }); + + it('should show error toast and close flyout if item for edit does not exist', async () => { + mockedApi.responseProvider.trustedApp.mockImplementation(() => { + throw new Error('does not exist'); + }); + + await renderAndWaitForFlyout(); + + await act(async () => { + await waitFor(() => { + expect(mockedApi.responseProvider.trustedApp).toHaveBeenCalled(); + }); + }); + + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledWith( + 'Failed to retrieve item for edit. Reason: does not exist' + ); + }); + + it('should not show the expired license callout', async () => { + const { queryByTestId, getByTestId } = await renderAndWaitForFlyout(); + + await act(async () => { + await waitFor(() => { + expect(getByTestId('formMock')).toBeTruthy(); + }); + }); + + expect(queryByTestId('testPage-flyout-expiredLicenseCallout')).not.toBeTruthy(); + }); + + it('should show expired license warning when unsupported features are being used (downgrade scenario)', async () => { + // make the API return a policy specific item + const _generateResponse = mockedApi.responseProvider.trustedApp.getMockImplementation()!; + mockedApi.responseProvider.trustedApp.mockImplementation((params) => { + return { + ..._generateResponse(params), + tags: [`${BY_POLICY_ARTIFACT_TAG_PREFIX}${123}`], + }; + }); + + useUserPrivileges.mockReturnValue({ + ...useUserPrivileges(), + endpointPrivileges: getEndpointPrivilegesInitialStateMock({ + canCreateArtifactsByPolicy: false, + }), + }); + + const { getByTestId } = await renderAndWaitForFlyout(); + + await act(async () => { + await waitFor(() => { + expect(getByTestId('formMock')).toBeTruthy(); + }); + }); + + expect(getByTestId('testPage-flyout-expiredLicenseCallout')).toBeTruthy(); + }); + }); + }); + + describe('and data exists', () => { + let renderWithListData: () => Promise>; + + const getFirstCard = async ({ + showActions = false, + }: Partial<{ showActions: boolean }> = {}): Promise => { + const cards = await renderResult.findAllByTestId('testPage-card'); + + if (cards.length === 0) { + throw new Error('No cards found!'); + } + + const card = cards[0]; + + if (showActions) { + await act(async () => { + userEvent.click(within(card).getByTestId('testPage-card-header-actions-button')); + + await waitFor(() => { + expect(renderResult.getByTestId('testPage-card-header-actions-contextMenuPanel')); + }); + }); + } + + return card; + }; + + beforeEach(async () => { + renderWithListData = async () => { + render(); + + await act(async () => { + await waitFor(() => { + expect(renderResult.getByTestId('testPage-list')).toBeTruthy(); + }); + }); + + return renderResult; + }; + }); + + it('should show list data loading indicator while list results are retrieved (and after list was checked to see if it has data)', async () => { + // add a delay to the list results, but not to the API call + // that is used to determine if the list contains data + mockedApi.responseProvider.trustedAppsList.mockDelay.mockImplementation(async (options) => { + const query = options.query as { page?: number; per_page?: number }; + if (query.page === 1 && query.per_page === 1) { + return; + } + + return new Promise((r) => setTimeout(r, 50)); + }); + + const { getByTestId } = await renderWithListData(); + + expect(getByTestId('testPage-list-loader')).toBeTruthy(); + }); + + it(`should show cards with results`, async () => { + const { findAllByTestId, getByTestId } = await renderWithListData(); + + await expect(findAllByTestId('testPage-card')).resolves.toHaveLength(10); + expect(getByTestId('testPage-showCount').textContent).toBe('Showing 20 artifacts'); + }); + + it('should show card actions', async () => { + const { getByTestId } = await renderWithListData(); + await getFirstCard({ showActions: true }); + + expect(getByTestId('testPage-card-cardEditAction')).toBeTruthy(); + expect(getByTestId('testPage-card-cardDeleteAction')).toBeTruthy(); + }); + + it('should persist pagination `page` changes to the URL', async () => { + const { getByTestId } = await renderWithListData(); + act(() => { + userEvent.click(getByTestId('pagination-button-1')); + }); + + await waitFor(() => { + expect(history.location.search).toMatch(/page=2/); + }); + }); + + it('should persist pagination `page size` changes to the URL', async () => { + const { getByTestId } = await renderWithListData(); + act(() => { + userEvent.click(getByTestId('tablePaginationPopoverButton')); + }); + await act(async () => { + await waitFor(() => { + expect(getByTestId('tablePagination-20-rows')); + }); + userEvent.click(getByTestId('tablePagination-20-rows')); + }); + + await waitFor(() => { + expect(history.location.search).toMatch(/pageSize=20/); + }); + }); + + describe('and interacting with card actions', () => { + const clickCardAction = async (action: 'edit' | 'delete') => { + await getFirstCard({ showActions: true }); + act(() => { + switch (action) { + case 'delete': + userEvent.click(renderResult.getByTestId('testPage-card-cardDeleteAction')); + break; + + case 'edit': + userEvent.click(renderResult.getByTestId('testPage-card-cardEditAction')); + break; + } + }); + }; + + it('should display the Edit flyout when edit action is clicked', async () => { + const { getByTestId } = await renderWithListData(); + await clickCardAction('edit'); + + expect(getByTestId('testPage-flyout')).toBeTruthy(); + }); + + it('should display the Delete modal when delete action is clicked', async () => { + const { getByTestId } = await renderWithListData(); + await clickCardAction('delete'); + + expect(getByTestId('testPage-deleteModal')).toBeTruthy(); + }); + + describe('and interacting with the deletion modal', () => { + let cancelButton: HTMLButtonElement; + let submitButton: HTMLButtonElement; + + beforeEach(async () => { + await renderWithListData(); + await clickCardAction('delete'); + + cancelButton = renderResult.getByTestId( + 'testPage-deleteModal-cancelButton' + ) as HTMLButtonElement; + submitButton = renderResult.getByTestId( + 'testPage-deleteModal-submitButton' + ) as HTMLButtonElement; + }); + + it('should show Cancel and Delete buttons enabled', async () => { + expect(cancelButton).toBeEnabled(); + expect(submitButton).toBeEnabled(); + }); + + it('should close modal if Cancel/Close buttons are clicked', async () => { + userEvent.click(cancelButton); + + expect(renderResult.queryByTestId('testPage-deleteModal')).toBeNull(); + }); + + it('should prevent modal from being closed while deletion is in flight', async () => { + const deferred = getDeferred(); + mockedApi.responseProvider.trustedAppDelete.mockDelay.mockReturnValue(deferred.promise); + + act(() => { + userEvent.click(submitButton); + }); + + await waitFor(() => { + expect(cancelButton).toBeEnabled(); + expect(submitButton).toBeEnabled(); + }); + + deferred.resolve(); // cleanup + }); + + it('should show success toast if deleted successfully', async () => { + act(() => { + userEvent.click(submitButton); + }); + + await act(async () => { + await waitFor(() => { + expect(mockedApi.responseProvider.trustedAppDelete).toHaveBeenCalled(); + }); + }); + + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + expect.stringMatching(/ has been removed$/) + ); + }); + + // FIXME:PT investigate test failure + // (I don't understand why its failing... All assertions are successful -- HELP!) + it.skip('should show error toast if deletion failed', async () => { + mockedApi.responseProvider.trustedAppDelete.mockImplementation(() => { + throw new Error('oh oh'); + }); + + act(() => { + userEvent.click(submitButton); + }); + + await act(async () => { + await waitFor(() => { + expect(mockedApi.responseProvider.trustedAppDelete).toHaveBeenCalled(); + }); + }); + + expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( + expect.stringMatching(/^Unable to remove .*\. Reason: oh oh/) + ); + expect(renderResult.getByTestId('testPage-deleteModal')).toBeTruthy(); + expect(cancelButton).toBeEnabled(); + expect(submitButton).toBeEnabled(); + }); + }); + }); + + describe('and search bar is used', () => { + const clickSearchButton = () => { + act(() => { + fireEvent.click(renderResult.getByTestId('searchButton')); + }); + }; + + beforeEach(async () => { + await renderWithListData(); + }); + + it('should persist filter to the URL params', async () => { + act(() => { + userEvent.type(renderResult.getByTestId('searchField'), 'fooFooFoo'); + }); + clickSearchButton(); + + await waitFor(() => { + expect(history.location.search).toMatch(/fooFooFoo/); + }); + + await waitFor(() => { + expect(mockedApi.responseProvider.trustedAppsList).toHaveBeenLastCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + filter: expect.stringMatching(/\*fooFooFoo\*/), + }), + }) + ); + }); + }); + + it('should persist policy filter to the URL params', async () => { + const policyId = mockedApi.responseProvider.endpointPackagePolicyList().items[0].id; + const firstPolicyTestId = `policiesSelector-popover-items-${policyId}`; + + await act(async () => { + await waitFor(() => { + expect(renderResult.getByTestId('policiesSelectorButton')).toBeTruthy(); + }); + }); + + act(() => { + userEvent.click(renderResult.getByTestId('policiesSelectorButton')); + }); + + await act(async () => { + await waitFor(() => { + expect(renderResult.getByTestId(firstPolicyTestId)).toBeTruthy(); + }); + userEvent.click(renderResult.getByTestId(firstPolicyTestId)); + }); + + await waitFor(() => { + expect(history.location.search).toMatch(new RegExp(`includedPolicies=${policyId}`)); + }); + }); + + it('should trigger a current page data fetch when Refresh button is clicked', async () => { + const currentApiCount = mockedApi.responseProvider.trustedAppsList.mock.calls.length; + + clickSearchButton(); + + await waitFor(() => { + expect(mockedApi.responseProvider.trustedAppsList).toHaveBeenCalledTimes( + currentApiCount + 1 + ); + }); + }); + + it('should show a no results found message if filter did not return any results', async () => { + let apiNoResultsDone = false; + mockedApi.responseProvider.trustedAppsList.mockImplementationOnce(() => { + apiNoResultsDone = true; + + return { + page: 1, + per_page: 10, + total: 0, + data: [], + }; + }); + + act(() => { + userEvent.type(renderResult.getByTestId('searchField'), 'fooFooFoo'); + }); + + clickSearchButton(); + + await act(async () => { + await waitFor(() => { + expect(apiNoResultsDone).toBe(true); + }); + }); + + await waitFor(() => { + // console.log(`\n\n${renderResult.getByTestId('testPage-list').outerHTML}\n\n\n`); + expect(renderResult.getByTestId('testPage-list-noResults')).toBeTruthy(); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx index 87673cf5c1e4..87e3b2bb0051 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx @@ -20,19 +20,19 @@ import { ArtifactEntryCard } from '../artifact_entry_card'; import { ArtifactListPageLabels, artifactListPageLabels } from './translations'; import { useTestIdGenerator } from '../hooks/use_test_id_generator'; import { ManagementPageLoader } from '../management_page_loader'; -import { SearchExceptions } from '../search_exceptions'; +import { SearchExceptions, SearchExceptionsProps } from '../search_exceptions'; import { useArtifactCardPropsProvider, UseArtifactCardPropsProviderProps, } from './hooks/use_artifact_card_props_provider'; import { NoDataEmptyState } from './components/no_data_empty_state'; -import { ArtifactFlyoutProps, MaybeArtifactFlyout } from './components/artifact_flyout'; +import { ArtifactFlyoutProps, ArtifactFlyout } from './components/artifact_flyout'; import { useIsFlyoutOpened } from './hooks/use_is_flyout_opened'; import { useSetUrlParams } from './hooks/use_set_url_params'; import { useWithArtifactListData } from './hooks/use_with_artifact_list_data'; import { ExceptionsListApiClient } from '../../services/exceptions_list/exceptions_list_api_client'; import { ArtifactListPageUrlParams } from './types'; -import { useUrlParams } from './hooks/use_url_params'; +import { useUrlParams } from '../hooks/use_url_params'; import { ListPageRouteState, MaybeImmutable } from '../../../../common/endpoint/types'; import { DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS } from '../../../../common/endpoint/service/artifacts/constants'; import { ArtifactDeleteModal } from './components/artifact_delete_modal'; @@ -42,6 +42,7 @@ import { useToasts } from '../../../common/lib/kibana'; import { useMemoizedRouteState } from '../../common/hooks'; import { BackToExternalAppSecondaryButton } from '../back_to_external_app_secondary_button'; import { BackToExternalAppButton } from '../back_to_external_app_button'; +import { useIsMounted } from '../hooks/use_is_mounted'; type ArtifactEntryCardType = typeof ArtifactEntryCard; @@ -56,6 +57,13 @@ export interface ArtifactListPageProps { ArtifactFormComponent: ArtifactFlyoutProps['FormComponent']; /** A list of labels for the given artifact page. Not all have to be defined, only those that should override the defaults */ labels: ArtifactListPageLabels; + /** + * Define a callback to handle the submission of the form data instead of the internal one in + * `ArtifactListPage` being used. + * @param item + * @param mode + */ + onFormSubmit?: Required['submitHandler']; /** A list of fields that will be used by the search functionality when a user enters a value in the searchbar */ searchableFields?: MaybeImmutable; flyoutSize?: EuiFlyoutSize; @@ -68,11 +76,14 @@ export const ArtifactListPage = memo( ArtifactFormComponent, searchableFields = DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS, labels: _labels = {}, + onFormSubmit, + flyoutSize, 'data-test-subj': dataTestSubj, }) => { const { state: routeState } = useLocation(); const getTestId = useTestIdGenerator(dataTestSubj); const toasts = useToasts(); + const isMounted = useIsMounted(); const isFlyoutOpened = useIsFlyoutOpened(); const setUrlParams = useSetUrlParams(); const { @@ -171,31 +182,44 @@ export const ArtifactListPage = memo( [setUrlParams] ); - const handleOnSearch = useCallback( + const handleOnSearch = useCallback( (filterValue: string, selectedPolicies: string, doHardRefresh) => { + const didFilterChange = + filterValue !== (filter ?? '') || selectedPolicies !== (includedPolicies ?? ''); + setUrlParams({ // `undefined` will drop the param from the url filter: filterValue.trim() === '' ? undefined : filterValue, includedPolicies: selectedPolicies.trim() === '' ? undefined : selectedPolicies, }); - if (doHardRefresh) { + // We don't want to trigger a refresh of the list twice because the URL above was already + // updated, so if the user explicitly clicked the `Refresh` button and nothing has changed + // in the filter, then trigger a refresh (since the url update did not actually trigger one) + if (doHardRefresh && !didFilterChange) { refetchListData(); } }, - [refetchListData, setUrlParams] + [filter, includedPolicies, refetchListData, setUrlParams] ); const handleArtifactDeleteModalOnSuccess = useCallback(() => { - setSelectedItemForDelete(undefined); - refetchListData(); - }, [refetchListData]); + if (isMounted) { + setSelectedItemForDelete(undefined); + refetchListData(); + } + }, [isMounted, refetchListData]); const handleArtifactDeleteModalOnCancel = useCallback(() => { setSelectedItemForDelete(undefined); }, []); + const handleArtifactFlyoutOnClose = useCallback(() => { + setSelectedItemForEdit(undefined); + }, []); + const handleArtifactFlyoutOnSuccess = useCallback(() => { + setSelectedItemForEdit(undefined); refetchListData(); }, [refetchListData]); @@ -221,15 +245,19 @@ export const ArtifactListPage = memo(
    } > - {/* Flyout component is driven by URL params and may or may not be displayed based on those */} - + {isFlyoutOpened && ( + + )} {selectedItemForDelete && ( ( loading={isLoading} pagination={uiPagination} contentClassName="card-container" - data-test-subj={getTestId('cardContent')} + data-test-subj={getTestId('list')} /> )} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.tsx index 4228d923a9ab..6bc7abaafdd8 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.tsx @@ -27,8 +27,8 @@ import { } from '../../../../../common/endpoint/service/artifacts'; import { ARTIFACT_DELETE_ACTION_LABELS, - useArtifactDeleteItem, -} from '../hooks/use_artifact_delete_item'; + useWithArtifactDeleteItem, +} from '../hooks/use_with_artifact_delete_item'; import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; export const ARTIFACT_DELETE_LABELS = Object.freeze({ @@ -90,7 +90,11 @@ export const ArtifactDeleteModal = memo( ({ apiClient, item, onCancel, onSuccess, 'data-test-subj': dataTestSubj, labels }) => { const getTestId = useTestIdGenerator(dataTestSubj); - const { deleteArtifactItem, isLoading: isDeleting } = useArtifactDeleteItem(apiClient, labels); + const { deleteArtifactItem, isLoading: isDeleting } = useWithArtifactDeleteItem( + apiClient, + item, + labels + ); const onConfirm = useCallback(() => { deleteArtifactItem(item).then(() => onSuccess()); @@ -103,7 +107,7 @@ export const ArtifactDeleteModal = memo( }, [isDeleting, onCancel]); return ( - + {labels.deleteModalTitle(item.name)} @@ -139,6 +143,7 @@ export const ArtifactDeleteModal = memo( color="danger" onClick={onConfirm} isLoading={isDeleting} + isDisabled={isDeleting} data-test-subj={getTestId('submitButton')} > {labels.deleteModalSubmitButtonTitle} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx index 483695de7382..63759df8d42c 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx @@ -21,11 +21,11 @@ import { EuiTitle, } from '@elastic/eui'; import { EuiFlyoutSize } from '@elastic/eui/src/components/flyout/flyout'; -import { useUrlParams } from '../hooks/use_url_params'; +import { HttpFetchError } from 'kibana/public'; +import { useUrlParams } from '../../hooks/use_url_params'; import { useIsFlyoutOpened } from '../hooks/use_is_flyout_opened'; import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; import { useSetUrlParams } from '../hooks/use_set_url_params'; -import { useArtifactGetItem } from '../hooks/use_artifact_get_item'; import { ArtifactFormComponentOnChangeCallbackProps, ArtifactFormComponentProps, @@ -37,6 +37,8 @@ import { useToasts } from '../../../../common/lib/kibana'; import { createExceptionListItemForCreate } from '../../../../../common/endpoint/service/artifacts/utils'; import { useWithArtifactSubmitData } from '../hooks/use_with_artifact_submit_data'; import { useIsArtifactAllowedPerPolicyUsage } from '../hooks/use_is_artifact_allowed_per_policy_usage'; +import { useIsMounted } from '../../hooks/use_is_mounted'; +import { useGetArtifact } from '../../../hooks/artifacts'; export const ARTIFACT_FLYOUT_LABELS = Object.freeze({ flyoutEditTitle: i18n.translate('xpack.securitySolution.artifactListPage.flyoutEditTitle', { @@ -98,7 +100,7 @@ export const ARTIFACT_FLYOUT_LABELS = Object.freeze({ defaultMessage: 'For more information, see our documentation.', }), - flyoutEditItemLoadFailure: (errorMessage: string) => + flyoutEditItemLoadFailure: (errorMessage: string): string => i18n.translate('xpack.securitySolution.artifactListPage.flyoutEditItemLoadFailure', { defaultMessage: 'Failed to retrieve item for edit. Reason: {errorMessage}', values: { errorMessage }, @@ -113,9 +115,9 @@ export const ARTIFACT_FLYOUT_LABELS = Object.freeze({ * values: { name }, * }) */ - flyoutCreateSubmitSuccess: ({ name }: ExceptionListItemSchema) => + flyoutCreateSubmitSuccess: ({ name }: ExceptionListItemSchema): string => i18n.translate('xpack.securitySolution.some_page.flyoutCreateSubmitSuccess', { - defaultMessage: '"{name}" has been added to your event filters.', + defaultMessage: '"{name}" has been added.', values: { name }, }), @@ -129,7 +131,7 @@ export const ARTIFACT_FLYOUT_LABELS = Object.freeze({ * values: { name }, * }) */ - flyoutEditSubmitSuccess: ({ name }: ExceptionListItemSchema) => + flyoutEditSubmitSuccess: ({ name }: ExceptionListItemSchema): string => i18n.translate('xpack.securitySolution.artifactListPage.flyoutEditSubmitSuccess', { defaultMessage: '"{name}" has been updated.', values: { name }, @@ -150,6 +152,11 @@ export interface ArtifactFlyoutProps { apiClient: ExceptionsListApiClient; FormComponent: React.ComponentType; onSuccess(): void; + onClose(): void; + submitHandler?: ( + item: ArtifactFormComponentOnChangeCallbackProps['item'], + mode: ArtifactFormComponentProps['mode'] + ) => Promise; /** * If the artifact data is provided and it matches the id in the URL, then it will not be * retrieved again via the API @@ -164,12 +171,14 @@ export interface ArtifactFlyoutProps { /** * Show the flyout based on URL params */ -export const MaybeArtifactFlyout = memo( +export const ArtifactFlyout = memo( ({ apiClient, item, FormComponent, onSuccess, + onClose, + submitHandler, labels: _labels = {}, 'data-test-subj': dataTestSubj, size = 'm', @@ -179,27 +188,45 @@ export const MaybeArtifactFlyout = memo( const isFlyoutOpened = useIsFlyoutOpened(); const setUrlParams = useSetUrlParams(); const { urlParams } = useUrlParams(); + const isMounted = useIsMounted(); const labels = useMemo(() => { return { ...ARTIFACT_FLYOUT_LABELS, ..._labels, }; }, [_labels]); + // TODO:PT Refactor internal/external state into the `useEithArtifactSucmitData()` hook + const [externalIsSubmittingData, setExternalIsSubmittingData] = useState(false); + const [externalSubmitHandlerError, setExternalSubmitHandlerError] = useState< + HttpFetchError | undefined + >(undefined); const isEditFlow = urlParams.show === 'edit'; const formMode: ArtifactFormComponentProps['mode'] = isEditFlow ? 'edit' : 'create'; const { - isLoading: isSubmittingData, + isLoading: internalIsSubmittingData, mutateAsync: submitData, - error: submitError, + error: internalSubmitError, } = useWithArtifactSubmitData(apiClient, formMode); + const isSubmittingData = useMemo(() => { + return submitHandler ? externalIsSubmittingData : internalIsSubmittingData; + }, [externalIsSubmittingData, internalIsSubmittingData, submitHandler]); + + const submitError = useMemo(() => { + return submitHandler ? externalSubmitHandlerError : internalSubmitError; + }, [externalSubmitHandlerError, internalSubmitError, submitHandler]); + const { isLoading: isLoadingItemForEdit, error, refetch: fetchItemForEdit, - } = useArtifactGetItem(apiClient, urlParams.itemId ?? '', false); + } = useGetArtifact(apiClient, urlParams.itemId ?? '', undefined, { + // We don't want to run this at soon as the component is rendered. `refetch` is called + // a little later if determined we're in `edit` mode + enabled: false, + }); const [formState, setFormState] = useState( createFormInitialState.bind(null, apiClient.listId, item) @@ -225,39 +252,69 @@ export const MaybeArtifactFlyout = memo( } // `undefined` will cause params to be dropped from url - setUrlParams({ id: undefined, show: undefined }, true); - }, [isSubmittingData, setUrlParams]); + setUrlParams({ itemId: undefined, show: undefined }, true); + + onClose(); + }, [isSubmittingData, onClose, setUrlParams]); const handleFormComponentOnChange: ArtifactFormComponentProps['onChange'] = useCallback( ({ item: updatedItem, isValid }) => { - setFormState({ - item: updatedItem, - isValid, - }); + if (isMounted) { + setFormState({ + item: updatedItem, + isValid, + }); + } }, - [] + [isMounted] ); - const handleSubmitClick = useCallback(() => { - submitData(formState.item).then((result) => { + const handleSuccess = useCallback( + (result: ExceptionListItemSchema) => { toasts.addSuccess( isEditFlow ? labels.flyoutEditSubmitSuccess(result) : labels.flyoutCreateSubmitSuccess(result) ); - // Close the flyout - // `undefined` will cause params to be dropped from url - setUrlParams({ id: undefined, show: undefined }, true); - }); - }, [formState.item, isEditFlow, labels, setUrlParams, submitData, toasts]); + if (isMounted) { + // Close the flyout + // `undefined` will cause params to be dropped from url + setUrlParams({ itemId: undefined, show: undefined }, true); + + onSuccess(); + } + }, + [isEditFlow, isMounted, labels, onSuccess, setUrlParams, toasts] + ); + + const handleSubmitClick = useCallback(() => { + if (submitHandler) { + setExternalIsSubmittingData(true); + + submitHandler(formState.item, formMode) + .then(handleSuccess) + .catch((submitHandlerError) => { + if (isMounted) { + setExternalSubmitHandlerError(submitHandlerError); + } + }) + .finally(() => { + if (isMounted) { + setExternalIsSubmittingData(false); + } + }); + } else { + submitData(formState.item).then(handleSuccess); + } + }, [formMode, formState.item, handleSuccess, isMounted, submitData, submitHandler]); // If we don't have the actual Artifact data yet for edit (in initialization phase - ex. came in with an // ID in the url that was not in the list), then retrieve it now useEffect(() => { if (isEditFlow && !hasItemDataForEdit && !error && isInitializing && !isLoadingItemForEdit) { fetchItemForEdit().then(({ data: editItemData }) => { - if (editItemData) { + if (editItemData && isMounted) { setFormState(createFormInitialState(apiClient.listId, editItemData)); } }); @@ -270,6 +327,7 @@ export const MaybeArtifactFlyout = memo( isInitializing, isLoadingItemForEdit, hasItemDataForEdit, + isMounted, ]); // If we got an error while trying ot retrieve the item for edit, then show a toast message @@ -278,7 +336,7 @@ export const MaybeArtifactFlyout = memo( toasts.addWarning(labels.flyoutEditItemLoadFailure(error?.body?.message || error.message)); // Blank out the url params for id and show (will close out the flyout) - setUrlParams({ id: undefined, show: undefined }); + setUrlParams({ itemId: undefined, show: undefined }); } }, [error, isEditFlow, labels, setUrlParams, toasts, urlParams.itemId]); @@ -306,7 +364,7 @@ export const MaybeArtifactFlyout = memo( )} - {isInitializing && } + {isInitializing && } {!isInitializing && ( ( ); } ); -MaybeArtifactFlyout.displayName = 'MaybeArtifactFlyout'; +ArtifactFlyout.displayName = 'ArtifactFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_create_item.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_create_item.ts deleted file mode 100644 index 4252d66f2a51..000000000000 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_create_item.ts +++ /dev/null @@ -1,46 +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 { - CreateExceptionListItemSchema, - ExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { useMutation } from 'react-query'; -import { HttpFetchError } from 'kibana/public'; -import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; - -// FIXME: delete entire file once PR# 125198 is merged. This entire file was copied from that pr - -export interface CallbackTypes { - onSuccess?: (updatedException: ExceptionListItemSchema) => void; - onError?: (error?: HttpFetchError) => void; - onSettled?: () => void; -} - -export function useCreateArtifact( - exceptionListApiClient: ExceptionsListApiClient, - callbacks: CallbackTypes = {} -) { - const { onSuccess = () => {}, onError = () => {}, onSettled = () => {} } = callbacks; - - return useMutation< - ExceptionListItemSchema, - HttpFetchError, - CreateExceptionListItemSchema, - () => void - >( - async (exception: CreateExceptionListItemSchema) => { - return exceptionListApiClient.create(exception); - }, - { - onSuccess, - onError, - onSettled: () => { - onSettled(); - }, - } - ); -} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_get_item.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_get_item.ts deleted file mode 100644 index 21b13aa28537..000000000000 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_get_item.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useQuery } from 'react-query'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { HttpFetchError } from 'kibana/public'; -import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; - -export const useArtifactGetItem = ( - apiClient: ExceptionsListApiClient, - itemId: string, - enabled: boolean = true -) => { - return useQuery( - ['item', apiClient, itemId], - () => apiClient.get(itemId), - { - enabled, - refetchOnWindowFocus: false, - keepPreviousData: true, - retry: false, - } - ); -}; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_update_item.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_update_item.ts deleted file mode 100644 index a217da0159ed..000000000000 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_update_item.ts +++ /dev/null @@ -1,49 +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 { - UpdateExceptionListItemSchema, - ExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { useQueryClient, useMutation } from 'react-query'; -import { HttpFetchError } from 'kibana/public'; -import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; - -// FIXME: delete entire file once PR# 125198 is merged. This entire file was copied from that pr - -export interface CallbackTypes { - onSuccess?: (updatedException: ExceptionListItemSchema) => void; - onError?: (error?: HttpFetchError) => void; - onSettled?: () => void; -} - -export function useUpdateArtifact( - exceptionListApiClient: ExceptionsListApiClient, - callbacks: CallbackTypes = {} -) { - const queryClient = useQueryClient(); - const { onSuccess = () => {}, onError = () => {}, onSettled = () => {} } = callbacks; - - return useMutation< - ExceptionListItemSchema, - HttpFetchError, - UpdateExceptionListItemSchema, - () => void - >( - async (exception: UpdateExceptionListItemSchema) => { - return exceptionListApiClient.update(exception); - }, - { - onSuccess, - onError, - onSettled: () => { - queryClient.invalidateQueries(['list', exceptionListApiClient]); - queryClient.invalidateQueries(['get', exceptionListApiClient]); - onSettled(); - }, - } - ); -} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_is_flyout_opened.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_is_flyout_opened.ts index dc53a58924e8..d6e66dd97252 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_is_flyout_opened.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_is_flyout_opened.ts @@ -6,7 +6,7 @@ */ import { useMemo } from 'react'; -import { useUrlParams } from './use_url_params'; +import { useUrlParams } from '../../hooks/use_url_params'; import { ArtifactListPageUrlParams } from '../types'; const SHOW_VALUES: readonly string[] = ['create', 'edit']; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_set_url_params.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_set_url_params.ts index 80ffdeb25394..aa157f2db053 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_set_url_params.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_set_url_params.ts @@ -8,9 +8,9 @@ import { useHistory, useLocation } from 'react-router-dom'; import { useCallback } from 'react'; import { pickBy } from 'lodash'; -import { useUrlParams } from './use_url_params'; +import { useUrlParams } from '../../hooks/use_url_params'; -// FIXME:PT delete/change once we get the common hook from @parkiino PR +// FIXME:PT Refactor into a more generic hooks for managing url params export const useSetUrlParams = (): (( /** Any param whose value is `undefined` will be removed from the URl when in append mode */ params: Record, diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_url_params.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_url_params.ts deleted file mode 100644 index 7e1b8d16b377..000000000000 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_url_params.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useMemo } from 'react'; -import { useLocation } from 'react-router-dom'; -import { parse, stringify } from 'query-string'; - -// FIXME:PT delete and use common hook once @parkiino merges -export function useUrlParams>(): { - urlParams: T; - toUrlParams: (params?: T) => string; -} { - const { search } = useLocation(); - return useMemo(() => { - const urlParams = parse(search) as unknown as T; - return { - urlParams, - toUrlParams: (params: T = urlParams) => stringify(params as unknown as object), - }; - }, [search]); -} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_delete_item.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_delete_item.ts similarity index 68% rename from x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_delete_item.ts rename to x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_delete_item.ts index feac0c2b0c59..df47472861a5 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_delete_item.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_delete_item.ts @@ -6,12 +6,12 @@ */ import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { useMutation, UseMutationResult } from 'react-query'; import { i18n } from '@kbn/i18n'; import { useMemo } from 'react'; import type { HttpFetchError } from 'kibana/public'; import { useToasts } from '../../../../common/lib/kibana'; import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; +import { useDeleteArtifact } from '../../../hooks/artifacts'; export const ARTIFACT_DELETE_ACTION_LABELS = Object.freeze({ /** @@ -26,9 +26,9 @@ export const ARTIFACT_DELETE_ACTION_LABELS = Object.freeze({ * values: { itemName, errorMessage }, * }) */ - deleteActionFailure: (itemName: string, errorMessage: string) => + deleteActionFailure: (itemName: string, errorMessage: string): string => i18n.translate('xpack.securitySolution.artifactListPage.deleteActionFailure', { - defaultMessage: 'Unable to remove "{itemName}" . Reason: {errorMessage}', + defaultMessage: 'Unable to remove "{itemName}". Reason: {errorMessage}', values: { itemName, errorMessage }, }), @@ -41,49 +41,38 @@ export const ARTIFACT_DELETE_ACTION_LABELS = Object.freeze({ * values: { itemName }, * }) */ - deleteActionSuccess: (itemName: string) => + deleteActionSuccess: (itemName: string): string => i18n.translate('xpack.securitySolution.artifactListPage.deleteActionSuccess', { defaultMessage: '"{itemName}" has been removed', values: { itemName }, }), }); -type UseArtifactDeleteItemMutationResult = UseMutationResult< - ExceptionListItemSchema, - HttpFetchError, - ExceptionListItemSchema ->; +type UseArtifactDeleteItemMutationResult = ReturnType; export type UseArtifactDeleteItemInterface = UseArtifactDeleteItemMutationResult & { deleteArtifactItem: UseArtifactDeleteItemMutationResult['mutateAsync']; }; -export const useArtifactDeleteItem = ( +export const useWithArtifactDeleteItem = ( apiClient: ExceptionsListApiClient, + item: ExceptionListItemSchema, labels: typeof ARTIFACT_DELETE_ACTION_LABELS ): UseArtifactDeleteItemInterface => { const toasts = useToasts(); - - const mutation = useMutation( - async (item: ExceptionListItemSchema) => { - return apiClient.delete(item.item_id); + const deleteArtifact = useDeleteArtifact(apiClient, { + onError: (error: HttpFetchError) => { + toasts.addDanger(labels.deleteActionFailure(item.name, error.body?.message || error.message)); + }, + onSuccess: (response) => { + toasts.addSuccess(labels.deleteActionSuccess(response.name)); }, - { - onError: (error: HttpFetchError, item) => { - toasts.addDanger( - labels.deleteActionFailure(item.name, error.body?.message || error.message) - ); - }, - onSuccess: (response) => { - toasts.addSuccess(labels.deleteActionSuccess(response.name)); - }, - } - ); + }); return useMemo(() => { return { - ...mutation, - deleteArtifactItem: mutation.mutateAsync, + ...deleteArtifact, + deleteArtifactItem: deleteArtifact.mutateAsync, }; - }, [mutation]); + }, [deleteArtifact]); }; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_list_data.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_list_data.ts index 3eca6c60bc71..b1742c761ba4 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_list_data.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_list_data.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type { QueryObserverResult } from 'react-query'; -import type { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { useEffect, useMemo, useState } from 'react'; import { Pagination } from '@elastic/eui'; import { useQuery } from 'react-query'; @@ -16,16 +14,14 @@ import { MANAGEMENT_DEFAULT_PAGE_SIZE, MANAGEMENT_PAGE_SIZE_OPTIONS, } from '../../../common/constants'; -import { useUrlParams } from './use_url_params'; +import { useUrlParams } from '../../hooks/use_url_params'; import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; import { ArtifactListPageUrlParams } from '../types'; import { MaybeImmutable } from '../../../../../common/endpoint/types'; import { useKueryFromExceptionsSearchFilter } from './use_kuery_from_exceptions_search_filter'; +import { useListArtifact } from '../../../hooks/artifacts'; -type WithArtifactListDataInterface = QueryObserverResult< - FoundExceptionListItemSchema, - ServerApiError -> & { +type WithArtifactListDataInterface = ReturnType & { /** * Set to true during initialization of the page until it can be determined if either data exists. * This should drive the showing of the overall page loading state if set to `true` @@ -50,16 +46,10 @@ export const useWithArtifactListData = ( const isMounted = useIsMounted(); const { - urlParams: { - page = 1, - pageSize = MANAGEMENT_DEFAULT_PAGE_SIZE, - sortOrder, - sortField, - filter, - includedPolicies, - }, + urlParams: { page = 1, pageSize = MANAGEMENT_DEFAULT_PAGE_SIZE, filter, includedPolicies }, } = useUrlParams(); + // Used to determine if the `does data exist` check should be done. const kuery = useKueryFromExceptionsSearchFilter(filter, searchableFields, includedPolicies); const { @@ -85,14 +75,15 @@ export const useWithArtifactListData = ( const [isPageInitializing, setIsPageInitializing] = useState(true); - const listDataRequest = useQuery( - ['list', apiClient, page, pageSize, sortField, sortField, kuery], - async () => apiClient.find({ page, perPage: pageSize, filter: kuery, sortField, sortOrder }), + const listDataRequest = useListArtifact( + apiClient, { - enabled: true, - keepPreviousData: true, - refetchOnWindowFocus: false, - } + page, + perPage: pageSize, + filter, + policies: includedPolicies ? includedPolicies.split(',') : [], + }, + searchableFields ); const { @@ -106,7 +97,7 @@ export const useWithArtifactListData = ( // This should only ever happen at most once; useEffect(() => { if (isMounted) { - if (isPageInitializing === true && !isLoadingDataExists) { + if (isPageInitializing && !isLoadingDataExists) { setIsPageInitializing(false); } } @@ -128,21 +119,30 @@ export const useWithArtifactListData = ( // Keep the `doesDataExist` updated if we detect that list data result total is zero. // Anytime: - // 1. the list data total is 0 - // 2. and page is 1 - // 3. and filter is empty - // 4. and doesDataExists is currently set to true - // check if data exists again + // 1. the list data total is 0 + // 2. and page is 1 + // 3. and filter is empty + // 4. and doesDataExists is `true` + // >> check if data exists again + // OR, Anytime: + // 1. `doesDataExists` is `false`, + // 2. and page is 1 + // 3. and filter is empty + // 4. the list data total is > 0 + // >> Check if data exists again (which should return true useEffect(() => { if ( isMounted && !isLoadingListData && + !isLoadingDataExists && !listDataError && - listData && - listData.total === 0 && String(page) === '1' && !kuery && - doesDataExist + // flow when there the last item on the list gets deleted, + // and list goes back to being empty + ((listData && listData.total === 0 && doesDataExist) || + // Flow when the list starts off empty and the first item is added + (listData && listData.total > 0 && !doesDataExist)) ) { checkIfDataExists(); } @@ -151,6 +151,7 @@ export const useWithArtifactListData = ( doesDataExist, filter, includedPolicies, + isLoadingDataExists, isLoadingListData, isMounted, kuery, diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_submit_data.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_submit_data.ts index 59a2739c9d3a..89812e9b53ba 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_submit_data.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_submit_data.ts @@ -7,8 +7,7 @@ import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; import { ArtifactFormComponentProps } from '../types'; -import { useUpdateArtifact } from './use_artifact_update_item'; -import { useCreateArtifact } from './use_artifact_create_item'; +import { useCreateArtifact, useUpdateArtifact } from '../../../hooks/artifacts'; export const useWithArtifactSubmitData = ( apiClient: ExceptionsListApiClient, diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/index.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/index.ts index ba26a4425902..db5c03a48ff2 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/index.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/index.ts @@ -6,5 +6,8 @@ */ export { ArtifactListPage } from './artifact_list_page'; -export type { ArtifactListPageProps } from './artifact_list_page'; export * from './types'; +export { artifactListPageLabels } from './translations'; + +export type { ArtifactListPageProps } from './artifact_list_page'; +export type { ArtifactListPageLabels } from './translations'; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/translations.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/translations.ts index ba6acf8a359a..c72b1a7af410 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/translations.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/translations.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { ARTIFACT_FLYOUT_LABELS } from './components/artifact_flyout'; import { ARTIFACT_DELETE_LABELS } from './components/artifact_delete_modal'; -import { ARTIFACT_DELETE_ACTION_LABELS } from './hooks/use_artifact_delete_item'; +import { ARTIFACT_DELETE_ACTION_LABELS } from './hooks/use_with_artifact_delete_item'; export const artifactListPageLabels = Object.freeze({ // ------------------------------ @@ -57,7 +57,7 @@ export const artifactListPageLabels = Object.freeze({ * values: { total }, * }) */ - getShowingCountLabel: (total: number) => { + getShowingCountLabel: (total: number): string => { return i18n.translate('xpack.securitySolution.artifactListPage.showingTotal', { defaultMessage: 'Showing {total, plural, one {# artifact} other {# artifacts}}', values: { total }, diff --git a/x-pack/plugins/security_solution/public/management/components/hooks/use_is_mounted.ts b/x-pack/plugins/security_solution/public/management/components/hooks/use_is_mounted.ts index c3ab4472cf42..0c5a79b2ca2f 100644 --- a/x-pack/plugins/security_solution/public/management/components/hooks/use_is_mounted.ts +++ b/x-pack/plugins/security_solution/public/management/components/hooks/use_is_mounted.ts @@ -5,22 +5,22 @@ * 2.0. */ -import { useEffect, useRef } from 'react'; +import { useEffect, useState } from 'react'; /** - * Track when a comonent is mounted/unmounted. Good for use in async processing that may update + * Track when a component is mounted/unmounted. Good for use in async processing that may update * a component's internal state. */ export const useIsMounted = (): boolean => { - const isMounted = useRef(false); + const [isMounted, setIsMounted] = useState(false); useEffect(() => { - isMounted.current = true; + setIsMounted(true); return () => { - isMounted.current = false; + setIsMounted(false); }; }, []); - return isMounted.current; + return isMounted; }; diff --git a/x-pack/plugins/security_solution/public/management/components/hooks/use_url_params.ts b/x-pack/plugins/security_solution/public/management/components/hooks/use_url_params.ts index d5dc2a000588..865b71781df6 100644 --- a/x-pack/plugins/security_solution/public/management/components/hooks/use_url_params.ts +++ b/x-pack/plugins/security_solution/public/management/components/hooks/use_url_params.ts @@ -6,7 +6,7 @@ */ import { useMemo } from 'react'; -import { parse, stringify, ParsedQuery } from 'query-string'; +import { parse, stringify } from 'query-string'; import { useLocation } from 'react-router-dom'; /** @@ -15,16 +15,16 @@ import { useLocation } from 'react-router-dom'; * `urlParams` that was parsed) for use in the url. * Object will be recreated every time `search` changes. */ -export function useUrlParams(): { - urlParams: ParsedQuery; - toUrlParams: (params: ParsedQuery) => string; +export function useUrlParams>(): { + urlParams: T; + toUrlParams: (params?: T) => string; } { const { search } = useLocation(); return useMemo(() => { - const urlParams = parse(search); + const urlParams = parse(search) as unknown as T; return { urlParams, - toUrlParams: (params = urlParams) => stringify(params), + toUrlParams: (params: T = urlParams) => stringify(params as unknown as object), }; }, [search]); } diff --git a/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx b/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx index c29eee522104..33905265ef4d 100644 --- a/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx +++ b/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx @@ -93,18 +93,21 @@ const RootContainer = styled.div` } `; -const DefaultNoItemsFound = memo(() => { - return ( - - } - /> - ); -}); +const DefaultNoItemsFound = memo<{ 'data-test-subj'?: string }>( + ({ 'data-test-subj': dataTestSubj }) => { + return ( + + } + /> + ); + } +); DefaultNoItemsFound.displayName = 'DefaultNoItemsFound'; @@ -227,7 +230,8 @@ export const PaginatedContent = memo( return ; }); } - if (!loading) return noItemsMessage || ; + if (!loading) + return noItemsMessage || ; }, [ ItemComponent, error, diff --git a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx index 7a7a28b4b064..695b1f18ef31 100644 --- a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx +++ b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx @@ -102,8 +102,8 @@ export const SearchExceptions = memo( ) : null} {!hideRefreshButton ? ( - - + + {i18n.translate('xpack.securitySolution.management.search.button', { defaultMessage: 'Refresh', })} diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.test.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.test.tsx index e7f389735056..e41660daa5c5 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.test.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.test.tsx @@ -56,7 +56,7 @@ describe('List artifact hook', () => { result = await renderQuery( () => - useListArtifact(instance, searchableFields, options, { + useListArtifact(instance, options, searchableFields, { onSuccess: onSuccessMock, retry: false, }), @@ -92,7 +92,7 @@ describe('List artifact hook', () => { result = await renderQuery( () => - useListArtifact(instance, searchableFields, options, { + useListArtifact(instance, options, searchableFields, { onError: onErrorMock, retry: false, }), diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.tsx index 9ac894649d60..1e0c4b3c55b8 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.tsx @@ -7,39 +7,55 @@ import { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { HttpFetchError } from 'kibana/public'; import { QueryObserverResult, useQuery, UseQueryOptions } from 'react-query'; +import { useMemo } from 'react'; import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../common/constants'; import { parsePoliciesAndFilterToKql, parseQueryFilterToKQL } from '../../common/utils'; import { ExceptionsListApiClient } from '../../services/exceptions_list/exceptions_list_api_client'; +import { DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS } from '../../../../common/endpoint/service/artifacts/constants'; +import { MaybeImmutable } from '../../../../common/endpoint/types'; + +const DEFAULT_OPTIONS = Object.freeze({}); export function useListArtifact( exceptionListApiClient: ExceptionsListApiClient, - searcheableFields: string[], - options: { + options: Partial<{ filter: string; page: number; perPage: number; policies: string[]; - } = { - filter: '', - page: MANAGEMENT_DEFAULT_PAGE, - perPage: MANAGEMENT_DEFAULT_PAGE_SIZE, - policies: [], - }, - customQueryOptions: UseQueryOptions + excludedPolicies: string[]; + }> = DEFAULT_OPTIONS, + searchableFields: MaybeImmutable = DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS, + customQueryOptions: Partial< + UseQueryOptions + > = DEFAULT_OPTIONS, + customQueryIds: string[] = [] ): QueryObserverResult { - const { filter, page, perPage, policies } = options; + const { + filter = '', + page = MANAGEMENT_DEFAULT_PAGE + 1, + perPage = MANAGEMENT_DEFAULT_PAGE_SIZE, + policies = [], + excludedPolicies = [], + } = options; + const filterKuery = useMemo(() => { + return parsePoliciesAndFilterToKql({ + kuery: parseQueryFilterToKQL(filter, searchableFields), + policies, + excludedPolicies, + }); + }, [filter, searchableFields, policies, excludedPolicies]); return useQuery( - ['list', exceptionListApiClient, options], - () => { - return exceptionListApiClient.find({ - filter: parsePoliciesAndFilterToKql({ - policies, - kuery: parseQueryFilterToKQL(filter, searcheableFields), - }), + [...customQueryIds, 'list', exceptionListApiClient, filterKuery, page, perPage], + async () => { + const result = await exceptionListApiClient.find({ + filter: filterKuery, perPage, page, }); + + return result; }, { refetchIntervalInBackground: false, diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.tsx index e068ab650d39..9e4ca1682f02 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.tsx @@ -10,9 +10,11 @@ import { QueryObserverResult, useQuery, UseQueryOptions } from 'react-query'; import { parsePoliciesAndFilterToKql, parseQueryFilterToKQL } from '../../common/utils'; import { ExceptionsListApiClient } from '../../services/exceptions_list/exceptions_list_api_client'; +const DEFAULT_OPTIONS = Object.freeze({}); + export function useSummaryArtifact( exceptionListApiClient: ExceptionsListApiClient, - searcheableFields: string[], + searchableFields: string[], options: { filter: string; policies: string[]; @@ -20,7 +22,7 @@ export function useSummaryArtifact( filter: '', policies: [], }, - customQueryOptions: UseQueryOptions + customQueryOptions: UseQueryOptions = DEFAULT_OPTIONS ): QueryObserverResult { const { filter, policies } = options; @@ -30,7 +32,7 @@ export function useSummaryArtifact( return exceptionListApiClient.summary( parsePoliciesAndFilterToKql({ policies, - kuery: parseQueryFilterToKQL(filter, searcheableFields), + kuery: parseQueryFilterToKQL(filter, searchableFields), }) ); }, diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts b/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts index 3fb68e417159..0ecdeae1fe6e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts @@ -24,3 +24,12 @@ export const BLOCKLISTS_LIST_DEFINITION: CreateExceptionListSchema = { list_id: ENDPOINT_BLOCKLISTS_LIST_ID, type: BLOCKLISTS_LIST_TYPE, }; + +export const SEARCHABLE_FIELDS: Readonly = [ + `name`, + `description`, + 'item_id', + `entries.value`, + `entries.entries.value`, + `comments.comment`, +]; diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/services/index.ts b/x-pack/plugins/security_solution/public/management/pages/blocklist/services/index.ts index 002093007329..fab5be9f7ab3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/services/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/services/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './blocklists_api_client'; +export { BlocklistsApiClient } from './blocklists_api_client'; diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx index 8d98b401102f..c016c10ad319 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx @@ -39,7 +39,7 @@ const BLOCKLIST_PAGE_LABELS: ArtifactListPageProps['labels'] = { defaultMessage: 'Blocklist', }), pageAboutInfo: i18n.translate('xpack.securitySolution.blocklist.pageAboutInfo', { - defaultMessage: 'Add a blocklist to block applications or files from running.', + defaultMessage: 'Add a blocklist to block applications or files from running on the endpoint.', }), pageAddButtonTitle: i18n.translate('xpack.securitySolution.blocklist.pageAddButtonTitle', { defaultMessage: 'Add blocklist entry', diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx index 6b3cc7478079..4107c971cc3b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx @@ -47,10 +47,13 @@ export interface EventFiltersFlyoutProps { id?: string; data?: Ecs; onCancel(): void; + maskProps?: { + style?: string; + }; } export const EventFiltersFlyout: React.FC = memo( - ({ onCancel, id, type = 'create', data }) => { + ({ onCancel, id, type = 'create', data, ...flyoutProps }) => { useEventFiltersNotification(); const [enrichedData, setEnrichedData] = useState(); const toasts = useToasts(); @@ -210,7 +213,12 @@ export const EventFiltersFlyout: React.FC = memo( ); return ( - +

    diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx index fa68215cc768..6d24b9558ea5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx @@ -25,8 +25,9 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { EVENT_FILTERS_OPERATORS } from '@kbn/securitysolution-list-utils'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; -import { OperatingSystem, PolicyData } from '../../../../../../../common/endpoint/types'; +import { PolicyData } from '../../../../../../../common/endpoint/types'; import { AddExceptionComments } from '../../../../../../common/components/exceptions/add_exception_comments'; import { filterIndexPatterns } from '../../../../../../common/components/exceptions/helpers'; import { Loader } from '../../../../../../common/components/loader'; @@ -225,6 +226,7 @@ export const EventFiltersForm: React.FC = memo( onChange: handleOnBuilderChange, listTypeSpecificIndexPatternFilter: filterIndexPatterns, operatorsList: EVENT_FILTERS_OPERATORS, + osTypes: exception?.os_types, }), [data, handleOnBuilderChange, http, indexPatterns, exception] ); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts index 8ba18f3df976..9fe60fe6508b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts @@ -15,6 +15,13 @@ import { ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_NAME, } from '@kbn/securitysolution-list-constants'; +export const SEARCHABLE_FIELDS: Readonly = [ + `item_id`, + `name`, + `description`, + `entries.value`, +]; + export const HOST_ISOLATION_EXCEPTIONS_LIST_TYPE: ExceptionListType = ExceptionListTypeEnum.ENDPOINT_HOST_ISOLATION_EXCEPTIONS; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.ts index 7ab03f9eaa68..6b043822968f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.ts @@ -23,6 +23,7 @@ import { } from '../../../common/constants'; import { getHostIsolationExceptionsListPath } from '../../../common/routing'; import { parsePoliciesAndFilterToKql, parseQueryFilterToKQL } from '../../../common/utils'; +import { SEARCHABLE_FIELDS } from '../constants'; import { getHostIsolationExceptionItems, getHostIsolationExceptionSummary, @@ -85,8 +86,6 @@ export function useCanSeeHostIsolationExceptionsMenu(): boolean { return canSeeMenu; } -const SEARCHABLE_FIELDS: Readonly = [`item_id`, `name`, `description`, `entries.value`]; - export function useFetchHostIsolationExceptionsList({ filter, page, diff --git a/x-pack/plugins/security_solution/public/management/pages/mocks/trusted_apps_http_mocks.ts b/x-pack/plugins/security_solution/public/management/pages/mocks/trusted_apps_http_mocks.ts index c92dcc0bd7cc..8e5f780bbab3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/mocks/trusted_apps_http_mocks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/mocks/trusted_apps_http_mocks.ts @@ -18,6 +18,7 @@ import { UpdateExceptionListItemSchema, ReadExceptionListItemSchema, CreateExceptionListItemSchema, + DeleteExceptionListItemSchema, ExceptionListSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { @@ -68,15 +69,18 @@ export const trustedAppsGetListHttpMocks = }) ); - // FIXME: remove hard-coded IDs below adn get them from the new FleetPackagePolicyGenerator (#2262) + // If we have more than 2 items, then set policy ids on the per-policy trusted app + if (data.length > 2) { + // FIXME: remove hard-coded IDs below adn get them from the new FleetPackagePolicyGenerator (#2262) - // Change the 3rd entry (index 2) to be policy specific - data[2].tags = [ - // IDs below are those generated by the `fleetGetEndpointPackagePolicyListHttpMock()` mock, - // so if using in combination with that API mock, these should just "work" - `${BY_POLICY_ARTIFACT_TAG_PREFIX}ddf6570b-9175-4a6d-b288-61a09771c647`, - `${BY_POLICY_ARTIFACT_TAG_PREFIX}b8e616ae-44fc-4be7-846c-ce8fa5c082dd`, - ]; + // Change the 3rd entry (index 2) to be policy specific + data[2].tags = [ + // IDs below are those generated by the `fleetGetEndpointPackagePolicyListHttpMock()` mock, + // so if using in combination with that API mock, these should just "work" + `${BY_POLICY_ARTIFACT_TAG_PREFIX}ddf6570b-9175-4a6d-b288-61a09771c647`, + `${BY_POLICY_ARTIFACT_TAG_PREFIX}b8e616ae-44fc-4be7-846c-ce8fa5c082dd`, + ]; + } return { page: apiQueryParams.page ?? 1, @@ -125,7 +129,7 @@ export type TrustedAppsGetOneHttpMocksInterface = ResponseProvidersInterface<{ trustedApp: (options: HttpFetchOptionsWithPath) => ExceptionListItemSchema; }>; /** - * HTTP mock for retrieving list of Trusted Apps + * HTTP mock for retrieving one Trusted Apps */ export const trustedAppsGetOneHttpMocks = httpHandlerMockFactory([ @@ -149,11 +153,40 @@ export const trustedAppsGetOneHttpMocks = }, ]); +export type TrustedAppsDeleteOneHttpMocksInterface = ResponseProvidersInterface<{ + trustedAppDelete: (options: HttpFetchOptionsWithPath) => ExceptionListItemSchema; +}>; +/** + * HTTP mock for deleting one Trusted Apps + */ +export const trustedAppsDeleteOneHttpMocks = + httpHandlerMockFactory([ + { + id: 'trustedAppDelete', + path: EXCEPTION_LIST_ITEM_URL, + method: 'delete', + handler: ({ query }): ExceptionListItemSchema => { + const apiQueryParams = query as DeleteExceptionListItemSchema; + const exceptionItem = new ExceptionsListItemGenerator('seed').generate({ + os_types: ['windows'], + tags: [GLOBAL_ARTIFACT_TAG], + }); + + exceptionItem.item_id = apiQueryParams.item_id ?? exceptionItem.item_id; + exceptionItem.id = apiQueryParams.id ?? exceptionItem.id; + exceptionItem.namespace_type = + apiQueryParams.namespace_type ?? exceptionItem.namespace_type; + + return exceptionItem; + }, + }, + ]); + export type TrustedAppPostHttpMocksInterface = ResponseProvidersInterface<{ trustedAppCreate: (options: HttpFetchOptionsWithPath) => ExceptionListItemSchema; }>; /** - * HTTP mocks that support updating a single Trusted Apps + * HTTP mocks that support creating a single Trusted Apps */ export const trustedAppPostHttpMocks = httpHandlerMockFactory([ { @@ -201,6 +234,7 @@ export type TrustedAppsAllHttpMocksInterface = FleetGetEndpointPackagePolicyList TrustedAppsGetListHttpMocksInterface & TrustedAppsGetOneHttpMocksInterface & TrustedAppPutHttpMocksInterface & + TrustedAppsDeleteOneHttpMocksInterface & TrustedAppPostHttpMocksInterface & TrustedAppsPostCreateListHttpMockInterface; /** Use this HTTP mock when wanting to mock the API calls done by the Trusted Apps Http service */ @@ -210,6 +244,7 @@ export const trustedAppsAllHttpMocks = composeHttpHandlerMocks { MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH, + MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, ]} exact component={PolicyDetails} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/index.ts index ab84bb4f253e..2be473568545 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/index.ts @@ -6,6 +6,5 @@ */ import { PolicySettingsAction } from './policy_settings_action'; -import { PolicyTrustedAppsAction } from './policy_trusted_apps_action'; -export type PolicyDetailsAction = PolicySettingsAction | PolicyTrustedAppsAction; +export type PolicyDetailsAction = PolicySettingsAction; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/policy_trusted_apps_action.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/policy_trusted_apps_action.ts deleted file mode 100644 index 3b27c7cd1b27..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/policy_trusted_apps_action.ts +++ /dev/null @@ -1,91 +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 { Action } from 'redux'; -import { AsyncResourceState } from '../../../../../state'; -import { - PutTrustedAppUpdateResponse, - GetTrustedAppsListResponse, - TrustedApp, - MaybeImmutable, -} from '../../../../../../../common/endpoint/types'; -import { PolicyArtifactsState } from '../../../types'; - -export interface PolicyArtifactsAssignableListPageDataChanged { - type: 'policyArtifactsAssignableListPageDataChanged'; - payload: AsyncResourceState; -} - -export interface PolicyArtifactsUpdateTrustedApps { - type: 'policyArtifactsUpdateTrustedApps'; - payload: { - action: 'assign' | 'remove'; - artifacts: MaybeImmutable; - }; -} - -export interface PolicyArtifactsUpdateTrustedAppsChanged { - type: 'policyArtifactsUpdateTrustedAppsChanged'; - payload: AsyncResourceState; -} - -export interface PolicyArtifactsAssignableListExistDataChanged { - type: 'policyArtifactsAssignableListExistDataChanged'; - payload: AsyncResourceState; -} - -export interface PolicyArtifactsAssignableListPageDataFilter { - type: 'policyArtifactsAssignableListPageDataFilter'; - payload: { filter: string }; -} - -export interface PolicyArtifactsDeosAnyTrustedAppExists { - type: 'policyArtifactsDeosAnyTrustedAppExists'; - payload: AsyncResourceState; -} - -export interface PolicyArtifactsHasTrustedApps { - type: 'policyArtifactsHasTrustedApps'; - payload: AsyncResourceState; -} - -export interface AssignedTrustedAppsListStateChanged - extends Action<'assignedTrustedAppsListStateChanged'> { - payload: PolicyArtifactsState['assignedList']; -} - -export interface PolicyDetailsListOfAllPoliciesStateChanged - extends Action<'policyDetailsListOfAllPoliciesStateChanged'> { - payload: PolicyArtifactsState['policies']; -} - -export type PolicyDetailsTrustedAppsForceListDataRefresh = - Action<'policyDetailsTrustedAppsForceListDataRefresh'>; - -export type PolicyDetailsArtifactsResetRemove = Action<'policyDetailsArtifactsResetRemove'>; - -export interface PolicyDetailsTrustedAppsRemoveListStateChanged - extends Action<'policyDetailsTrustedAppsRemoveListStateChanged'> { - payload: PolicyArtifactsState['removeList']; -} - -/** - * All of the possible actions for Trusted Apps under the Policy Details store - */ -export type PolicyTrustedAppsAction = - | PolicyArtifactsAssignableListPageDataChanged - | PolicyArtifactsUpdateTrustedApps - | PolicyArtifactsUpdateTrustedAppsChanged - | PolicyArtifactsAssignableListExistDataChanged - | PolicyArtifactsAssignableListPageDataFilter - | PolicyArtifactsDeosAnyTrustedAppExists - | PolicyArtifactsHasTrustedApps - | AssignedTrustedAppsListStateChanged - | PolicyDetailsListOfAllPoliciesStateChanged - | PolicyDetailsTrustedAppsForceListDataRefresh - | PolicyDetailsTrustedAppsRemoveListStateChanged - | PolicyDetailsArtifactsResetRemove; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/index.ts index a5752ed1658d..2409193d6381 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/index.ts @@ -7,24 +7,18 @@ import { ImmutableMiddlewareFactory } from '../../../../../../common/store'; import { MiddlewareRunnerContext, PolicyDetailsState } from '../../../types'; -import { policyTrustedAppsMiddlewareRunner } from './policy_trusted_apps_middleware'; import { policySettingsMiddlewareRunner } from './policy_settings_middleware'; -import { TrustedAppsHttpService } from '../../../../trusted_apps/service'; export const policyDetailsMiddlewareFactory: ImmutableMiddlewareFactory = ( coreStart ) => { - // Initialize services needed by Policy middleware - const trustedAppsService = new TrustedAppsHttpService(coreStart.http); const middlewareContext: MiddlewareRunnerContext = { coreStart, - trustedAppsService, }; return (store) => (next) => async (action) => { next(action); policySettingsMiddlewareRunner(middlewareContext, store, action); - policyTrustedAppsMiddlewareRunner(middlewareContext, store, action); }; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/policy_trusted_apps_middleware.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/policy_trusted_apps_middleware.ts deleted file mode 100644 index e8a647c257b0..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/policy_trusted_apps_middleware.ts +++ /dev/null @@ -1,443 +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 { isEmpty } from 'lodash/fp'; -import { - GetPolicyListResponse, - MiddlewareRunner, - MiddlewareRunnerContext, - PolicyAssignedTrustedApps, - PolicyDetailsState, - PolicyDetailsStore, - PolicyRemoveTrustedApps, -} from '../../../types'; -import { - doesPolicyTrustedAppsListNeedUpdate, - getCurrentArtifactsLocation, - getCurrentPolicyAssignedTrustedAppsState, - getCurrentTrustedAppsRemoveListState, - getCurrentUrlLocationPaginationParams, - getLatestLoadedPolicyAssignedTrustedAppsState, - getTrustedAppsIsRemoving, - getTrustedAppsPolicyListState, - isOnPolicyTrustedAppsView, - isPolicyTrustedAppListLoading, - licensedPolicy, - policyIdFromParams, - getDoesAnyTrustedAppExistsIsLoading, -} from '../selectors'; -import { - GetTrustedAppsListResponse, - Immutable, - MaybeImmutable, - PutTrustedAppUpdateResponse, - TrustedApp, -} from '../../../../../../../common/endpoint/types'; -import { ImmutableMiddlewareAPI } from '../../../../../../common/store'; -import { TrustedAppsService } from '../../../../trusted_apps/service'; -import { - asStaleResourceState, - createFailedResourceState, - createLoadedResourceState, - createLoadingResourceState, - isLoadingResourceState, - isUninitialisedResourceState, - isLoadedResourceState, -} from '../../../../../state'; -import { parseQueryFilterToKQL } from '../../../../../common/utils'; -import { SEARCHABLE_FIELDS } from '../../../../trusted_apps/constants'; -import { PolicyDetailsAction } from '../action'; -import { ServerApiError } from '../../../../../../common/types'; - -/** Runs all middleware actions associated with the Trusted Apps view in Policy Details */ -export const policyTrustedAppsMiddlewareRunner: MiddlewareRunner = async ( - context, - store, - action -) => { - const state = store.getState(); - - /* ----------------------------------------------------------- - If not on the Trusted Apps Policy view, then just return - ----------------------------------------------------------- */ - if (!isOnPolicyTrustedAppsView(state)) { - return; - } - - const { trustedAppsService } = context; - - switch (action.type) { - case 'userChangedUrl': - fetchPolicyTrustedAppsIfNeeded(context, store); - fetchAllPoliciesIfNeeded(context, store); - - if (action.type === 'userChangedUrl' && getCurrentArtifactsLocation(state).show === 'list') { - await searchTrustedApps(store, trustedAppsService); - } - - break; - - case 'policyDetailsTrustedAppsForceListDataRefresh': - fetchPolicyTrustedAppsIfNeeded(context, store, true); - break; - - case 'policyArtifactsUpdateTrustedApps': - if ( - getCurrentArtifactsLocation(state).show === 'list' && - action.payload.action === 'assign' - ) { - await updateTrustedApps(store, trustedAppsService, action.payload.artifacts); - } else if (action.payload.action === 'remove') { - removeTrustedAppsFromPolicy(context, store, action.payload.artifacts); - } - - break; - - case 'policyArtifactsAssignableListPageDataFilter': - if (getCurrentArtifactsLocation(state).show === 'list') { - await searchTrustedApps(store, trustedAppsService, action.payload.filter); - } - - break; - } -}; - -const checkIfThereAreAssignableTrustedApps = async ( - store: ImmutableMiddlewareAPI, - trustedAppsService: TrustedAppsService -) => { - const state = store.getState(); - const policyId = policyIdFromParams(state); - - store.dispatch({ - type: 'policyArtifactsAssignableListExistDataChanged', - payload: createLoadingResourceState(), - }); - try { - const trustedApps = await trustedAppsService.getTrustedAppsList({ - page: 1, - per_page: 100, - kuery: `(not exception-list-agnostic.attributes.tags:"policy:${policyId}") AND (not exception-list-agnostic.attributes.tags:"policy:all")`, - }); - - store.dispatch({ - type: 'policyArtifactsAssignableListExistDataChanged', - payload: createLoadedResourceState(!isEmpty(trustedApps.data)), - }); - } catch (err) { - store.dispatch({ - type: 'policyArtifactsAssignableListExistDataChanged', - payload: createFailedResourceState(err.body ?? err), - }); - } -}; - -const checkIfPolicyHasTrustedAppsAssigned = async ( - store: ImmutableMiddlewareAPI, - trustedAppsService: TrustedAppsService -) => { - const state = store.getState(); - if (isLoadingResourceState(state.artifacts.hasTrustedApps)) { - return; - } - if (isLoadedResourceState(state.artifacts.hasTrustedApps)) { - store.dispatch({ - type: 'policyArtifactsHasTrustedApps', - payload: createLoadingResourceState(state.artifacts.hasTrustedApps), - }); - } else { - store.dispatch({ - type: 'policyArtifactsHasTrustedApps', - payload: createLoadingResourceState(), - }); - } - try { - const policyId = policyIdFromParams(state); - const kuery = `(exception-list-agnostic.attributes.tags:"policy:${policyId}" OR exception-list-agnostic.attributes.tags:"policy:all")`; - const trustedApps = await trustedAppsService.getTrustedAppsList({ - page: 1, - per_page: 100, - kuery, - }); - - if ( - !trustedApps.total && - isUninitialisedResourceState(state.artifacts.doesAnyTrustedAppExists) - ) { - await checkIfAnyTrustedApp(store, trustedAppsService); - } - - store.dispatch({ - type: 'policyArtifactsHasTrustedApps', - payload: createLoadedResourceState(trustedApps), - }); - } catch (err) { - store.dispatch({ - type: 'policyArtifactsHasTrustedApps', - payload: createFailedResourceState(err.body ?? err), - }); - } -}; - -const checkIfAnyTrustedApp = async ( - store: ImmutableMiddlewareAPI, - trustedAppsService: TrustedAppsService -) => { - const state = store.getState(); - if (getDoesAnyTrustedAppExistsIsLoading(state)) { - return; - } - store.dispatch({ - type: 'policyArtifactsDeosAnyTrustedAppExists', - payload: createLoadingResourceState(), - }); - try { - const trustedApps = await trustedAppsService.getTrustedAppsList({ - page: 1, - per_page: 100, - }); - - store.dispatch({ - type: 'policyArtifactsDeosAnyTrustedAppExists', - payload: createLoadedResourceState(trustedApps), - }); - } catch (err) { - store.dispatch({ - type: 'policyArtifactsDeosAnyTrustedAppExists', - payload: createFailedResourceState(err.body ?? err), - }); - } -}; - -const searchTrustedApps = async ( - store: ImmutableMiddlewareAPI, - trustedAppsService: TrustedAppsService, - filter?: string -) => { - const state = store.getState(); - const policyId = policyIdFromParams(state); - - store.dispatch({ - type: 'policyArtifactsAssignableListPageDataChanged', - payload: createLoadingResourceState(), - }); - - try { - const kuery = [ - `(not exception-list-agnostic.attributes.tags:"policy:${policyId}") AND (not exception-list-agnostic.attributes.tags:"policy:all")`, - ]; - - if (filter) { - const filterKuery = parseQueryFilterToKQL(filter, SEARCHABLE_FIELDS) || undefined; - if (filterKuery) { - kuery.push(filterKuery); - } - } - - const trustedApps = await trustedAppsService.getTrustedAppsList({ - page: 1, - per_page: 100, - kuery: kuery.join(' AND '), - }); - - store.dispatch({ - type: 'policyArtifactsAssignableListPageDataChanged', - payload: createLoadedResourceState(trustedApps), - }); - - if (isEmpty(trustedApps.data)) { - checkIfThereAreAssignableTrustedApps(store, trustedAppsService); - } - } catch (err) { - store.dispatch({ - type: 'policyArtifactsAssignableListPageDataChanged', - payload: createFailedResourceState(err.body ?? err), - }); - } -}; - -const updateTrustedApps = async ( - store: ImmutableMiddlewareAPI, - trustedAppsService: TrustedAppsService, - trustedApps: MaybeImmutable -) => { - const state = store.getState(); - const policyId = policyIdFromParams(state); - - store.dispatch({ - type: 'policyArtifactsUpdateTrustedAppsChanged', - payload: createLoadingResourceState(), - }); - - try { - const updatedTrustedApps = await trustedAppsService.assignPolicyToTrustedApps( - policyId, - trustedApps - ); - await checkIfPolicyHasTrustedAppsAssigned(store, trustedAppsService); - - store.dispatch({ - type: 'policyArtifactsUpdateTrustedAppsChanged', - payload: createLoadedResourceState(updatedTrustedApps), - }); - - store.dispatch({ type: 'policyDetailsTrustedAppsForceListDataRefresh' }); - } catch (err) { - store.dispatch({ - type: 'policyArtifactsUpdateTrustedAppsChanged', - payload: createFailedResourceState(err.body ?? err), - }); - } -}; - -const fetchPolicyTrustedAppsIfNeeded = async ( - { trustedAppsService }: MiddlewareRunnerContext, - { getState, dispatch }: PolicyDetailsStore, - forceFetch: boolean = false -) => { - const state = getState(); - - if (isPolicyTrustedAppListLoading(state)) { - return; - } - - if (forceFetch || doesPolicyTrustedAppsListNeedUpdate(state)) { - dispatch({ - type: 'assignedTrustedAppsListStateChanged', - payload: createLoadingResourceState( - asStaleResourceState(getCurrentPolicyAssignedTrustedAppsState(state)) - ), - }); - - try { - const urlLocationData = getCurrentUrlLocationPaginationParams(state); - const policyId = policyIdFromParams(state); - const kuery = [ - `((exception-list-agnostic.attributes.tags:"policy:${policyId}") OR (exception-list-agnostic.attributes.tags:"policy:all"))`, - ]; - - if (urlLocationData.filter) { - const filterKuery = - parseQueryFilterToKQL(urlLocationData.filter, SEARCHABLE_FIELDS) || undefined; - if (filterKuery) { - kuery.push(filterKuery); - } - } - const fetchResponse = await trustedAppsService.getTrustedAppsList({ - page: urlLocationData.page_index + 1, - per_page: urlLocationData.page_size, - kuery: kuery.join(' AND '), - }); - - dispatch({ - type: 'assignedTrustedAppsListStateChanged', - payload: createLoadedResourceState>({ - location: urlLocationData, - artifacts: fetchResponse, - }), - }); - - if (isUninitialisedResourceState(state.artifacts.hasTrustedApps)) { - await checkIfPolicyHasTrustedAppsAssigned({ getState, dispatch }, trustedAppsService); - } - } catch (error) { - dispatch({ - type: 'assignedTrustedAppsListStateChanged', - payload: createFailedResourceState>( - error as ServerApiError, - getLatestLoadedPolicyAssignedTrustedAppsState(getState()) - ), - }); - } - } -}; - -const fetchAllPoliciesIfNeeded = async ( - { trustedAppsService }: MiddlewareRunnerContext, - { getState, dispatch }: PolicyDetailsStore -) => { - const state = getState(); - const currentPoliciesState = getTrustedAppsPolicyListState(state); - const isLoading = isLoadingResourceState(currentPoliciesState); - const hasBeenLoaded = !isUninitialisedResourceState(currentPoliciesState); - - if (isLoading || hasBeenLoaded) { - return; - } - - dispatch({ - type: 'policyDetailsListOfAllPoliciesStateChanged', - // @ts-expect-error ts 4.5 upgrade - payload: createLoadingResourceState(asStaleResourceState(currentPoliciesState)), - }); - - try { - const policyList = await trustedAppsService.getPolicyList({ - query: { - page: 1, - perPage: 1000, - }, - }); - - dispatch({ - type: 'policyDetailsListOfAllPoliciesStateChanged', - payload: createLoadedResourceState(policyList), - }); - } catch (error) { - dispatch({ - type: 'policyDetailsListOfAllPoliciesStateChanged', - payload: createFailedResourceState(error.body || error), - }); - } -}; - -const removeTrustedAppsFromPolicy = async ( - { trustedAppsService }: MiddlewareRunnerContext, - { getState, dispatch }: PolicyDetailsStore, - trustedApps: MaybeImmutable -): Promise => { - const state = getState(); - - if (getTrustedAppsIsRemoving(state)) { - return; - } - - dispatch({ - type: 'policyDetailsTrustedAppsRemoveListStateChanged', - payload: createLoadingResourceState( - asStaleResourceState(getCurrentTrustedAppsRemoveListState(state)) - ), - }); - - try { - const currentPolicyId = licensedPolicy(state)?.id; - - if (!currentPolicyId) { - throw new Error('current policy id not found'); - } - - const response = await trustedAppsService.removePolicyFromTrustedApps( - currentPolicyId, - trustedApps - ); - await checkIfPolicyHasTrustedAppsAssigned({ getState, dispatch }, trustedAppsService); - - dispatch({ - type: 'policyDetailsTrustedAppsRemoveListStateChanged', - payload: createLoadedResourceState({ artifacts: trustedApps, response }), - }); - - dispatch({ - type: 'policyDetailsTrustedAppsForceListDataRefresh', - }); - } catch (error) { - dispatch({ - type: 'policyDetailsTrustedAppsRemoveListStateChanged', - payload: createFailedResourceState(error.body || error), - }); - } -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/index.ts index a577c1ca85ef..264f315be189 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/index.ts @@ -10,7 +10,6 @@ import { PolicyDetailsState } from '../../../types'; import { AppAction } from '../../../../../../common/store/actions'; import { policySettingsReducer } from './policy_settings_reducer'; import { initialPolicyDetailsState } from './initial_policy_details_state'; -import { policyTrustedAppsReducer } from './trusted_apps_reducer'; export * from './initial_policy_details_state'; @@ -18,7 +17,7 @@ export const policyDetailsReducer: ImmutableReducer { - return [policySettingsReducer, policyTrustedAppsReducer].reduce( + return [policySettingsReducer].reduce( (updatedState, runReducer) => runReducer(updatedState, action), state ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/initial_policy_details_state.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/initial_policy_details_state.ts index a1e63bc889dd..23bd0f9a56d3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/initial_policy_details_state.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/initial_policy_details_state.ts @@ -11,7 +11,6 @@ import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE, } from '../../../../../common/constants'; -import { createUninitialisedResourceState } from '../../../../../state'; /** * Return a fresh copy of initial state, since we mutate state in the reducer. @@ -34,13 +33,5 @@ export const initialPolicyDetailsState: () => Immutable = () show: undefined, filter: '', }, - assignableList: createUninitialisedResourceState(), - trustedAppsToUpdate: createUninitialisedResourceState(), - assignableListEntriesExist: createUninitialisedResourceState(), - doesAnyTrustedAppExists: createUninitialisedResourceState(), - hasTrustedApps: createUninitialisedResourceState(), - assignedList: createUninitialisedResourceState(), - policies: createUninitialisedResourceState(), - removeList: createUninitialisedResourceState(), }, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.test.ts deleted file mode 100644 index e1d2fab6dcdb..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.test.ts +++ /dev/null @@ -1,272 +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 { PolicyDetailsState } from '../../../types'; -import { initialPolicyDetailsState } from './initial_policy_details_state'; -import { policyTrustedAppsReducer } from './trusted_apps_reducer'; - -import { ImmutableObject } from '../../../../../../../common/endpoint/types'; -import { - createLoadedResourceState, - createUninitialisedResourceState, - createLoadingResourceState, - createFailedResourceState, -} from '../../../../../state'; -import { getMockListResponse, getAPIError, getMockCreateResponse } from '../../../test_utils'; -import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; - -describe('policy trusted apps reducer', () => { - let initialState: ImmutableObject; - - beforeEach(() => { - initialState = { - ...initialPolicyDetailsState(), - location: { - pathname: getPolicyDetailsArtifactsListPath('abc'), - search: '', - hash: '', - }, - }; - }); - - describe('PolicyTrustedApps', () => { - describe('policyArtifactsAssignableListPageDataChanged', () => { - it('sets assignable list uninitialised', () => { - const result = policyTrustedAppsReducer(initialState, { - type: 'policyArtifactsAssignableListPageDataChanged', - payload: createUninitialisedResourceState(), - }); - - expect(result).toStrictEqual({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableList: { - type: 'UninitialisedResourceState', - }, - }, - }); - }); - it('sets assignable list loading', () => { - const result = policyTrustedAppsReducer(initialState, { - type: 'policyArtifactsAssignableListPageDataChanged', - payload: createLoadingResourceState(createUninitialisedResourceState()), - }); - - expect(result).toStrictEqual({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableList: { - previousState: { - type: 'UninitialisedResourceState', - }, - type: 'LoadingResourceState', - }, - }, - }); - }); - it('sets assignable list loaded', () => { - const result = policyTrustedAppsReducer(initialState, { - type: 'policyArtifactsAssignableListPageDataChanged', - payload: createLoadedResourceState(getMockListResponse()), - }); - - expect(result).toStrictEqual({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableList: { - data: getMockListResponse(), - type: 'LoadedResourceState', - }, - }, - }); - }); - it('sets assignable list failed', () => { - const result = policyTrustedAppsReducer(initialState, { - type: 'policyArtifactsAssignableListPageDataChanged', - payload: createFailedResourceState(getAPIError()), - }); - - expect(result).toStrictEqual({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableList: { - type: 'FailedResourceState', - error: getAPIError(), - lastLoadedState: undefined, - }, - }, - }); - }); - }); - }); - - describe('policyArtifactsUpdateTrustedAppsChanged', () => { - it('sets update trusted app uninitialised', () => { - const result = policyTrustedAppsReducer(initialState, { - type: 'policyArtifactsUpdateTrustedAppsChanged', - payload: createUninitialisedResourceState(), - }); - - expect(result).toStrictEqual({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: { - type: 'UninitialisedResourceState', - }, - }, - }); - }); - it('sets update trusted app loading', () => { - const result = policyTrustedAppsReducer(initialState, { - type: 'policyArtifactsUpdateTrustedAppsChanged', - payload: createLoadingResourceState(createUninitialisedResourceState()), - }); - - expect(result).toStrictEqual({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: { - previousState: { - type: 'UninitialisedResourceState', - }, - type: 'LoadingResourceState', - }, - }, - }); - }); - it('sets update trusted app loaded', () => { - const result = policyTrustedAppsReducer(initialState, { - type: 'policyArtifactsUpdateTrustedAppsChanged', - payload: createLoadedResourceState([getMockCreateResponse()]), - }); - - expect(result).toStrictEqual({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: { - data: [getMockCreateResponse()], - type: 'LoadedResourceState', - }, - }, - }); - }); - it('sets update trusted app failed', () => { - const result = policyTrustedAppsReducer(initialState, { - type: 'policyArtifactsUpdateTrustedAppsChanged', - payload: createFailedResourceState(getAPIError()), - }); - - expect(result).toStrictEqual({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: { - type: 'FailedResourceState', - error: getAPIError(), - lastLoadedState: undefined, - }, - }, - }); - }); - }); - - describe('policyArtifactsAssignableListExistDataChanged', () => { - it('sets exists trusted app uninitialised', () => { - const result = policyTrustedAppsReducer(initialState, { - type: 'policyArtifactsAssignableListExistDataChanged', - payload: createUninitialisedResourceState(), - }); - - expect(result).toStrictEqual({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: { - type: 'UninitialisedResourceState', - }, - }, - }); - }); - it('sets exists trusted app loading', () => { - const result = policyTrustedAppsReducer(initialState, { - type: 'policyArtifactsAssignableListExistDataChanged', - payload: createLoadingResourceState(createUninitialisedResourceState()), - }); - - expect(result).toStrictEqual({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: { - previousState: { - type: 'UninitialisedResourceState', - }, - type: 'LoadingResourceState', - }, - }, - }); - }); - it('sets exists trusted app loaded negative', () => { - const result = policyTrustedAppsReducer(initialState, { - type: 'policyArtifactsAssignableListExistDataChanged', - payload: createLoadedResourceState(false), - }); - - expect(result).toStrictEqual({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: { - data: false, - type: 'LoadedResourceState', - }, - }, - }); - }); - it('sets exists trusted app loaded positive', () => { - const result = policyTrustedAppsReducer(initialState, { - type: 'policyArtifactsAssignableListExistDataChanged', - payload: createLoadedResourceState(true), - }); - - expect(result).toStrictEqual({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: { - data: true, - type: 'LoadedResourceState', - }, - }, - }); - }); - it('sets exists trusted app failed', () => { - const result = policyTrustedAppsReducer(initialState, { - type: 'policyArtifactsAssignableListExistDataChanged', - payload: createFailedResourceState(getAPIError()), - }); - - expect(result).toStrictEqual({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: { - type: 'FailedResourceState', - error: getAPIError(), - lastLoadedState: undefined, - }, - }, - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.ts deleted file mode 100644 index f601e3ef0afb..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.ts +++ /dev/null @@ -1,124 +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 { ImmutableReducer } from '../../../../../../common/store'; -import { PolicyDetailsState } from '../../../types'; -import { AppAction } from '../../../../../../common/store/actions'; -import { initialPolicyDetailsState } from './initial_policy_details_state'; -import { isUninitialisedResourceState } from '../../../../../state'; -import { getCurrentPolicyAssignedTrustedAppsState, isOnPolicyTrustedAppsView } from '../selectors'; - -export const policyTrustedAppsReducer: ImmutableReducer = ( - state = initialPolicyDetailsState(), - action -) => { - /* ---------------------------------------------------------- - If not on the Trusted Apps Policy view, then just return - ---------------------------------------------------------- */ - if (!isOnPolicyTrustedAppsView(state)) { - // If the artifacts state namespace needs resetting, then do it now - if (!isUninitialisedResourceState(getCurrentPolicyAssignedTrustedAppsState(state))) { - return { - ...state, - artifacts: initialPolicyDetailsState().artifacts, - }; - } - - return state; - } - - if (action.type === 'policyArtifactsAssignableListPageDataChanged') { - return { - ...state, - artifacts: { - ...state.artifacts, - assignableList: action.payload, - }, - }; - } - - if (action.type === 'policyArtifactsUpdateTrustedAppsChanged') { - return { - ...state, - artifacts: { - ...state.artifacts, - trustedAppsToUpdate: action.payload, - }, - }; - } - - if (action.type === 'policyArtifactsAssignableListExistDataChanged') { - return { - ...state, - artifacts: { - ...state.artifacts, - assignableListEntriesExist: action.payload, - }, - }; - } - - if (action.type === 'policyArtifactsDeosAnyTrustedAppExists') { - return { - ...state, - artifacts: { - ...state?.artifacts, - doesAnyTrustedAppExists: action.payload, - }, - }; - } - - if (action.type === 'policyArtifactsHasTrustedApps') { - return { - ...state, - artifacts: { - ...state?.artifacts, - hasTrustedApps: action.payload, - }, - }; - } - if (action.type === 'assignedTrustedAppsListStateChanged') { - return { - ...state, - artifacts: { - ...state?.artifacts, - assignedList: action.payload, - }, - }; - } - - if (action.type === 'policyDetailsListOfAllPoliciesStateChanged') { - return { - ...state, - artifacts: { - ...state.artifacts, - policies: action.payload, - }, - }; - } - - if (action.type === 'policyDetailsTrustedAppsRemoveListStateChanged') { - return { - ...state, - artifacts: { - ...state.artifacts, - removeList: action.payload, - }, - }; - } - - if (action.type === 'policyDetailsArtifactsResetRemove') { - return { - ...state, - artifacts: { - ...state.artifacts, - removeList: initialPolicyDetailsState().artifacts.removeList, - }, - }; - } - - return state; -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/index.ts index d9c167d4a801..808791338ca7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/index.ts @@ -6,5 +6,4 @@ */ export * from './policy_settings_selectors'; -export * from './trusted_apps_selectors'; export * from './policy_common_selectors'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_common_selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_common_selectors.ts index 40953b927e93..ef753e75a839 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_common_selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_common_selectors.ts @@ -12,6 +12,7 @@ import { MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH, + MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, } from '../../../../../common/constants'; import { PolicyDetailsSelector, PolicyDetailsState } from '../../../types'; @@ -76,3 +77,16 @@ export const isOnHostIsolationExceptionsView: PolicyDetailsSelector = c ); } ); + +/** Returns a boolean of whether the user is on the blocklists page or not */ +export const isOnBlocklistsView: PolicyDetailsSelector = createSelector( + getUrlLocationPathname, + (pathname) => { + return ( + matchPath(pathname ?? '', { + path: MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, + exact: true, + }) !== null + ); + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts index a1a4c62d7073..eda993be8984 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts @@ -23,6 +23,7 @@ import { MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH, + MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, } from '../../../../../common/constants'; import { ManagementRoutePolicyDetailsParams } from '../../../../../types'; import { getPolicyDataForUpdate } from '../../../../../../../common/endpoint/service/policy'; @@ -31,6 +32,7 @@ import { isOnPolicyEventFiltersView, isOnHostIsolationExceptionsView, isOnPolicyFormView, + isOnBlocklistsView, } from './policy_common_selectors'; /** Returns the policy details */ @@ -93,7 +95,8 @@ export const isOnPolicyDetailsPage = (state: Immutable) => isOnPolicyFormView(state) || isOnPolicyTrustedAppsView(state) || isOnPolicyEventFiltersView(state) || - isOnHostIsolationExceptionsView(state); + isOnHostIsolationExceptionsView(state) || + isOnBlocklistsView(state); /** Returns the license info fetched from the license service */ export const license = (state: Immutable) => { @@ -111,6 +114,7 @@ export const policyIdFromParams: (state: Immutable) => strin MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH, + MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, ], exact: true, })?.params?.policyId ?? '' diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.test.ts deleted file mode 100644 index 0fbd674b265b..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.test.ts +++ /dev/null @@ -1,589 +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 { PolicyArtifactsState, PolicyDetailsState } from '../../../types'; -import { initialPolicyDetailsState } from '../reducer'; -import { - getAssignableArtifactsList, - getAssignableArtifactsListIsLoading, - getUpdateArtifactsIsLoading, - getUpdateArtifactsIsFailed, - getUpdateArtifactsLoaded, - getAssignableArtifactsListExist, - getAssignableArtifactsListExistIsLoading, - getUpdateArtifacts, - doesPolicyTrustedAppsListNeedUpdate, - isPolicyTrustedAppListLoading, - getPolicyTrustedAppList, - getPolicyTrustedAppsListPagination, - getTrustedAppsListOfAllPolicies, - getTrustedAppsAllPoliciesById, -} from './trusted_apps_selectors'; -import { getCurrentArtifactsLocation, isOnPolicyTrustedAppsView } from './policy_common_selectors'; - -import { ImmutableObject } from '../../../../../../../common/endpoint/types'; -import { - createLoadedResourceState, - createUninitialisedResourceState, - createLoadingResourceState, - createFailedResourceState, -} from '../../../../../state'; -import { MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH } from '../../../../../common/constants'; -import { - getMockListResponse, - getAPIError, - getMockCreateResponse, - getMockPolicyDetailsArtifactListUrlParams, - getMockPolicyDetailsArtifactsPageLocationUrlParams, -} from '../../../test_utils'; -import { getGeneratedPolicyResponse } from '../../../../trusted_apps/store/mocks'; - -describe('policy trusted apps selectors', () => { - let initialState: ImmutableObject; - - const createArtifactsState = ( - artifacts: Partial = {} - ): ImmutableObject => { - return { - ...initialState, - artifacts: { - ...initialState.artifacts, - ...artifacts, - }, - }; - }; - - beforeEach(() => { - initialState = initialPolicyDetailsState(); - }); - - describe('doesPolicyTrustedAppsListNeedUpdate()', () => { - it('should return true if state is not loaded', () => { - expect(doesPolicyTrustedAppsListNeedUpdate(initialState)).toBe(true); - }); - - it('should return true if it is loaded, but URL params were changed', () => { - expect( - doesPolicyTrustedAppsListNeedUpdate( - createArtifactsState({ - location: getMockPolicyDetailsArtifactsPageLocationUrlParams({ page_index: 4 }), - assignedList: createLoadedResourceState({ - location: getMockPolicyDetailsArtifactListUrlParams(), - artifacts: getMockListResponse(), - }), - }) - ) - ).toBe(true); - }); - - it('should return false if state is loaded adn URL params are the same', () => { - expect( - doesPolicyTrustedAppsListNeedUpdate( - createArtifactsState({ - location: getMockPolicyDetailsArtifactsPageLocationUrlParams(), - assignedList: createLoadedResourceState({ - location: getMockPolicyDetailsArtifactListUrlParams(), - artifacts: getMockListResponse(), - }), - }) - ) - ).toBe(false); - }); - }); - - describe('isPolicyTrustedAppListLoading()', () => { - it('should return true when loading data', () => { - expect( - isPolicyTrustedAppListLoading( - createArtifactsState({ - assignedList: createLoadingResourceState(createUninitialisedResourceState()), - }) - ) - ).toBe(true); - }); - - it.each([ - ['uninitialized', createUninitialisedResourceState() as PolicyArtifactsState['assignedList']], - ['loaded', createLoadedResourceState({}) as PolicyArtifactsState['assignedList']], - ['failed', createFailedResourceState({}) as PolicyArtifactsState['assignedList']], - ])('should return false when state is %s', (__, assignedListState) => { - expect( - isPolicyTrustedAppListLoading(createArtifactsState({ assignedList: assignedListState })) - ).toBe(false); - }); - }); - - describe('getPolicyTrustedAppList()', () => { - it('should return the list of trusted apps', () => { - const listResponse = getMockListResponse(); - - expect( - getPolicyTrustedAppList( - createArtifactsState({ - location: getMockPolicyDetailsArtifactsPageLocationUrlParams(), - assignedList: createLoadedResourceState({ - location: getMockPolicyDetailsArtifactListUrlParams(), - artifacts: listResponse, - }), - }) - ) - ).toEqual(listResponse.data); - }); - - it('should return empty array if no data is loaded', () => { - expect(getPolicyTrustedAppList(initialState)).toEqual([]); - }); - }); - - describe('getPolicyTrustedAppsListPagination()', () => { - it('should return default pagination data even if no api data is available', () => { - expect(getPolicyTrustedAppsListPagination(initialState)).toEqual({ - pageIndex: 0, - pageSize: 10, - pageSizeOptions: [10, 20, 50], - totalItemCount: 0, - }); - }); - - it('should return pagination data based on api response data', () => { - const listResponse = getMockListResponse(); - - listResponse.page = 6; - listResponse.per_page = 100; - listResponse.total = 1000; - - expect( - getPolicyTrustedAppsListPagination( - createArtifactsState({ - location: getMockPolicyDetailsArtifactsPageLocationUrlParams({ - page_index: 5, - page_size: 100, - }), - assignedList: createLoadedResourceState({ - location: getMockPolicyDetailsArtifactListUrlParams({ - page_index: 5, - page_size: 100, - }), - artifacts: listResponse, - }), - }) - ) - ).toEqual({ - pageIndex: 5, - pageSize: 100, - pageSizeOptions: [10, 20, 50], - totalItemCount: 1000, - }); - }); - }); - - describe('getTrustedAppsListOfAllPolicies()', () => { - it('should return the loaded list of policies', () => { - const policiesApiResponse = getGeneratedPolicyResponse(); - - expect( - getTrustedAppsListOfAllPolicies( - createArtifactsState({ - policies: createLoadedResourceState(policiesApiResponse), - }) - ) - ).toEqual(policiesApiResponse.items); - }); - - it('should return an empty array of no policy data was loaded yet', () => { - expect(getTrustedAppsListOfAllPolicies(initialState)).toEqual([]); - }); - }); - - describe('getTrustedAppsAllPoliciesById()', () => { - it('should return an empty object if no polices', () => { - expect(getTrustedAppsAllPoliciesById(initialState)).toEqual({}); - }); - - it('should return an object with policy id and policy data', () => { - const policiesApiResponse = getGeneratedPolicyResponse(); - - expect( - getTrustedAppsAllPoliciesById( - createArtifactsState({ - policies: createLoadedResourceState(policiesApiResponse), - }) - ) - ).toEqual({ [policiesApiResponse.items[0].id]: policiesApiResponse.items[0] }); - }); - }); - - describe('isOnPolicyTrustedAppsPage()', () => { - it('when location is on policy trusted apps page', () => { - const isOnPage = isOnPolicyTrustedAppsView({ - ...initialState, - location: { - pathname: MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, - search: '', - hash: '', - }, - }); - expect(isOnPage).toBeFalsy(); - }); - it('when location is not on policy trusted apps page', () => { - const isOnPage = isOnPolicyTrustedAppsView({ - ...initialState, - location: { pathname: '', search: '', hash: '' }, - }); - expect(isOnPage).toBeFalsy(); - }); - }); - - describe('getCurrentArtifactsLocation()', () => { - it('when location is defined', () => { - const location = getCurrentArtifactsLocation(initialState); - expect(location).toEqual({ filter: '', page_index: 0, page_size: 10, show: undefined }); - }); - it('when location has show param to list', () => { - const location = getCurrentArtifactsLocation({ - ...initialState, - artifacts: { - ...initialState.artifacts, - location: { ...initialState.artifacts.location, show: 'list' }, - }, - }); - expect(location).toEqual({ filter: '', page_index: 0, page_size: 10, show: 'list' }); - }); - }); - - describe('getAssignableArtifactsList()', () => { - it('when assignable list is uninitialised', () => { - const assignableList = getAssignableArtifactsList(initialState); - expect(assignableList).toBeUndefined(); - }); - it('when assignable list is loading', () => { - const assignableList = getAssignableArtifactsList({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableList: createLoadingResourceState(createUninitialisedResourceState()), - }, - }); - expect(assignableList).toBeUndefined(); - }); - it('when assignable list is loaded', () => { - const assignableList = getAssignableArtifactsList({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableList: createLoadedResourceState(getMockListResponse()), - }, - }); - expect(assignableList).toEqual(getMockListResponse()); - }); - }); - - describe('getAssignableArtifactsListIsLoading()', () => { - it('when assignable list is loading', () => { - const isLoading = getAssignableArtifactsListIsLoading({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableList: createLoadingResourceState(createUninitialisedResourceState()), - }, - }); - expect(isLoading).toBeTruthy(); - }); - it('when assignable list is uninitialised', () => { - const isLoading = getAssignableArtifactsListIsLoading({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableList: createUninitialisedResourceState(), - }, - }); - expect(isLoading).toBeFalsy(); - }); - it('when assignable list is loaded', () => { - const isLoading = getAssignableArtifactsListIsLoading({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableList: createLoadedResourceState(getMockListResponse()), - }, - }); - expect(isLoading).toBeFalsy(); - }); - }); - - describe('getUpdateArtifactsIsLoading()', () => { - it('when update artifacts is loading', () => { - const isLoading = getUpdateArtifactsIsLoading({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createLoadingResourceState(createUninitialisedResourceState()), - }, - }); - expect(isLoading).toBeTruthy(); - }); - it('when update artifacts is uninitialised', () => { - const isLoading = getUpdateArtifactsIsLoading({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createUninitialisedResourceState(), - }, - }); - expect(isLoading).toBeFalsy(); - }); - it('when update artifacts is loaded', () => { - const isLoading = getUpdateArtifactsIsLoading({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createLoadedResourceState([getMockCreateResponse()]), - }, - }); - expect(isLoading).toBeFalsy(); - }); - }); - - describe('getUpdateArtifactsIsFailed()', () => { - it('when update artifacts is loading', () => { - const hasFailed = getUpdateArtifactsIsFailed({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createLoadingResourceState(createUninitialisedResourceState()), - }, - }); - expect(hasFailed).toBeFalsy(); - }); - it('when update artifacts is uninitialised', () => { - const hasFailed = getUpdateArtifactsIsFailed({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createUninitialisedResourceState(), - }, - }); - expect(hasFailed).toBeFalsy(); - }); - it('when update artifacts is loaded', () => { - const hasFailed = getUpdateArtifactsIsFailed({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createLoadedResourceState([getMockCreateResponse()]), - }, - }); - expect(hasFailed).toBeFalsy(); - }); - it('when update artifacts has failed', () => { - const hasFailed = getUpdateArtifactsIsFailed({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createFailedResourceState(getAPIError()), - }, - }); - expect(hasFailed).toBeTruthy(); - }); - }); - - describe('getUpdateArtifactsLoaded()', () => { - it('when update artifacts is loading', () => { - const isLoaded = getUpdateArtifactsLoaded({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createLoadingResourceState(createUninitialisedResourceState()), - }, - }); - expect(isLoaded).toBeFalsy(); - }); - it('when update artifacts is uninitialised', () => { - const isLoaded = getUpdateArtifactsLoaded({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createUninitialisedResourceState(), - }, - }); - expect(isLoaded).toBeFalsy(); - }); - it('when update artifacts is loaded', () => { - const isLoaded = getUpdateArtifactsLoaded({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createLoadedResourceState([getMockCreateResponse()]), - }, - }); - expect(isLoaded).toBeTruthy(); - }); - it('when update artifacts has failed', () => { - const isLoaded = getUpdateArtifactsLoaded({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createFailedResourceState(getAPIError()), - }, - }); - expect(isLoaded).toBeFalsy(); - }); - }); - - describe('getUpdateArtifacts()', () => { - it('when update artifacts is loading', () => { - const isLoading = getUpdateArtifacts({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createLoadingResourceState(createUninitialisedResourceState()), - }, - }); - expect(isLoading).toBeUndefined(); - }); - it('when update artifacts is uninitialised', () => { - const isLoading = getUpdateArtifacts({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createUninitialisedResourceState(), - }, - }); - expect(isLoading).toBeUndefined(); - }); - it('when update artifacts is loaded', () => { - const isLoading = getUpdateArtifacts({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createLoadedResourceState([getMockCreateResponse()]), - }, - }); - expect(isLoading).toEqual([getMockCreateResponse()]); - }); - it('when update artifacts has failed', () => { - const isLoading = getUpdateArtifacts({ - ...initialState, - artifacts: { - ...initialState.artifacts, - trustedAppsToUpdate: createFailedResourceState(getAPIError()), - }, - }); - expect(isLoading).toBeUndefined(); - }); - }); - - describe('getAssignableArtifactsListExist()', () => { - it('when check artifacts exists is loading', () => { - const exists = getAssignableArtifactsListExist({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: createLoadingResourceState( - createUninitialisedResourceState() - ), - }, - }); - expect(exists).toBeFalsy(); - }); - it('when check artifacts exists is uninitialised', () => { - const exists = getAssignableArtifactsListExist({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: createUninitialisedResourceState(), - }, - }); - expect(exists).toBeFalsy(); - }); - it('when check artifacts exists is loaded with negative result', () => { - const exists = getAssignableArtifactsListExist({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: createLoadedResourceState(false), - }, - }); - expect(exists).toBeFalsy(); - }); - it('when check artifacts exists is loaded with positive result', () => { - const exists = getAssignableArtifactsListExist({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: createLoadedResourceState(true), - }, - }); - expect(exists).toBeTruthy(); - }); - it('when check artifacts exists has failed', () => { - const exists = getAssignableArtifactsListExist({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: createFailedResourceState(getAPIError()), - }, - }); - expect(exists).toBeFalsy(); - }); - }); - - describe('getAssignableArtifactsListExistIsLoading()', () => { - it('when check artifacts exists is loading', () => { - const isLoading = getAssignableArtifactsListExistIsLoading({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: createLoadingResourceState( - createUninitialisedResourceState() - ), - }, - }); - expect(isLoading).toBeTruthy(); - }); - it('when check artifacts exists is uninitialised', () => { - const isLoading = getAssignableArtifactsListExistIsLoading({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: createUninitialisedResourceState(), - }, - }); - expect(isLoading).toBeFalsy(); - }); - it('when check artifacts exists is loaded with negative result', () => { - const isLoading = getAssignableArtifactsListExistIsLoading({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: createLoadedResourceState(false), - }, - }); - expect(isLoading).toBeFalsy(); - }); - it('when check artifacts exists is loaded with positive result', () => { - const isLoading = getAssignableArtifactsListExistIsLoading({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: createLoadedResourceState(true), - }, - }); - expect(isLoading).toBeFalsy(); - }); - it('when check artifacts exists has failed', () => { - const isLoading = getAssignableArtifactsListExistIsLoading({ - ...initialState, - artifacts: { - ...initialState.artifacts, - assignableListEntriesExist: createFailedResourceState(getAPIError()), - }, - }); - expect(isLoading).toBeFalsy(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.ts deleted file mode 100644 index d341b8ae7a18..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.ts +++ /dev/null @@ -1,265 +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 { createSelector } from 'reselect'; -import { Pagination } from '@elastic/eui'; -import { isEmpty } from 'lodash/fp'; -import { - PolicyArtifactsState, - PolicyAssignedTrustedApps, - PolicyDetailsArtifactsPageListLocationParams, - PolicyDetailsSelector, - PolicyDetailsState, -} from '../../../types'; -import { - Immutable, - ImmutableArray, - PostTrustedAppCreateResponse, - GetTrustedAppsListResponse, - PolicyData, -} from '../../../../../../../common/endpoint/types'; -import { MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../../../common/constants'; -import { - getLastLoadedResourceState, - isFailedResourceState, - isLoadedResourceState, - isLoadingResourceState, - LoadedResourceState, -} from '../../../../../state'; -import { getCurrentArtifactsLocation } from './policy_common_selectors'; -import { ServerApiError } from '../../../../../../common/types'; - -export const doesPolicyHaveTrustedAppsAssignedList = ( - state: PolicyDetailsState -): { loading: boolean; hasTrustedApps: boolean } => { - return { - loading: isLoadingResourceState(state.artifacts.assignedList), - hasTrustedApps: isLoadedResourceState(state.artifacts.assignedList) - ? !isEmpty(state.artifacts.assignedList.data.artifacts.data) - : false, - }; -}; - -/** - * Returns current assignable artifacts list - */ -export const getAssignableArtifactsList = ( - state: Immutable -): Immutable | undefined => - getLastLoadedResourceState(state.artifacts.assignableList)?.data; - -/** - * Returns if assignable list is loading - */ -export const getAssignableArtifactsListIsLoading = ( - state: Immutable -): boolean => isLoadingResourceState(state.artifacts.assignableList); - -/** - * Returns if update action is loading - */ -export const getUpdateArtifactsIsLoading = (state: Immutable): boolean => - isLoadingResourceState(state.artifacts.trustedAppsToUpdate); - -/** - * Returns if update action is loading - */ -export const getUpdateArtifactsIsFailed = (state: Immutable): boolean => - isFailedResourceState(state.artifacts.trustedAppsToUpdate); - -/** - * Returns if update action is done successfully - */ -export const getUpdateArtifactsLoaded = (state: Immutable): boolean => { - return isLoadedResourceState(state.artifacts.trustedAppsToUpdate); -}; - -/** - * Returns true if there is data assignable even if the search didn't returned it. - */ -export const getAssignableArtifactsListExist = (state: Immutable): boolean => { - return ( - isLoadedResourceState(state.artifacts.assignableListEntriesExist) && - state.artifacts.assignableListEntriesExist.data - ); -}; - -/** - * Returns true if there is data assignable even if the search didn't returned it. - */ -export const getAssignableArtifactsListExistIsLoading = ( - state: Immutable -): boolean => { - return isLoadingResourceState(state.artifacts.assignableListEntriesExist); -}; - -/** - * Returns artifacts to be updated - */ -export const getUpdateArtifacts = ( - state: Immutable -): ImmutableArray | undefined => { - return state.artifacts.trustedAppsToUpdate.type === 'LoadedResourceState' - ? state.artifacts.trustedAppsToUpdate.data - : undefined; -}; - -/** - * Returns does any TA exists - */ -export const getDoesTrustedAppExists = (state: Immutable): boolean => { - return ( - isLoadedResourceState(state.artifacts.doesAnyTrustedAppExists) && - !!state.artifacts.doesAnyTrustedAppExists.data.total - ); -}; - -/** - * Returns does any TA exists loading - */ -export const doesTrustedAppExistsLoading = (state: Immutable): boolean => { - return isLoadingResourceState(state.artifacts.doesAnyTrustedAppExists); -}; - -/** Returns a boolean of whether the user is on the policy details page or not */ -export const getCurrentPolicyAssignedTrustedAppsState: PolicyDetailsSelector< - PolicyArtifactsState['assignedList'] -> = (state) => { - return state.artifacts.assignedList; -}; - -/** Returns current filter value */ -export const getCurrentPolicyArtifactsFilter: PolicyDetailsSelector = (state) => { - return state.artifacts.location.filter; -}; - -export const getLatestLoadedPolicyAssignedTrustedAppsState: PolicyDetailsSelector< - undefined | LoadedResourceState -> = createSelector(getCurrentPolicyAssignedTrustedAppsState, (currentAssignedTrustedAppsState) => { - return getLastLoadedResourceState(currentAssignedTrustedAppsState); -}); - -export const getCurrentUrlLocationPaginationParams: PolicyDetailsSelector = - // eslint-disable-next-line @typescript-eslint/naming-convention - createSelector(getCurrentArtifactsLocation, ({ filter, page_index, page_size }) => { - return { filter, page_index, page_size }; - }); - -export const doesPolicyTrustedAppsListNeedUpdate: PolicyDetailsSelector = createSelector( - getCurrentPolicyAssignedTrustedAppsState, - getCurrentUrlLocationPaginationParams, - (assignedListState, locationData) => { - return ( - !isLoadedResourceState(assignedListState) || - (isLoadedResourceState(assignedListState) && - ( - Object.keys(locationData) as Array - ).some((key) => assignedListState.data.location[key] !== locationData[key])) - ); - } -); - -export const isPolicyTrustedAppListLoading: PolicyDetailsSelector = createSelector( - getCurrentPolicyAssignedTrustedAppsState, - (assignedState) => isLoadingResourceState(assignedState) -); - -export const getPolicyTrustedAppList: PolicyDetailsSelector = - createSelector(getLatestLoadedPolicyAssignedTrustedAppsState, (assignedState) => { - return assignedState?.data.artifacts.data ?? []; - }); - -export const getPolicyTrustedAppsListPagination: PolicyDetailsSelector = createSelector( - getLatestLoadedPolicyAssignedTrustedAppsState, - (currentAssignedTrustedAppsState) => { - const trustedAppsApiResponse = currentAssignedTrustedAppsState?.data.artifacts; - - return { - // Trusted apps api is `1` based for page - need to subtract here for `Pagination` component - pageIndex: trustedAppsApiResponse?.page ? trustedAppsApiResponse.page - 1 : 0, - pageSize: trustedAppsApiResponse?.per_page ?? MANAGEMENT_PAGE_SIZE_OPTIONS[0], - totalItemCount: trustedAppsApiResponse?.total || 0, - pageSizeOptions: [...MANAGEMENT_PAGE_SIZE_OPTIONS], - }; - } -); - -export const getTotalPolicyTrustedAppsListPagination = ( - state: Immutable -): number => { - return getLastLoadedResourceState(state.artifacts.hasTrustedApps)?.data.total || 0; -}; - -export const getTrustedAppsPolicyListState: PolicyDetailsSelector< - PolicyDetailsState['artifacts']['policies'] -> = (state) => state.artifacts.policies; - -export const getTrustedAppsListOfAllPolicies: PolicyDetailsSelector = createSelector( - getTrustedAppsPolicyListState, - (policyListState) => { - return getLastLoadedResourceState(policyListState)?.data.items ?? []; - } -); - -export const getTrustedAppsAllPoliciesById: PolicyDetailsSelector< - Record> -> = createSelector(getTrustedAppsListOfAllPolicies, (allPolicies) => { - return allPolicies.reduce>>((mapById, policy) => { - mapById[policy.id] = policy; - return mapById; - }, {}) as Immutable>>; -}); - -export const getHasTrustedApps: PolicyDetailsSelector = (state) => { - return !!getLastLoadedResourceState(state.artifacts.hasTrustedApps)?.data.total; -}; - -export const getIsLoadedHasTrustedApps: PolicyDetailsSelector = (state) => - !!getLastLoadedResourceState(state.artifacts.hasTrustedApps); - -export const getHasTrustedAppsIsLoading: PolicyDetailsSelector = (state) => - isLoadingResourceState(state.artifacts.hasTrustedApps); - -export const getDoesAnyTrustedAppExists: PolicyDetailsSelector< - PolicyDetailsState['artifacts']['doesAnyTrustedAppExists'] -> = (state) => state.artifacts.doesAnyTrustedAppExists; - -export const getDoesAnyTrustedAppExistsIsLoading: PolicyDetailsSelector = createSelector( - getDoesAnyTrustedAppExists, - (doesAnyTrustedAppExists) => { - return isLoadingResourceState(doesAnyTrustedAppExists); - } -); - -export const getPolicyTrustedAppListError: PolicyDetailsSelector< - Immutable | undefined -> = createSelector(getCurrentPolicyAssignedTrustedAppsState, (currentAssignedTrustedAppsState) => { - if (isFailedResourceState(currentAssignedTrustedAppsState)) { - return currentAssignedTrustedAppsState.error; - } -}); - -export const getCurrentTrustedAppsRemoveListState: PolicyDetailsSelector< - PolicyArtifactsState['removeList'] -> = (state) => state.artifacts.removeList; - -export const getTrustedAppsIsRemoving: PolicyDetailsSelector = createSelector( - getCurrentTrustedAppsRemoveListState, - (removeListState) => isLoadingResourceState(removeListState) -); - -export const getTrustedAppsRemovalError: PolicyDetailsSelector = - createSelector(getCurrentTrustedAppsRemoveListState, (removeListState) => { - if (isFailedResourceState(removeListState)) { - return removeListState.error; - } - }); - -export const getTrustedAppsWasRemoveSuccessful: PolicyDetailsSelector = createSelector( - getCurrentTrustedAppsRemoveListState, - (removeListState) => isLoadedResourceState(removeListState) -); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/types.ts b/x-pack/plugins/security_solution/public/management/pages/policy/types.ts index bb511c886c83..6057c0545fa6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/types.ts @@ -13,7 +13,6 @@ import { ProtectionFields, PolicyData, UIPolicyConfig, - PostTrustedAppCreateResponse, MaybeImmutable, GetTrustedAppsListResponse, TrustedApp, @@ -26,10 +25,8 @@ import { GetPackagePoliciesResponse, UpdatePackagePolicyResponse, } from '../../../../../fleet/common'; -import { AsyncResourceState } from '../../state'; import { ImmutableMiddlewareAPI } from '../../../common/store'; import { AppAction } from '../../../common/store/actions'; -import { TrustedAppsService } from '../trusted_apps/service'; export type PolicyDetailsStore = ImmutableMiddlewareAPI; @@ -44,7 +41,6 @@ export type MiddlewareRunner = ( export interface MiddlewareRunnerContext { coreStart: CoreStart; - trustedAppsService: TrustedAppsService; } export type PolicyDetailsSelector = ( @@ -91,22 +87,6 @@ export interface PolicyRemoveTrustedApps { export interface PolicyArtifactsState { /** artifacts location params */ location: PolicyDetailsArtifactsPageLocation; - /** A list of artifacts can be linked to the policy */ - assignableList: AsyncResourceState; - /** Represents if available trusted apps entries exist, regardless of whether the list is showing results */ - assignableListEntriesExist: AsyncResourceState; - /** A list of trusted apps going to be updated */ - trustedAppsToUpdate: AsyncResourceState; - /** Represents if there is any trusted app existing */ - doesAnyTrustedAppExists: AsyncResourceState; - /** Represents if there is any trusted app existing assigned to the policy (without filters) */ - hasTrustedApps: AsyncResourceState; - /** List of artifacts currently assigned to the policy (body specific and global) */ - assignedList: AsyncResourceState; - /** A list of all available polices */ - policies: AsyncResourceState; - /** list of artifacts to remove. Holds the ids that were removed and the API response */ - removeList: AsyncResourceState; } export interface PolicyDetailsArtifactsPageListLocationParams { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/index.ts new file mode 100644 index 000000000000..4a8db9217141 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/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 { PolicyArtifactsDeleteModal } from './policy_artifacts_delete_modal'; +export { POLICY_ARTIFACT_DELETE_MODAL_LABELS } from './translations'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx similarity index 84% rename from x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.test.tsx rename to x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx index 2e00dab30300..ed5553337d6a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx @@ -15,33 +15,36 @@ import { AppContextTestRender, createAppRootMockRenderer, } from '../../../../../../common/mock/endpoint'; -import { PolicyEventFiltersDeleteModal } from './policy_event_filters_delete_modal'; +import { PolicyArtifactsDeleteModal } from './policy_artifacts_delete_modal'; import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_utils'; -import { cleanEventFilterToUpdate } from '../../../../event_filters/service/service_actions'; +import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { POLICY_ARTIFACT_DELETE_MODAL_LABELS } from './translations'; -describe('Policy details event filter delete modal', () => { +describe('Policy details artifacts delete modal', () => { let policyId: string; let render: () => Promise>; let renderResult: ReturnType; let mockedContext: AppContextTestRender; let exception: ExceptionListItemSchema; let mockedApi: ReturnType; - let onCancel: () => void; + let onCloseMock: () => jest.Mock; beforeEach(() => { policyId = uuid.v4(); mockedContext = createAppRootMockRenderer(); exception = getExceptionListItemSchemaMock(); - onCancel = jest.fn(); + onCloseMock = jest.fn(); mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); render = async () => { await act(async () => { renderResult = mockedContext.render( - ); await waitFor(mockedApi.responseProvider.eventFiltersList); @@ -74,7 +77,7 @@ describe('Policy details event filter delete modal', () => { await waitFor(() => { expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenLastCalledWith({ body: JSON.stringify( - cleanEventFilterToUpdate({ + EventFiltersApiClient.cleanExceptionsBeforeUpdate({ ...exception, tags: ['policy:1234', 'policy:4321', 'not-a-policy-tag'], }) @@ -93,7 +96,7 @@ describe('Policy details event filter delete modal', () => { expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenCalled(); }); - expect(onCancel).toHaveBeenCalled(); + expect(onCloseMock).toHaveBeenCalled(); expect(mockedContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalled(); }); @@ -112,7 +115,7 @@ describe('Policy details event filter delete modal', () => { }); expect(mockedContext.coreStart.notifications.toasts.addError).toHaveBeenCalledWith(error, { - title: 'Error while attempt to remove event filter', + title: 'Error while attempting to remove artifact', }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.tsx new file mode 100644 index 000000000000..a92411c22ce2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCallOut, EuiConfirmModal, EuiSpacer, EuiText } from '@elastic/eui'; +import { useQueryClient } from 'react-query'; +import { HttpFetchError } from 'kibana/public'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import React, { useCallback } from 'react'; +import { useBulkUpdateArtifact } from '../../../../../hooks/artifacts'; +import { useToasts } from '../../../../../../common/lib/kibana'; +import { ExceptionsListApiClient } from '../../../../../services/exceptions_list/exceptions_list_api_client'; +import { BY_POLICY_ARTIFACT_TAG_PREFIX } from '../../../../../../../common/endpoint/service/artifacts'; +import { POLICY_ARTIFACT_DELETE_MODAL_LABELS } from './translations'; + +interface PolicyArtifactsDeleteModalProps { + policyId: string; + policyName: string; + apiClient: ExceptionsListApiClient; + exception: ExceptionListItemSchema; + onClose: () => void; + labels: typeof POLICY_ARTIFACT_DELETE_MODAL_LABELS; +} + +export const PolicyArtifactsDeleteModal = React.memo( + ({ policyId, policyName, apiClient, exception, onClose, labels }) => { + const toasts = useToasts(); + const queryClient = useQueryClient(); + + const { mutate: updateArtifact, isLoading: isUpdateArtifactLoading } = useBulkUpdateArtifact( + apiClient, + { + onSuccess: () => { + toasts.addSuccess({ + title: labels.deleteModalSuccessMessageTitle, + text: labels.deleteModalSuccessMessageText(exception, policyName), + }); + queryClient.invalidateQueries(['list', apiClient]); + onClose(); + }, + onError: (error?: HttpFetchError) => { + toasts.addError(error as unknown as Error, { + title: labels.deleteModalErrorMessage, + }); + }, + } + ); + + const handleModalConfirm = useCallback(() => { + const modifiedException = { + ...exception, + tags: exception.tags.filter((tag) => tag !== `${BY_POLICY_ARTIFACT_TAG_PREFIX}${policyId}`), + }; + updateArtifact([modifiedException]); + }, [exception, policyId, updateArtifact]); + + const handleOnClose = useCallback(() => { + if (!isUpdateArtifactLoading) { + onClose(); + } + }, [isUpdateArtifactLoading, onClose]); + + return ( + + +

    {labels.deleteModalImpactInfo}

    +
    + + + + +

    {labels.deleteModalConfirmInfo}

    +
    +
    + ); + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/translations.ts new file mode 100644 index 000000000000..bbbe4ed73c29 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/translations.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +export const POLICY_ARTIFACT_DELETE_MODAL_LABELS = Object.freeze({ + deleteModalTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.list.removeDialog.title', + { + defaultMessage: 'Remove artifact from policy', + } + ), + + deleteModalImpactInfo: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.list.removeDialog.messageCallout', + { + defaultMessage: + 'This artifact will be removed only from this policy and can still be found and managed from the artifact page.', + } + ), + + deleteModalConfirmInfo: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.list.removeDialog.message', + { + defaultMessage: 'Are you sure you wish to continue?', + } + ), + + deleteModalSubmitButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.list.removeDialog.confirmLabel', + { + defaultMessage: 'Remove from policy', + } + ), + + deleteModalCancelButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.list.removeDialog.cancelLabel', + { defaultMessage: 'Cancel' } + ), + + deleteModalSuccessMessageTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.list.removeDialog.successToastTitle', + { defaultMessage: 'Successfully removed' } + ), + deleteModalSuccessMessageText: (exception: ExceptionListItemSchema, policyName: string): string => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.list.removeDialog.successToastText', + { + defaultMessage: '"{artifactName}" has been removed from {policyName} policy', + values: { artifactName: exception.name, policyName }, + } + ), + deleteModalErrorMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.list.removeDialog.errorToastTitle', + { + defaultMessage: 'Error while attempting to remove artifact', + } + ), +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/index.ts new file mode 100644 index 000000000000..db833460283c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { PolicyArtifactsEmptyUnassigned } from './policy_artifacts_empty_unassigned'; +export { PolicyArtifactsEmptyUnexisting } from './policy_artifacts_empty_unexisting'; +export { + POLICY_ARTIFACT_EMPTY_UNASSIGNED_LABELS, + POLICY_ARTIFACT_EMPTY_UNEXISTING_LABELS, +} from './translations'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unassigned.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unassigned.tsx new file mode 100644 index 000000000000..5a606de45068 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unassigned.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiButton, EuiEmptyPrompt, EuiPageTemplate, EuiLink } from '@elastic/eui'; +import { usePolicyDetailsArtifactsNavigateCallback } from '../../policy_hooks'; +import { useGetLinkTo } from './use_policy_artifacts_empty_hooks'; +import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; +import { POLICY_ARTIFACT_EMPTY_UNASSIGNED_LABELS } from './translations'; +import { EventFiltersPageLocation } from '../../../../event_filters/types'; +import { TrustedAppsListPageLocation } from '../../../../trusted_apps/state'; +import { HostIsolationExceptionsPageLocation } from '../../../../host_isolation_exceptions/types'; +interface CommonProps { + policyId: string; + policyName: string; + listId: string; + labels: typeof POLICY_ARTIFACT_EMPTY_UNASSIGNED_LABELS; + getPolicyArtifactsPath: (policyId: string) => string; + getArtifactPath: ( + location?: + | Partial + | Partial + | Partial + ) => string; +} + +export const PolicyArtifactsEmptyUnassigned = memo( + ({ policyId, policyName, listId, labels, getPolicyArtifactsPath, getArtifactPath }) => { + const { canCreateArtifactsByPolicy } = useUserPrivileges().endpointPrivileges; + const { onClickHandler, toRouteUrl } = useGetLinkTo( + policyId, + policyName, + getPolicyArtifactsPath, + getArtifactPath + ); + + const navigateCallback = usePolicyDetailsArtifactsNavigateCallback(listId); + const onClickPrimaryButtonHandler = useCallback( + () => + navigateCallback({ + show: 'list', + }), + [navigateCallback] + ); + return ( + + {labels.emptyUnassignedTitle}

    } + body={labels.emptyUnassignedMessage(policyName)} + actions={[ + ...(canCreateArtifactsByPolicy + ? [ + + {labels.emptyUnassignedPrimaryActionButtonTitle} + , + ] + : []), + // eslint-disable-next-line @elastic/eui/href-or-on-click + + {labels.emptyUnassignedSecondaryActionButtonTitle} + , + ]} + /> + + ); + } +); + +PolicyArtifactsEmptyUnassigned.displayName = 'PolicyArtifactsEmptyUnassigned'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unexisting.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unexisting.tsx new file mode 100644 index 000000000000..c62db552e9a0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unexisting.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiEmptyPrompt, EuiButton, EuiPageTemplate } from '@elastic/eui'; +import { useGetLinkTo } from './use_policy_artifacts_empty_hooks'; +import { POLICY_ARTIFACT_EMPTY_UNEXISTING_LABELS } from './translations'; +import { EventFiltersPageLocation } from '../../../../event_filters/types'; +import { TrustedAppsListPageLocation } from '../../../../trusted_apps/state'; +import { HostIsolationExceptionsPageLocation } from '../../../../host_isolation_exceptions/types'; + +interface CommonProps { + policyId: string; + policyName: string; + labels: typeof POLICY_ARTIFACT_EMPTY_UNEXISTING_LABELS; + getPolicyArtifactsPath: (policyId: string) => string; + getArtifactPath: ( + location?: + | Partial + | Partial + | Partial + ) => string; +} + +export const PolicyArtifactsEmptyUnexisting = memo( + ({ policyId, policyName, labels, getPolicyArtifactsPath, getArtifactPath }) => { + const { onClickHandler, toRouteUrl } = useGetLinkTo( + policyId, + policyName, + getPolicyArtifactsPath, + getArtifactPath, + { + show: 'create', + } + ); + return ( + + {labels.emptyUnexistingTitle}} + body={labels.emptyUnexistingMessage} + actions={ + // eslint-disable-next-line @elastic/eui/href-or-on-click + + {labels.emptyUnexistingPrimaryActionButtonTitle} + + } + /> + + ); + } +); + +PolicyArtifactsEmptyUnexisting.displayName = 'PolicyArtifactsEmptyUnexisting'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/translations.ts new file mode 100644 index 000000000000..8fdb3f2dbfb5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/translations.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 { i18n } from '@kbn/i18n'; + +export const POLICY_ARTIFACT_EMPTY_UNASSIGNED_LABELS = Object.freeze({ + emptyUnassignedTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.empty.unassigned.title', + { defaultMessage: 'No assigned artifacts' } + ), + emptyUnassignedMessage: (policyName: string): string => + i18n.translate('xpack.securitySolution.endpoint.policy.artifacts.empty.unassigned.content', { + defaultMessage: + 'There are currently no artifacts assigned to {policyName}. Assign artifacts now or add and manage them on the artifacts page.', + values: { policyName }, + }), + emptyUnassignedPrimaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.empty.unassigned.primaryAction', + { + defaultMessage: 'Assign artifacts', + } + ), + emptyUnassignedSecondaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.empty.unassigned.secondaryAction', + { + defaultMessage: 'Manage artifacts', + } + ), +}); + +export const POLICY_ARTIFACT_EMPTY_UNEXISTING_LABELS = Object.freeze({ + emptyUnexistingTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.empty.unexisting.title', + { defaultMessage: 'No artifacts exist' } + ), + emptyUnexistingMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.empty.unexisting.content', + { defaultMessage: 'There are currently no artifacts applied to your endpoints.' } + ), + emptyUnexistingPrimaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.empty.unexisting.action', + { defaultMessage: 'Add artifacts' } + ), +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/use_policy_host_isolation_exceptions_empty_hooks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/use_policy_artifacts_empty_hooks.ts similarity index 60% rename from x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/use_policy_host_isolation_exceptions_empty_hooks.ts rename to x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/use_policy_artifacts_empty_hooks.ts index 494dfd9a7ae0..2304cb7dfdd6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/use_policy_host_isolation_exceptions_empty_hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/use_policy_artifacts_empty_hooks.ts @@ -9,35 +9,38 @@ import { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { useNavigateToAppEventHandler } from '../../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; import { useAppUrl } from '../../../../../../common/lib/kibana/hooks'; -import { - getPolicyHostIsolationExceptionsPath, - getHostIsolationExceptionsListPath, -} from '../../../../../common/routing'; import { APP_UI_ID } from '../../../../../../../common/constants'; +import { EventFiltersPageLocation } from '../../../../event_filters/types'; +import { TrustedAppsListPageLocation } from '../../../../trusted_apps/state'; import { HostIsolationExceptionsPageLocation } from '../../../../host_isolation_exceptions/types'; export const useGetLinkTo = ( policyId: string, policyName: string, - location?: Partial + getPolicyArtifactsPath: (policyId: string) => string, + getArtifactPath: ( + location?: + | Partial + | Partial + | Partial + ) => string, + location?: Partial<{ show: 'create' }> ) => { const { getAppUrl } = useAppUrl(); const { toRoutePath, toRouteUrl } = useMemo(() => { - const path = getHostIsolationExceptionsListPath(location); + const path = getArtifactPath(location); return { toRoutePath: path, toRouteUrl: getAppUrl({ path }), }; - }, [getAppUrl, location]); + }, [getAppUrl, getArtifactPath, location]); - const policyHostIsolationExceptionsPath = useMemo( - () => getPolicyHostIsolationExceptionsPath(policyId), - [policyId] - ); - const policyHostIsolationExceptionsRouteState = useMemo(() => { + const policyArtifactsPath = getPolicyArtifactsPath(policyId); + + const policyArtifactRouteState = useMemo(() => { return { backButtonLabel: i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.empty.unassigned.backButtonLabel', + 'xpack.securitySolution.endpoint.policy.artifacts.empty.unassigned.backButtonLabel', { defaultMessage: 'Back to {policyName} policy', values: { @@ -48,24 +51,24 @@ export const useGetLinkTo = ( onBackButtonNavigateTo: [ APP_UI_ID, { - path: policyHostIsolationExceptionsPath, + path: policyArtifactsPath, }, ], backButtonUrl: getAppUrl({ appId: APP_UI_ID, - path: policyHostIsolationExceptionsPath, + path: policyArtifactsPath, }), }; - }, [getAppUrl, policyName, policyHostIsolationExceptionsPath]); + }, [getAppUrl, policyName, policyArtifactsPath]); const onClickHandler = useNavigateToAppEventHandler(APP_UI_ID, { - state: policyHostIsolationExceptionsRouteState, + state: policyArtifactRouteState, path: toRoutePath, }); return { onClickHandler, toRouteUrl, - state: policyHostIsolationExceptionsRouteState, + state: policyArtifactRouteState, }; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/index.ts new file mode 100644 index 000000000000..7337ccff2a83 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/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 { PolicyArtifactsFlyout } from './policy_artifacts_flyout'; +export { POLICY_ARTIFACT_FLYOUT_LABELS } from './translations'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx similarity index 85% rename from x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.test.tsx rename to x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx index 7d984cdb2a38..e9ac07785728 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx @@ -17,8 +17,9 @@ import { } from '../../../../../../common/mock/endpoint'; import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data'; import { PolicyData } from '../../../../../../../common/endpoint/types'; +import { MANAGEMENT_DEFAULT_PAGE } from '../../../../../common/constants'; import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_utils'; -import { PolicyEventFiltersFlyout } from './policy_event_filters_flyout'; +import { MAX_ALLOWED_RESULTS, PolicyArtifactsFlyout } from './policy_artifacts_flyout'; import { parseQueryFilterToKQL, parsePoliciesAndFilterToKql } from '../../../../../common/utils'; import { SEARCHABLE_FIELDS } from '../../../../event_filters/constants'; import { @@ -26,6 +27,8 @@ import { UpdateExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { cleanEventFilterToUpdate } from '../../../../event_filters/service/service_actions'; +import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { POLICY_ARTIFACT_FLYOUT_LABELS } from './translations'; const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ path: '/api/exception_lists/items/_find', @@ -33,8 +36,8 @@ const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ filter: customFilter, list_id: ['endpoint_event_filters'], namespace_type: ['agnostic'], - page: undefined, - per_page: 100, + page: MANAGEMENT_DEFAULT_PAGE + 1, + per_page: MAX_ALLOWED_RESULTS, sort_field: undefined, sort_order: undefined, }, @@ -59,7 +62,7 @@ const getCleanedExceptionWithNewTags = ( return cleanEventFilterToUpdate(exceptionToUpdateWithNewTags); }; -describe('Policy details event filters flyout', () => { +describe('Policy details artifacts flyout', () => { let render: () => Promise>; let renderResult: ReturnType; let mockedContext: AppContextTestRender; @@ -76,7 +79,13 @@ describe('Policy details event filters flyout', () => { render = async () => { await act(async () => { renderResult = mockedContext.render( - + ); await waitFor(mockedApi.responseProvider.eventFiltersList); }); @@ -89,7 +98,7 @@ describe('Policy details event filters flyout', () => { return getFoundExceptionListItemSchemaMock(1); }); await render(); - expect(mockedApi.responseProvider.eventFiltersList).toHaveBeenLastCalledWith( + expect(mockedApi.responseProvider.eventFiltersList).toHaveBeenCalledWith( getDefaultQueryParameters( parsePoliciesAndFilterToKql({ excludedPolicies: [policy.id, 'all'], @@ -124,7 +133,7 @@ describe('Policy details event filters flyout', () => { }) ) ); - expect(renderResult.getByTestId('eventFilters-no-items-found')).toBeTruthy(); + expect(renderResult.getByTestId('artifacts-no-items-found')).toBeTruthy(); }); }); @@ -132,7 +141,7 @@ describe('Policy details event filters flyout', () => { // both exceptions list requests will return no results mockedApi.responseProvider.eventFiltersList.mockImplementation(() => getEmptyList()); await render(); - expect(await renderResult.findByTestId('eventFilters-no-assignable-items')).toBeTruthy(); + expect(await renderResult.findByTestId('artifacts-no-assignable-items')).toBeTruthy(); }); it('should disable the submit button if no exceptions are selected', async () => { @@ -141,7 +150,7 @@ describe('Policy details event filters flyout', () => { }); await render(); expect(await renderResult.findByTestId('artifactsList')).toBeTruthy(); - expect(renderResult.getByTestId('eventFilters-assign-confirm-button')).toBeDisabled(); + expect(renderResult.getByTestId('artifacts-assign-confirm-button')).toBeDisabled(); }); it('should enable the submit button if an exception is selected', async () => { @@ -155,7 +164,7 @@ describe('Policy details event filters flyout', () => { // click the first item userEvent.click(renderResult.getByTestId(`${firstOneName}_checkbox`)); - expect(renderResult.getByTestId('eventFilters-assign-confirm-button')).toBeEnabled(); + expect(renderResult.getByTestId('artifacts-assign-confirm-button')).toBeEnabled(); }); it('should warn the user when there are over 100 results in the flyout', async () => { @@ -167,7 +176,7 @@ describe('Policy details event filters flyout', () => { }); await render(); expect(await renderResult.findByTestId('artifactsList')).toBeTruthy(); - expect(renderResult.getByTestId('eventFilters-too-many-results')).toBeTruthy(); + expect(renderResult.getByTestId('artifacts-too-many-results')).toBeTruthy(); }); describe('when submitting the form', () => { @@ -205,7 +214,7 @@ describe('Policy details event filters flyout', () => { // click the first item userEvent.click(renderResult.getByTestId(`${FIRST_ONE_NAME}_checkbox`)); // submit the form - userEvent.click(renderResult.getByTestId('eventFilters-assign-confirm-button')); + userEvent.click(renderResult.getByTestId('artifacts-assign-confirm-button')); // verify the request with the new tag await waitFor(() => { @@ -219,7 +228,7 @@ describe('Policy details event filters flyout', () => { await waitFor(() => { expect(mockedContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith({ - text: `"${FIRST_ONE_NAME}" has been added to your event filters list.`, + text: `"${FIRST_ONE_NAME}" has been added to your artifacts list.`, title: 'Success', }); }); @@ -231,7 +240,7 @@ describe('Policy details event filters flyout', () => { userEvent.click(renderResult.getByTestId(`${FIRST_ONE_NAME}_checkbox`)); userEvent.click(renderResult.getByTestId(`${SECOND_ONE_NAME}_checkbox`)); // submit the form - userEvent.click(renderResult.getByTestId('eventFilters-assign-confirm-button')); + userEvent.click(renderResult.getByTestId('artifacts-assign-confirm-button')); // verify the request with the new tag await waitFor(() => { @@ -253,27 +262,26 @@ describe('Policy details event filters flyout', () => { await waitFor(() => { expect(mockedContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith({ - text: '2 event filters have been added to your list.', + text: '2 artifacts have been added to your list.', title: 'Success', }); }); expect(onCloseMock).toHaveBeenCalled(); }); - it('should show a toast error when the request fails and close the flyout', async () => { + it('should show a toast error when the request fails', async () => { mockedApi.responseProvider.eventFiltersUpdateOne.mockImplementation(() => { throw new Error('the server is too far away'); }); // click first item userEvent.click(renderResult.getByTestId(`${FIRST_ONE_NAME}_checkbox`)); // submit the form - userEvent.click(renderResult.getByTestId('eventFilters-assign-confirm-button')); + userEvent.click(renderResult.getByTestId('artifacts-assign-confirm-button')); await waitFor(() => { expect(mockedContext.coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( 'An error occurred updating artifacts' ); - expect(onCloseMock).toHaveBeenCalled(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.tsx new file mode 100644 index 000000000000..1ba31da56530 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.tsx @@ -0,0 +1,236 @@ +/* + * Copyright 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, { useCallback, useMemo, useState } from 'react'; +import { useQueryClient } from 'react-query'; +import { isEmpty, without } from 'lodash/fp'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { + EuiTitle, + EuiFlyout, + EuiSpacer, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiCallOut, + EuiEmptyPrompt, +} from '@elastic/eui'; +import { SearchExceptions } from '../../../../../components/search_exceptions'; +import { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; +import { useToasts } from '../../../../../../common/lib/kibana'; +import { PolicyArtifactsAssignableList } from '../../artifacts/assignable'; +import { ExceptionsListApiClient } from '../../../../../services/exceptions_list/exceptions_list_api_client'; +import { useListArtifact, useBulkUpdateArtifact } from '../../../../../hooks/artifacts'; +import { POLICY_ARTIFACT_FLYOUT_LABELS } from './translations'; + +interface PolicyArtifactsFlyoutProps { + policyItem: ImmutableObject; + apiClient: ExceptionsListApiClient; + searchableFields: string[]; + onClose: () => void; + labels: typeof POLICY_ARTIFACT_FLYOUT_LABELS; +} + +export const MAX_ALLOWED_RESULTS = 100; + +export const PolicyArtifactsFlyout = React.memo( + ({ policyItem, apiClient, searchableFields, onClose, labels }) => { + const toasts = useToasts(); + const queryClient = useQueryClient(); + const [selectedArtifactIds, setSelectedArtifactIds] = useState([]); + const [currentFilter, setCurrentFilter] = useState(''); + + const bulkUpdateMutation = useBulkUpdateArtifact(apiClient, { + onSuccess: (updatedExceptions: ExceptionListItemSchema[]) => { + toasts.addSuccess({ + title: labels.flyoutSuccessMessageTitle, + text: labels.flyoutSuccessMessageText(updatedExceptions), + }); + queryClient.invalidateQueries(['list', apiClient]); + onClose(); + }, + onError: () => { + toasts.addDanger(labels.flyoutErrorMessage); + }, + }); + + const { + data: artifacts, + isLoading: isLoadingArtifacts, + isRefetching: isRefetchingArtifacts, + } = useListArtifact( + apiClient, + { + perPage: MAX_ALLOWED_RESULTS, + filter: currentFilter, + excludedPolicies: [policyItem.id, 'all'], + }, + searchableFields + ); + + const { data: allNotAssigned, isLoading: isLoadingAllNotAssigned } = useListArtifact( + apiClient, + + { + excludedPolicies: [policyItem.id, 'all'], + }, + searchableFields + ); + + const handleOnSearch = useCallback((query) => { + setSelectedArtifactIds([]); + setCurrentFilter(query); + }, []); + + const handleOnConfirmAction = useCallback(() => { + if (!artifacts) { + return; + } + const artifactsToUpdate: ExceptionListItemSchema[] = []; + selectedArtifactIds.forEach((selectedId) => { + const artifact = artifacts.data.find((current) => current.id === selectedId); + if (artifact) { + artifact.tags = [...artifact.tags, `policy:${policyItem.id}`]; + artifactsToUpdate.push(artifact); + } + }); + bulkUpdateMutation.mutate(artifactsToUpdate); + }, [bulkUpdateMutation, artifacts, policyItem.id, selectedArtifactIds]); + + const handleSelectArtifacts = (artifactId: string, selected: boolean) => { + setSelectedArtifactIds((currentSelectedArtifactIds) => + selected + ? [...currentSelectedArtifactIds, artifactId] + : without([artifactId], currentSelectedArtifactIds) + ); + }; + + const searchWarningMessage = useMemo( + () => ( + <> + + {labels.flyoutWarningCalloutMessage(MAX_ALLOWED_RESULTS)} + + + + ), + [labels] + ); + + const assignableArtifacts = useMemo( + () => allNotAssigned?.total !== 0 && (artifacts?.total !== 0 || currentFilter !== ''), + [allNotAssigned?.total, artifacts?.total, currentFilter] + ); + + const isGlobalLoading = useMemo( + () => isLoadingArtifacts || isRefetchingArtifacts || isLoadingAllNotAssigned, + [isLoadingAllNotAssigned, isLoadingArtifacts, isRefetchingArtifacts] + ); + + const noItemsMessage = useMemo(() => { + if (isGlobalLoading) { + return null; + } + + // there are no artifacts assignable to this policy + if (!assignableArtifacts) { + return ( + {labels.flyoutNoArtifactsToBeAssignedMessage}

    } + /> + ); + } + + // there are no results for the current search + if (artifacts?.total === 0) { + return ( + {labels.flyoutNoSearchResultsMessage}

    } + /> + ); + } + }, [ + isGlobalLoading, + assignableArtifacts, + artifacts?.total, + labels.flyoutNoArtifactsToBeAssignedMessage, + labels.flyoutNoSearchResultsMessage, + ]); + + return ( + + + +

    {labels.flyoutTitle}

    +
    + + {labels.flyoutSubtitle(policyItem.name)} +
    + + {(artifacts?.total || 0) > MAX_ALLOWED_RESULTS ? searchWarningMessage : null} + {!isLoadingAllNotAssigned && assignableArtifacts && ( + + )} + + + + + {noItemsMessage} + + + + + + {labels.flyoutCancelButtonTitle} + + + + + {labels.flyoutSubmitButtonTitle(policyItem.name)} + + + + +
    + ); + } +); + +PolicyArtifactsFlyout.displayName = 'PolicyArtifactsFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/translations.ts new file mode 100644 index 000000000000..071a6f7334fb --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/translations.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +export const POLICY_ARTIFACT_FLYOUT_LABELS = Object.freeze({ + flyoutWarningCalloutTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.layout.flyout.searchWarning.title', + { + defaultMessage: 'Limited search results', + } + ), + flyoutWarningCalloutMessage: (maxNumber: number) => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.layout.flyout.searchWarning.text', + { + defaultMessage: + 'Only the first {maxNumber} artifacts are displayed. Please use the search bar to refine the results.', + values: { maxNumber }, + } + ), + flyoutNoArtifactsToBeAssignedMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.layout.flyout.noAssignable', + { + defaultMessage: 'There are no artifacts that can be assigned to this policy.', + } + ), + flyoutNoSearchResultsMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.layout.flyout.noResults', + { + defaultMessage: 'No items found', + } + ), + flyoutTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.layout.flyout.title', + { + defaultMessage: 'Assign artifacts', + } + ), + flyoutSubtitle: (policyName: string): string => + i18n.translate('xpack.securitySolution.endpoint.policy.artifacts.layout.flyout.subtitle', { + defaultMessage: 'Select artifacts to add to {policyName}', + values: { policyName }, + }), + flyoutSearchPlaceholder: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.layout.search.label', + { + defaultMessage: 'Search artifacts', + } + ), + flyoutCancelButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.layout.flyout.cancel', + { + defaultMessage: 'Cancel', + } + ), + flyoutSubmitButtonTitle: (policyName: string): string => + i18n.translate('xpack.securitySolution.endpoint.policy.artifacts.layout.flyout.confirm', { + defaultMessage: 'Assign to {policyName}', + values: { policyName }, + }), + flyoutErrorMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.layout.flyout.toastError.text', + { + defaultMessage: `An error occurred updating artifacts`, + } + ), + flyoutSuccessMessageTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.layout.flyout.toastSuccess.title', + { + defaultMessage: 'Success', + } + ), + flyoutSuccessMessageText: (updatedExceptions: ExceptionListItemSchema[]): string => + updatedExceptions.length > 1 + ? i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.layout.flyout.toastSuccess.textMultiples', + { + defaultMessage: '{count} artifacts have been added to your list.', + values: { count: updatedExceptions.length }, + } + ) + : i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.layout.flyout.toastSuccess.textSingle', + { + defaultMessage: '"{name}" has been added to your artifacts list.', + values: { name: updatedExceptions[0].name }, + } + ), +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/index.ts new file mode 100644 index 000000000000..77db9c5c4fa5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/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 { PolicyArtifactsLayout } from './policy_artifacts_layout'; +export { POLICY_ARTIFACT_LAYOUT_LABELS } from './translations'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx new file mode 100644 index 000000000000..62342b4e64fc --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx @@ -0,0 +1,191 @@ +/* + * Copyright 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 { act, waitFor } from '@testing-library/react'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../common/mock/endpoint'; +import { + getEventFiltersListPath, + getPolicyDetailsArtifactsListPath, + getPolicyEventFiltersPath, +} from '../../../../../common/routing'; +import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data'; + +import { PolicyArtifactsLayout } from './policy_artifacts_layout'; +import { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; +import { parsePoliciesAndFilterToKql } from '../../../../../common/utils'; +import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_utils'; +import { getFoundExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; +import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; +import { POLICY_ARTIFACT_EVENT_FILTERS_LABELS } from '../../tabs/event_filters_translations'; +import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { SEARCHABLE_FIELDS as EVENT_FILTERS_SEARCHABLE_FIELDS } from '../../../../event_filters/constants'; +import { FormattedMessage } from '@kbn/i18n-react'; + +let render: (externalPrivileges?: boolean) => Promise>; +let mockedContext: AppContextTestRender; +let renderResult: ReturnType; +let policyItem: ImmutableObject; +const generator = new EndpointDocGenerator(); +let mockedApi: ReturnType; +let history: AppContextTestRender['history']; + +const getEventFiltersLabels = () => ({ + ...POLICY_ARTIFACT_EVENT_FILTERS_LABELS, + layoutAboutMessage: (count: number, link: React.ReactElement): React.ReactNode => ( + + ), +}); + +describe('Policy artifacts layout', () => { + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); + mockedApi.responseProvider.eventFiltersList.mockClear(); + policyItem = generator.generatePolicyPackagePolicy(); + ({ history } = mockedContext); + + getEndpointPrivilegesInitialStateMock({ + canCreateArtifactsByPolicy: true, + }); + render = async (externalPrivileges = true) => { + await act(async () => { + renderResult = mockedContext.render( + + EventFiltersApiClient.getInstance(mockedContext.coreStart.http) + } + searchableFields={EVENT_FILTERS_SEARCHABLE_FIELDS} + getArtifactPath={getEventFiltersListPath} + getPolicyArtifactsPath={getPolicyEventFiltersPath} + externalPrivileges={externalPrivileges} + /> + ); + await waitFor(mockedApi.responseProvider.eventFiltersList); + }); + return renderResult; + }; + history.push(getPolicyEventFiltersPath(policyItem.id)); + }); + + it('should render layout with a loader', async () => { + const component = mockedContext.render( + + EventFiltersApiClient.getInstance(mockedContext.coreStart.http) + } + searchableFields={[...EVENT_FILTERS_SEARCHABLE_FIELDS]} + getArtifactPath={getEventFiltersListPath} + getPolicyArtifactsPath={getPolicyEventFiltersPath} + /> + ); + expect(component.getByTestId('policy-artifacts-loading-spinner')).toBeTruthy(); + }); + + it('should render layout with no assigned artifacts data when there are no artifacts', async () => { + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(0) + ); + + await render(); + expect(await renderResult.findByTestId('policy-artifacts-empty-unexisting')).not.toBeNull(); + }); + + it('should render layout with no assigned artifacts data when there are artifacts', async () => { + mockedApi.responseProvider.eventFiltersList.mockImplementation( + (args?: { query: { filter: string } }) => { + if ( + !args || + args.query.filter !== parsePoliciesAndFilterToKql({ policies: [policyItem.id, 'all'] }) + ) { + return getFoundExceptionListItemSchemaMock(1); + } else { + return getFoundExceptionListItemSchemaMock(0); + } + } + ); + + await render(); + + expect(await renderResult.findByTestId('policy-artifacts-empty-unassigned')).not.toBeNull(); + }); + + it('should render layout with data', async () => { + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(3) + ); + await render(); + expect(await renderResult.findByTestId('policy-artifacts-header-section')).not.toBeNull(); + expect(await renderResult.findByTestId('policy-artifacts-layout-about')).not.toBeNull(); + expect((await renderResult.findByTestId('policy-artifacts-layout-about')).textContent).toMatch( + '3 event filters' + ); + }); + + it('should hide `Assign artifacts to policy` on empty state with unassigned policies when downgraded to a gold or below license', async () => { + getEndpointPrivilegesInitialStateMock({ + canCreateArtifactsByPolicy: false, + }); + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(0) + ); + + await render(); + mockedContext.history.push(getPolicyDetailsArtifactsListPath(policyItem.id)); + expect(renderResult.queryByTestId('artifacts-assign-button')).toBeNull(); + }); + + it('should hide the `Assign artifacts to policy` button license is downgraded to gold or below', async () => { + getEndpointPrivilegesInitialStateMock({ + canCreateArtifactsByPolicy: false, + }); + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(5) + ); + + await render(); + mockedContext.history.push(getPolicyDetailsArtifactsListPath(policyItem.id)); + + expect(renderResult.queryByTestId('artifacts-assign-button')).toBeNull(); + }); + + it('should hide the `Assign artifacts` flyout when license is downgraded to gold or below', async () => { + getEndpointPrivilegesInitialStateMock({ + canCreateArtifactsByPolicy: false, + }); + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(2) + ); + + await render(); + mockedContext.history.push( + `${getPolicyDetailsArtifactsListPath(policyItem.id)}/eventFilters?show=list` + ); + + expect(renderResult.queryByTestId('artifacts-assign-flyout')).toBeNull(); + }); + + describe('Without external privileges', () => { + it('should not display the assign policies button', async () => { + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(5) + ); + await render(false); + expect(renderResult.queryByTestId('artifacts-assign-button')).toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx new file mode 100644 index 000000000000..c1f771fdcccd --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx @@ -0,0 +1,247 @@ +/* + * Copyright 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, { useMemo, useCallback, useState } from 'react'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { + EuiTitle, + EuiPageHeader, + EuiPageHeaderSection, + EuiText, + EuiSpacer, + EuiLink, + EuiButton, + EuiPageContent, +} from '@elastic/eui'; +import { useAppUrl } from '../../../../../../common/lib/kibana'; +import { APP_UI_ID } from '../../../../../../../common/constants'; +import { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; +import { ManagementPageLoader } from '../../../../../components/management_page_loader'; +import { useUrlParams } from '../../../../../components/hooks/use_url_params'; +import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; +import { usePolicyDetailsArtifactsNavigateCallback } from '../../policy_hooks'; +import { ExceptionsListApiClient } from '../../../../../services/exceptions_list/exceptions_list_api_client'; +import { useListArtifact } from '../../../../../hooks/artifacts'; +import { PolicyArtifactsEmptyUnassigned, PolicyArtifactsEmptyUnexisting } from '../empty'; +import { PolicyArtifactsList } from '../list'; +import { PolicyArtifactsFlyout } from '../flyout'; +import { PolicyArtifactsPageLabels, policyArtifactsPageLabels } from '../translations'; +import { PolicyArtifactsDeleteModal } from '../delete_modal'; +import { EventFiltersPageLocation } from '../../../../event_filters/types'; +import { HostIsolationExceptionsPageLocation } from '../../../../host_isolation_exceptions/types'; +import { TrustedAppsListPageLocation } from '../../../../trusted_apps/state'; + +interface PolicyArtifactsLayoutProps { + policyItem?: ImmutableObject | undefined; + /** A list of labels for the given policy artifact page. Not all have to be defined, only those that should override the defaults */ + labels: PolicyArtifactsPageLabels; + getExceptionsListApiClient: () => ExceptionsListApiClient; + searchableFields: readonly string[]; + getArtifactPath: ( + location?: + | Partial + | Partial + | Partial + ) => string; + getPolicyArtifactsPath: (policyId: string) => string; + /** A boolean to check extra privileges for restricted actions, true when it's allowed, false when not */ + externalPrivileges?: boolean; +} +export const PolicyArtifactsLayout = React.memo( + ({ + policyItem, + labels: _labels = {}, + getExceptionsListApiClient, + searchableFields, + getArtifactPath, + getPolicyArtifactsPath, + externalPrivileges = true, + }) => { + const exceptionsListApiClient = useMemo( + () => getExceptionsListApiClient(), + [getExceptionsListApiClient] + ); + const { getAppUrl } = useAppUrl(); + const navigateCallback = usePolicyDetailsArtifactsNavigateCallback( + exceptionsListApiClient.listId + ); + const { canCreateArtifactsByPolicy } = useUserPrivileges().endpointPrivileges; + const { urlParams } = useUrlParams(); + const [exceptionItemToDelete, setExceptionItemToDelete] = useState< + ExceptionListItemSchema | undefined + >(); + + const labels = useMemo(() => { + return { + ...policyArtifactsPageLabels, + ..._labels, + }; + }, [_labels]); + + const { data: allAssigned, isLoading: isLoadingAllAssigned } = useListArtifact( + exceptionsListApiClient, + { + policies: policyItem ? [policyItem.id, 'all'] : [], + }, + searchableFields + ); + + const { + data: allArtifacts, + isLoading: isLoadingAllArtifacts, + isRefetching: isRefetchingAllArtifacts, + } = useListArtifact(exceptionsListApiClient, {}, searchableFields, {}, ['allExisting']); + + const handleOnClickAssignButton = useCallback(() => { + navigateCallback({ show: 'list' }); + }, [navigateCallback]); + const handleOnCloseFlyout = useCallback(() => { + navigateCallback({ show: undefined }); + }, [navigateCallback]); + + const handleDeleteModalClose = useCallback(() => { + setExceptionItemToDelete(undefined); + }, [setExceptionItemToDelete]); + + const handleOnDeleteActionCallback = useCallback( + (item) => { + setExceptionItemToDelete(item); + }, + [setExceptionItemToDelete] + ); + + const assignToPolicyButton = useMemo( + () => ( + + {labels.layoutAssignButtonTitle} + + ), + [handleOnClickAssignButton, labels.layoutAssignButtonTitle] + ); + + const aboutInfo = useMemo(() => { + const link = ( + + {labels.layoutViewAllLinkMessage} + + ); + + return labels.layoutAboutMessage(allAssigned?.total || 0, link); + }, [getAppUrl, getArtifactPath, labels, allAssigned?.total]); + + const isGlobalLoading = useMemo( + () => isLoadingAllAssigned || isLoadingAllArtifacts || isRefetchingAllArtifacts, + [isLoadingAllAssigned, isLoadingAllArtifacts, isRefetchingAllArtifacts] + ); + + const isEmptyState = useMemo(() => allAssigned && allAssigned.total === 0, [allAssigned]); + + if (!policyItem || isGlobalLoading) { + return ; + } + + if (isEmptyState) { + return ( + <> + {canCreateArtifactsByPolicy && urlParams.show === 'list' && ( + + )} + {allArtifacts && allArtifacts.total !== 0 ? ( + + ) : ( + + )} + + ); + } + + return ( +
    + + + +

    {labels.layoutTitle}

    +
    + + + + +

    {aboutInfo}

    +
    +
    + + {canCreateArtifactsByPolicy && externalPrivileges && assignToPolicyButton} + +
    + {canCreateArtifactsByPolicy && externalPrivileges && urlParams.show === 'list' && ( + + )} + {exceptionItemToDelete && ( + + )} + + + + +
    + ); + } +); + +PolicyArtifactsLayout.displayName = 'PolicyArtifactsLayout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/translations.ts new file mode 100644 index 000000000000..82ffb8b16ca7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/translations.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +export const POLICY_ARTIFACT_LAYOUT_LABELS = Object.freeze({ + layoutTitle: i18n.translate('xpack.securitySolution.endpoint.policy.artifacts.layout.title', { + defaultMessage: 'Assigned artifacts', + }), + layoutAssignButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.layout.assignToPolicy', + { + defaultMessage: 'Assign artifact to policy', + } + ), + layoutViewAllLinkMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.layout.about.viewAllLinkLabel', + { + defaultMessage: 'view all artifacts', + } + ), + layoutAboutMessage: (count: number, _: React.ReactElement): React.ReactNode => { + return i18n.translate('xpack.securitySolution.endpoint.policy.artifacts.layout.about', { + defaultMessage: + 'There {count, plural, one {is} other {are}} {count} {count, plural, =1 {artifact} other {artifacts}} associated with this policy. Click here to view all artifacts', + values: { count }, + }); + }, +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/index.ts new file mode 100644 index 000000000000..260b580de749 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/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 { PolicyArtifactsList } from './policy_artifacts_list'; +export { POLICY_ARTIFACT_LIST_LABELS } from './translations'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx similarity index 64% rename from x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.test.tsx rename to x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx index e8425a57b401..9ce3d56dac47 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx @@ -15,12 +15,14 @@ import { } from '../../../../../../common/mock/endpoint'; import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data'; import { PolicyData } from '../../../../../../../common/endpoint/types'; -import { getPolicyEventFiltersPath } from '../../../../../common/routing'; +import { getEventFiltersListPath, getPolicyEventFiltersPath } from '../../../../../common/routing'; import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_utils'; -import { PolicyEventFiltersList } from './policy_event_filters_list'; +import { PolicyArtifactsList } from './policy_artifacts_list'; import { parseQueryFilterToKQL, parsePoliciesAndFilterToKql } from '../../../../../common/utils'; import { SEARCHABLE_FIELDS } from '../../../../event_filters/constants'; import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; +import { POLICY_ARTIFACT_LIST_LABELS } from './translations'; +import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; const endpointGenerator = new EndpointDocGenerator('seed'); const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ @@ -36,22 +38,37 @@ const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ }, }); -describe('Policy details event filters list', () => { - let render: () => Promise>; +describe('Policy details artifacts list', () => { + let render: (externalPrivileges?: boolean) => Promise>; let renderResult: ReturnType; let history: AppContextTestRender['history']; let mockedContext: AppContextTestRender; let mockedApi: ReturnType; let policy: PolicyData; - + let handleOnDeleteActionCallbackMock: jest.Mock; beforeEach(() => { policy = endpointGenerator.generatePolicyPackagePolicy(); mockedContext = createAppRootMockRenderer(); mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); ({ history } = mockedContext); - render = async () => { + handleOnDeleteActionCallbackMock = jest.fn(); + getEndpointPrivilegesInitialStateMock({ + canCreateArtifactsByPolicy: true, + }); + render = async (externalPrivileges = true) => { await act(async () => { - renderResult = mockedContext.render(); + renderResult = mockedContext.render( + + ); await waitFor(mockedApi.responseProvider.eventFiltersList); }); return renderResult; @@ -65,8 +82,8 @@ describe('Policy details event filters list', () => { getFoundExceptionListItemSchemaMock(0) ); await render(); - expect(renderResult.getByTestId('policyDetailsEventFiltersSearchCount')).toHaveTextContent( - 'Showing 0 event filters' + expect(renderResult.getByTestId('policyDetailsArtifactsSearchCount')).toHaveTextContent( + 'Showing 0 artifacts' ); expect(renderResult.getByTestId('searchField')).toBeTruthy(); }); @@ -76,22 +93,22 @@ describe('Policy details event filters list', () => { getFoundExceptionListItemSchemaMock(3) ); await render(); - expect(renderResult.getAllByTestId('eventFilters-collapsed-list-card')).toHaveLength(3); + expect(renderResult.getAllByTestId('artifacts-collapsed-list-card')).toHaveLength(3); expect( - renderResult.queryAllByTestId('eventFilters-collapsed-list-card-criteriaConditions') + renderResult.queryAllByTestId('artifacts-collapsed-list-card-criteriaConditions') ).toHaveLength(0); }); it('should expand an item when expand is clicked', async () => { await render(); - expect(renderResult.getAllByTestId('eventFilters-collapsed-list-card')).toHaveLength(1); + expect(renderResult.getAllByTestId('artifacts-collapsed-list-card')).toHaveLength(1); userEvent.click( - renderResult.getByTestId('eventFilters-collapsed-list-card-header-expandCollapse') + renderResult.getByTestId('artifacts-collapsed-list-card-header-expandCollapse') ); expect( - renderResult.queryAllByTestId('eventFilters-collapsed-list-card-criteriaConditions') + renderResult.queryAllByTestId('artifacts-collapsed-list-card-criteriaConditions') ).toHaveLength(1); }); @@ -130,7 +147,7 @@ describe('Policy details event filters list', () => { await render(); // click the actions button userEvent.click( - renderResult.getByTestId('eventFilters-collapsed-list-card-header-actions-button') + renderResult.getByTestId('artifacts-collapsed-list-card-header-actions-button') ); expect(renderResult.queryByTestId('view-full-details-action')).toBeTruthy(); }); @@ -144,9 +161,24 @@ describe('Policy details event filters list', () => { ); await render(); userEvent.click( - renderResult.getByTestId('eventFilters-collapsed-list-card-header-actions-button') + renderResult.getByTestId('artifacts-collapsed-list-card-header-actions-button') ); expect(renderResult.queryByTestId('remove-from-policy-action')).toBeNull(); }); + + describe('without external privileges', () => { + it('should not display the delete action, do show the full details', async () => { + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(1) + ); + await render(false); + // click the actions button + userEvent.click( + await renderResult.findByTestId('artifacts-collapsed-list-card-header-actions-button') + ); + expect(renderResult.queryByTestId('remove-from-policy-action')).toBeFalsy(); + expect(renderResult.queryByTestId('view-full-details-action')).toBeTruthy(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.tsx new file mode 100644 index 000000000000..4c87ba883f39 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.tsx @@ -0,0 +1,207 @@ +/* + * Copyright 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, { useCallback, useMemo, useState } from 'react'; +import { EuiSpacer, EuiText, Pagination } from '@elastic/eui'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { useAppUrl } from '../../../../../../common/lib/kibana'; +import { APP_UI_ID } from '../../../../../../../common/constants'; +import { SearchExceptions } from '../../../../../components/search_exceptions'; +import { useEndpointPoliciesToArtifactPolicies } from '../../../../../components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies'; +import { useUrlParams } from '../../../../../components/hooks/use_url_params'; +import { useUrlPagination } from '../../../../../components/hooks/use_url_pagination'; +import { useGetEndpointSpecificPolicies } from '../../../../../services/policies/hooks'; +import { + ArtifactCardGrid, + ArtifactCardGridProps, +} from '../../../../../components/artifact_card_grid'; +import { usePolicyDetailsArtifactsNavigateCallback } from '../../policy_hooks'; +import { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; +import { isGlobalPolicyEffected } from '../../../../../components/effected_policy_select/utils'; +import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; +import { useGetLinkTo } from '../empty/use_policy_artifacts_empty_hooks'; +import { ExceptionsListApiClient } from '../../../../../services/exceptions_list/exceptions_list_api_client'; +import { useListArtifact } from '../../../../../hooks/artifacts'; +import { POLICY_ARTIFACT_LIST_LABELS } from './translations'; +import { EventFiltersPageLocation } from '../../../../event_filters/types'; +import { TrustedAppsListPageLocation } from '../../../../trusted_apps/state'; +import { HostIsolationExceptionsPageLocation } from '../../../../host_isolation_exceptions/types'; + +interface PolicyArtifactsListProps { + policy: ImmutableObject; + apiClient: ExceptionsListApiClient; + searchableFields: string[]; + getArtifactPath: ( + location?: + | Partial + | Partial + | Partial + ) => string; + getPolicyArtifactsPath: (policyId: string) => string; + labels: typeof POLICY_ARTIFACT_LIST_LABELS; + onDeleteActionCallback: (item: ExceptionListItemSchema) => void; + externalPrivileges?: boolean; +} + +export const PolicyArtifactsList = React.memo( + ({ + policy, + apiClient, + searchableFields, + getArtifactPath, + getPolicyArtifactsPath, + labels, + onDeleteActionCallback, + externalPrivileges = true, + }) => { + const { getAppUrl } = useAppUrl(); + const { canCreateArtifactsByPolicy } = useUserPrivileges().endpointPrivileges; + const policiesRequest = useGetEndpointSpecificPolicies({ perPage: 1000 }); + const navigateCallback = usePolicyDetailsArtifactsNavigateCallback(apiClient.listId); + const { urlParams } = useUrlParams(); + const [expandedItemsMap, setExpandedItemsMap] = useState>(new Map()); + + const { state } = useGetLinkTo(policy.id, policy.name, getPolicyArtifactsPath, getArtifactPath); + + const { pageSizeOptions, pagination: urlPagination, setPagination } = useUrlPagination(); + + const { + data: artifacts, + isLoading: isLoadingArtifacts, + isRefetching: isRefetchingArtifacts, + } = useListArtifact( + apiClient, + { + page: urlPagination.page, + perPage: urlPagination.pageSize, + filter: urlParams.filter as string, + policies: [policy.id, 'all'], + }, + searchableFields + ); + + const pagination: Pagination = useMemo( + () => ({ + pageSize: urlPagination.pageSize, + pageIndex: urlPagination.page - 1, + pageSizeOptions, + totalItemCount: artifacts?.total || 0, + }), + [artifacts?.total, pageSizeOptions, urlPagination.page, urlPagination.pageSize] + ); + + const handleOnSearch = useCallback( + (filter) => { + navigateCallback({ filter }); + }, + [navigateCallback] + ); + + const handleOnExpandCollapse = useCallback( + ({ expanded, collapsed }) => { + const newExpandedMap = new Map(expandedItemsMap); + for (const item of expanded) { + newExpandedMap.set(item.id, true); + } + for (const item of collapsed) { + newExpandedMap.set(item.id, false); + } + setExpandedItemsMap(newExpandedMap); + }, + [expandedItemsMap] + ); + const handleOnPageChange = useCallback( + ({ pageIndex, pageSize }) => { + if (artifacts?.total) setPagination({ page: pageIndex + 1, pageSize }); + }, + [artifacts?.total, setPagination] + ); + + const totalItemsCountLabel = useMemo(() => { + return labels.listTotalItemCountMessage(artifacts?.data.length || 0); + }, [artifacts?.data.length, labels]); + + const artifactCardPolicies = useEndpointPoliciesToArtifactPolicies(policiesRequest.data?.items); + const provideCardProps = useCallback( + (artifact) => { + const viewUrlPath = getArtifactPath({ + filter: (artifact as ExceptionListItemSchema).item_id, + }); + const fullDetailsAction = { + icon: 'controlsHorizontal', + children: labels.listFullDetailsActionTitle, + href: getAppUrl({ appId: APP_UI_ID, path: viewUrlPath }), + navigateAppId: APP_UI_ID, + navigateOptions: { path: viewUrlPath, state }, + 'data-test-subj': 'view-full-details-action', + }; + const item = artifact as ExceptionListItemSchema; + + const isGlobal = isGlobalPolicyEffected(item.tags); + const deleteAction = { + icon: 'trash', + children: labels.listRemoveActionTitle, + onClick: () => { + onDeleteActionCallback(item); + }, + disabled: isGlobal, + toolTipContent: isGlobal ? labels.listRemoveActionNotAllowedMessage : undefined, + toolTipPosition: 'top' as const, + 'data-test-subj': 'remove-from-policy-action', + }; + return { + expanded: expandedItemsMap.get(item.id) || false, + actions: + canCreateArtifactsByPolicy && externalPrivileges + ? [fullDetailsAction, deleteAction] + : [fullDetailsAction], + policies: artifactCardPolicies, + }; + }, + [ + artifactCardPolicies, + canCreateArtifactsByPolicy, + expandedItemsMap, + externalPrivileges, + getAppUrl, + getArtifactPath, + labels.listFullDetailsActionTitle, + labels.listRemoveActionNotAllowedMessage, + labels.listRemoveActionTitle, + onDeleteActionCallback, + state, + ] + ); + + return ( + <> + + + + {totalItemsCountLabel} + + + + + ); + } +); + +PolicyArtifactsList.displayName = 'PolicyArtifactsList'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/translations.ts new file mode 100644 index 000000000000..5d9cd3879d37 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/translations.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 { i18n } from '@kbn/i18n'; + +export const POLICY_ARTIFACT_LIST_LABELS = Object.freeze({ + listTotalItemCountMessage: (totalItemsCount: number): string => + i18n.translate('xpack.securitySolution.endpoint.policy.artifacts.list.totalItemCount', { + defaultMessage: 'Showing {totalItemsCount, plural, one {# artifact} other {# artifacts}}', + values: { totalItemsCount }, + }), + listFullDetailsActionTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.list.fullDetailsAction', + { defaultMessage: 'View full details' } + ), + listRemoveActionTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.list.removeAction', + { defaultMessage: 'Remove from policy' } + ), + listRemoveActionNotAllowedMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.list.removeActionNotAllowed', + { + defaultMessage: 'Globally applied artifact cannot be removed from policy.', + } + ), + listSearchPlaceholderMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.list.search.placeholder', + { + defaultMessage: `Search on the fields below: name, description, value`, + } + ), +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/translations.ts new file mode 100644 index 000000000000..84331c6cec95 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/translations.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { POLICY_ARTIFACT_DELETE_MODAL_LABELS } from './delete_modal'; +import { + POLICY_ARTIFACT_EMPTY_UNASSIGNED_LABELS, + POLICY_ARTIFACT_EMPTY_UNEXISTING_LABELS, +} from './empty'; +import { POLICY_ARTIFACT_FLYOUT_LABELS } from './flyout'; +import { POLICY_ARTIFACT_LAYOUT_LABELS } from './layout'; +import { POLICY_ARTIFACT_LIST_LABELS } from './list'; + +export const policyArtifactsPageLabels = Object.freeze({ + // ------------------------------ + // POLICY ARTIFACT LAYOUT + // ------------------------------ + ...POLICY_ARTIFACT_LAYOUT_LABELS, + + // ------------------------------ + // POLICY ARTIFACT DELETE MODAL + // ------------------------------ + ...POLICY_ARTIFACT_DELETE_MODAL_LABELS, + + // ------------------------------ + // POLICY ARTIFACT FLYOUT + // ------------------------------ + ...POLICY_ARTIFACT_FLYOUT_LABELS, + + // ------------------------------ + // POLICY ARTIFACT EMPTY UNASSIGNED + // ------------------------------ + ...POLICY_ARTIFACT_EMPTY_UNASSIGNED_LABELS, + + // ------------------------------ + // POLICY ARTIFACT EMPTY UNEXISTING + // ------------------------------ + ...POLICY_ARTIFACT_EMPTY_UNEXISTING_LABELS, + + // ------------------------------ + // POLICY ARTIFACT LIST + // ------------------------------ + ...POLICY_ARTIFACT_LIST_LABELS, +}); + +type IAllLabels = typeof policyArtifactsPageLabels; + +/** + * The set of labels that normally have the policy artifact specific name in it, thus must be set for every page + */ +export type PolicyArtifactsPageRequiredLabels = Pick< + IAllLabels, + | 'deleteModalTitle' + | 'deleteModalImpactInfo' + | 'deleteModalErrorMessage' + | 'flyoutWarningCalloutMessage' + | 'flyoutNoArtifactsToBeAssignedMessage' + | 'flyoutTitle' + | 'flyoutSubtitle' + | 'flyoutSearchPlaceholder' + | 'flyoutErrorMessage' + | 'flyoutSuccessMessageText' + | 'emptyUnassignedTitle' + | 'emptyUnassignedMessage' + | 'emptyUnassignedPrimaryActionButtonTitle' + | 'emptyUnassignedSecondaryActionButtonTitle' + | 'emptyUnexistingTitle' + | 'emptyUnexistingMessage' + | 'emptyUnexistingPrimaryActionButtonTitle' + | 'listTotalItemCountMessage' + | 'listRemoveActionNotAllowedMessage' + | 'listSearchPlaceholderMessage' + | 'layoutTitle' + | 'layoutAssignButtonTitle' + | 'layoutViewAllLinkMessage' + | 'layoutAboutMessage' +>; + +export type PolicyArtifactPageOptionalLabels = Omit< + IAllLabels, + keyof PolicyArtifactsPageRequiredLabels +>; + +export type PolicyArtifactsPageLabels = PolicyArtifactsPageRequiredLabels & + Partial; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx index 45aad6c3d143..c02969993e62 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx @@ -10,7 +10,7 @@ import { useDispatch } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiSwitch, EuiText } from '@elastic/eui'; -import { OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { isAntivirusRegistrationEnabled } from '../../../store/policy_details/selectors'; import { usePolicyDetailsSelector } from '../../policy_hooks'; import { ConfigForm } from '../config_form'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.stories.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.stories.tsx index 89fe46445b20..79e32cf2e367 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.stories.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.stories.tsx @@ -11,7 +11,7 @@ import { addDecorator, storiesOf } from '@storybook/react'; import { euiLightVars } from '@kbn/ui-theme'; import { EuiCheckbox, EuiSpacer, EuiSwitch, EuiText } from '@elastic/eui'; -import { OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { ConfigForm } from '.'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx index 9d753749dabe..6a5f7d187478 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx @@ -21,7 +21,7 @@ import { } from '@elastic/eui'; import { ThemeContext } from 'styled-components'; -import { OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { OS_TITLES } from '../../../../../common/translations'; const TITLES = { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/events_form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/events_form/index.tsx index 4364d9214124..a3f4b2fdc7fb 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/events_form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/events_form/index.tsx @@ -8,11 +8,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiCheckbox, EuiSpacer, EuiText, htmlIdGenerator } from '@elastic/eui'; -import { - OperatingSystem, - PolicyOperatingSystem, - UIPolicyConfig, -} from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { PolicyOperatingSystem, UIPolicyConfig } from '../../../../../../../common/endpoint/types'; import { ConfigForm, ConfigFormHeading } from '../../components/config_form'; const OPERATING_SYSTEM_TO_TEST_SUBJ: { [K in OperatingSystem]: string } = { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.tsx deleted file mode 100644 index bfa2f09ab977..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiCallOut, EuiConfirmModal, EuiSpacer, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import React, { useCallback } from 'react'; -import { useToasts } from '../../../../../../common/lib/kibana'; -import { ServerApiError } from '../../../../../../common/types'; -import { useBulkUpdateEventFilters } from '../hooks'; - -export const PolicyEventFiltersDeleteModal = ({ - policyId, - policyName, - exception, - onCancel, -}: { - policyId: string; - policyName: string; - exception: ExceptionListItemSchema; - onCancel: () => void; -}) => { - const toasts = useToasts(); - - const { mutate: updateEventFilter, isLoading: isUpdateEventFilterLoading } = - useBulkUpdateEventFilters({ - onUpdateSuccess: () => { - toasts.addSuccess({ - title: i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeDialog.successToastTitle', - { defaultMessage: 'Successfully removed' } - ), - text: i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeDialog.successToastText', - { - defaultMessage: '"{eventFilterName}" has been removed from {policyName} policy', - values: { eventFilterName: exception.name, policyName }, - } - ), - }); - onCancel(); - }, - onUpdateError: (error?: ServerApiError) => { - toasts.addError(error as unknown as Error, { - title: i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeDialog.errorToastTitle', - { - defaultMessage: 'Error while attempt to remove event filter', - } - ), - }); - onCancel(); - }, - onSettledCallback: onCancel, - }); - - const handleModalConfirm = useCallback(() => { - const modifiedException = { - ...exception, - tags: exception.tags.filter((tag) => tag !== `policy:${policyId}`), - }; - updateEventFilter([modifiedException]); - }, [exception, policyId, updateEventFilter]); - - const handleCancel = useCallback(() => { - if (!isUpdateEventFilterLoading) { - onCancel(); - } - }, [isUpdateEventFilterLoading, onCancel]); - - return ( - - -

    - -

    -
    - - - - -

    - -

    -
    -
    - ); -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/empty/policy_event_filters_empty_unassigned.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/empty/policy_event_filters_empty_unassigned.tsx deleted file mode 100644 index ac944371acdd..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/empty/policy_event_filters_empty_unassigned.tsx +++ /dev/null @@ -1,81 +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, { memo, useCallback } from 'react'; -import { EuiButton, EuiEmptyPrompt, EuiPageTemplate, EuiLink } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { usePolicyDetailsEventFiltersNavigateCallback } from '../../policy_hooks'; -import { useGetLinkTo } from './use_policy_event_filters_empty_hooks'; -import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; - -interface CommonProps { - policyId: string; - policyName: string; -} - -export const PolicyEventFiltersEmptyUnassigned = memo(({ policyId, policyName }) => { - const { canCreateArtifactsByPolicy } = useUserPrivileges().endpointPrivileges; - const { onClickHandler, toRouteUrl } = useGetLinkTo(policyId, policyName); - - const navigateCallback = usePolicyDetailsEventFiltersNavigateCallback(); - const onClickPrimaryButtonHandler = useCallback( - () => - navigateCallback({ - show: 'list', - }), - [navigateCallback] - ); - return ( - - - - - } - body={ - - } - actions={[ - ...(canCreateArtifactsByPolicy - ? [ - - - , - ] - : []), - // eslint-disable-next-line @elastic/eui/href-or-on-click - - - , - ]} - /> - - ); -}); - -PolicyEventFiltersEmptyUnassigned.displayName = 'PolicyEventFiltersEmptyUnassigned'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/empty/policy_event_filters_empty_unexisting.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/empty/policy_event_filters_empty_unexisting.tsx deleted file mode 100644 index 7976fc8a566d..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/empty/policy_event_filters_empty_unexisting.tsx +++ /dev/null @@ -1,53 +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, { memo } from 'react'; -import { EuiEmptyPrompt, EuiButton, EuiPageTemplate } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { useGetLinkTo } from './use_policy_event_filters_empty_hooks'; - -interface CommonProps { - policyId: string; - policyName: string; -} - -export const PolicyEventFiltersEmptyUnexisting = memo(({ policyId, policyName }) => { - const { onClickHandler, toRouteUrl } = useGetLinkTo(policyId, policyName, { show: 'create' }); - return ( - - - - - } - body={ - - } - actions={ - // eslint-disable-next-line @elastic/eui/href-or-on-click - - - - } - /> - - ); -}); - -PolicyEventFiltersEmptyUnexisting.displayName = 'PolicyEventFiltersEmptyUnexisting'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/empty/use_policy_event_filters_empty_hooks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/empty/use_policy_event_filters_empty_hooks.ts deleted file mode 100644 index 0a7aa8fbabf6..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/empty/use_policy_event_filters_empty_hooks.ts +++ /dev/null @@ -1,65 +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 { useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { useNavigateToAppEventHandler } from '../../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; -import { useAppUrl } from '../../../../../../common/lib/kibana/hooks'; -import { getPolicyEventFiltersPath, getEventFiltersListPath } from '../../../../../common/routing'; -import { APP_UI_ID } from '../../../../../../../common/constants'; -import { EventFiltersPageLocation } from '../../../../event_filters/types'; - -export const useGetLinkTo = ( - policyId: string, - policyName: string, - location?: Partial -) => { - const { getAppUrl } = useAppUrl(); - const { toRoutePath, toRouteUrl } = useMemo(() => { - const path = getEventFiltersListPath(location); - return { - toRoutePath: path, - toRouteUrl: getAppUrl({ path }), - }; - }, [getAppUrl, location]); - - const policyEventFiltersPath = useMemo(() => getPolicyEventFiltersPath(policyId), [policyId]); - const policyEventFilterRouteState = useMemo(() => { - return { - backButtonLabel: i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.empty.unassigned.backButtonLabel', - { - defaultMessage: 'Back to {policyName} policy', - values: { - policyName, - }, - } - ), - onBackButtonNavigateTo: [ - APP_UI_ID, - { - path: policyEventFiltersPath, - }, - ], - backButtonUrl: getAppUrl({ - appId: APP_UI_ID, - path: policyEventFiltersPath, - }), - }; - }, [getAppUrl, policyName, policyEventFiltersPath]); - - const onClickHandler = useNavigateToAppEventHandler(APP_UI_ID, { - state: policyEventFilterRouteState, - path: toRoutePath, - }); - - return { - onClickHandler, - toRouteUrl, - state: policyEventFilterRouteState, - }; -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.tsx deleted file mode 100644 index 5c97b914bb1c..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.tsx +++ /dev/null @@ -1,281 +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, { useCallback, useMemo, useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { isEmpty, without } from 'lodash/fp'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { - EuiTitle, - EuiFlyout, - EuiSpacer, - EuiFlyoutHeader, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiButton, - EuiCallOut, - EuiEmptyPrompt, -} from '@elastic/eui'; -import { SearchExceptions } from '../../../../../components/search_exceptions'; -import { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; -import { useToasts } from '../../../../../../common/lib/kibana'; -import { useSearchNotAssignedEventFilters, useBulkUpdateEventFilters } from '../hooks'; -import { PolicyArtifactsAssignableList } from '../../artifacts/assignable'; - -interface PolicyEventFiltersFlyoutProps { - policyItem: ImmutableObject; - onClose: () => void; -} - -const MAX_ALLOWED_RESULTS = 100; - -export const PolicyEventFiltersFlyout = React.memo( - ({ policyItem, onClose }) => { - const toasts = useToasts(); - const [selectedArtifactIds, setSelectedArtifactIds] = useState([]); - const [currentFilter, setCurrentFilter] = useState(''); - - const bulkUpdateMutation = useBulkUpdateEventFilters({ - onUpdateSuccess: (updatedExceptions: ExceptionListItemSchema[]) => { - toasts.addSuccess({ - title: i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.layout.flyout.toastSuccess.title', - { - defaultMessage: 'Success', - } - ), - text: - updatedExceptions.length > 1 - ? i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.layout.flyout.toastSuccess.textMultiples', - { - defaultMessage: '{count} event filters have been added to your list.', - values: { count: updatedExceptions.length }, - } - ) - : i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.layout.flyout.toastSuccess.textSingle', - { - defaultMessage: '"{name}" has been added to your event filters list.', - values: { name: updatedExceptions[0].name }, - } - ), - }); - }, - onUpdateError: () => { - toasts.addDanger( - i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.layout.flyout.toastError.text', - { - defaultMessage: `An error occurred updating artifacts`, - } - ) - ); - }, - onSettledCallback: onClose, - }); - - const { - data: eventFilters, - isLoading: isLoadingEventFilters, - isRefetching: isRefetchingEventFilters, - } = useSearchNotAssignedEventFilters(policyItem.id, { - perPage: MAX_ALLOWED_RESULTS, - filter: currentFilter, - }); - - const { data: allNotAssigned, isLoading: isLoadingAllNotAssigned } = - useSearchNotAssignedEventFilters(policyItem.id, { - enabled: currentFilter !== '' && eventFilters?.total === 0, - }); - - const handleOnSearch = useCallback((query) => { - setSelectedArtifactIds([]); - setCurrentFilter(query); - }, []); - - const handleOnConfirmAction = useCallback(() => { - if (!eventFilters) { - return; - } - const eventFiltersToUpdate: ExceptionListItemSchema[] = []; - selectedArtifactIds.forEach((selectedId) => { - const eventFilter = eventFilters.data.find((current) => current.id === selectedId); - if (eventFilter) { - eventFilter.tags = [...eventFilter.tags, `policy:${policyItem.id}`]; - eventFiltersToUpdate.push(eventFilter); - } - }); - bulkUpdateMutation.mutate(eventFiltersToUpdate); - }, [bulkUpdateMutation, eventFilters, policyItem.id, selectedArtifactIds]); - - const handleSelectArtifacts = (artifactId: string, selected: boolean) => { - setSelectedArtifactIds((currentSelectedArtifactIds) => - selected - ? [...currentSelectedArtifactIds, artifactId] - : without([artifactId], currentSelectedArtifactIds) - ); - }; - - const searchWarningMessage = useMemo( - () => ( - <> - - {i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.layout.flyout.searchWarning.text', - { - defaultMessage: - 'Only the first 100 event filters are displayed. Please use the search bar to refine the results.', - } - )} - - - - ), - [] - ); - - const noItemsMessage = useMemo(() => { - if (isLoadingEventFilters || isRefetchingEventFilters || isLoadingAllNotAssigned) { - return null; - } - - // there are no event filters assignable to this policy - if (allNotAssigned?.total === 0 || (eventFilters?.total === 0 && currentFilter === '')) { - return ( - - } - /> - ); - } - - // there are no results for the current search - if (eventFilters?.total === 0) { - return ( - - } - /> - ); - } - }, [ - allNotAssigned?.total, - currentFilter, - eventFilters?.total, - isLoadingAllNotAssigned, - isLoadingEventFilters, - isRefetchingEventFilters, - ]); - - return ( - - - -

    - -

    -
    - - -
    - - {(eventFilters?.total || 0) > MAX_ALLOWED_RESULTS ? searchWarningMessage : null} - - - - - - {noItemsMessage} - - - - - - - - - - - - - - - -
    - ); - } -); - -PolicyEventFiltersFlyout.displayName = 'PolicyEventFiltersFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/hooks.ts deleted file mode 100644 index 24e3cb464d4d..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/hooks.ts +++ /dev/null @@ -1,161 +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 pMap from 'p-map'; -import { - ExceptionListItemSchema, - FoundExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { QueryObserverResult, useMutation, useQuery, useQueryClient } from 'react-query'; -import { ServerApiError } from '../../../../../common/types'; -import { useHttp } from '../../../../../common/lib/kibana'; -import { getList, updateOne } from '../../../event_filters/service/service_actions'; -import { parseQueryFilterToKQL, parsePoliciesAndFilterToKql } from '../../../../common/utils'; -import { SEARCHABLE_FIELDS } from '../../../event_filters/constants'; - -export function useGetAllAssignedEventFilters( - policyId: string, - enabled: boolean = true -): QueryObserverResult { - const http = useHttp(); - return useQuery( - ['eventFilters', 'assigned', policyId], - () => { - return getList({ - http, - filter: parsePoliciesAndFilterToKql({ policies: [...(policyId ? [policyId] : []), 'all'] }), - }); - }, - { - refetchIntervalInBackground: false, - refetchOnWindowFocus: false, - refetchOnMount: true, - enabled, - keepPreviousData: true, - } - ); -} - -export function useSearchAssignedEventFilters( - policyId: string, - options: { filter?: string; page?: number; perPage?: number } -): QueryObserverResult { - const http = useHttp(); - const { filter, page, perPage } = options; - - return useQuery( - ['eventFilters', 'assigned', 'search', policyId, options], - () => { - return getList({ - http, - filter: parsePoliciesAndFilterToKql({ - policies: [policyId, 'all'], - kuery: parseQueryFilterToKQL(filter || '', SEARCHABLE_FIELDS), - }), - perPage, - page: (page ?? 0) + 1, - }); - }, - { - refetchIntervalInBackground: false, - refetchOnWindowFocus: false, - refetchOnMount: true, - keepPreviousData: true, - } - ); -} -export function useSearchNotAssignedEventFilters( - policyId: string, - options: { filter?: string; perPage?: number; enabled?: boolean } -): QueryObserverResult { - const http = useHttp(); - return useQuery( - ['eventFilters', 'notAssigned', policyId, options], - () => { - const { filter, perPage } = options; - - return getList({ - http, - filter: parsePoliciesAndFilterToKql({ - excludedPolicies: [policyId, 'all'], - kuery: parseQueryFilterToKQL(filter || '', SEARCHABLE_FIELDS), - }), - perPage, - }); - }, - { - refetchIntervalInBackground: false, - refetchOnWindowFocus: false, - refetchOnMount: true, - keepPreviousData: true, - enabled: options.enabled ?? true, - } - ); -} - -export function useBulkUpdateEventFilters( - callbacks: { - onUpdateSuccess?: (updatedExceptions: ExceptionListItemSchema[]) => void; - onUpdateError?: (error?: ServerApiError) => void; - onSettledCallback?: () => void; - } = {} -) { - const http = useHttp(); - const queryClient = useQueryClient(); - - const { - onUpdateSuccess = () => {}, - onUpdateError = () => {}, - onSettledCallback = () => {}, - } = callbacks; - - return useMutation< - ExceptionListItemSchema[], - ServerApiError, - ExceptionListItemSchema[], - () => void - >( - (eventFilters: ExceptionListItemSchema[]) => { - return pMap( - eventFilters, - (eventFilter) => { - return updateOne(http, eventFilter); - }, - { - concurrency: 5, - } - ); - }, - { - onSuccess: onUpdateSuccess, - onError: onUpdateError, - onSettled: () => { - queryClient.invalidateQueries(['eventFilters', 'notAssigned']); - queryClient.invalidateQueries(['eventFilters', 'assigned']); - onSettledCallback(); - }, - } - ); -} - -export function useGetAllEventFilters(): QueryObserverResult< - FoundExceptionListItemSchema, - ServerApiError -> { - const http = useHttp(); - return useQuery( - ['eventFilters', 'all'], - () => { - return getList({ http }); - }, - { - refetchIntervalInBackground: false, - refetchOnWindowFocus: false, - refetchOnMount: true, - keepPreviousData: true, - } - ); -} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/layout/policy_event_filters_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/layout/policy_event_filters_layout.test.tsx deleted file mode 100644 index fe0668e63774..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/layout/policy_event_filters_layout.test.tsx +++ /dev/null @@ -1,128 +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 from 'react'; -import { PolicyEventFiltersLayout } from './policy_event_filters_layout'; -import * as reactTestingLibrary from '@testing-library/react'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; -import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data'; - -import { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; -import { parsePoliciesAndFilterToKql } from '../../../../../common/utils'; -import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_utils'; -import { getFoundExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; -import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; - -let render: () => ReturnType; -let mockedContext: AppContextTestRender; -let policyItem: ImmutableObject; -const generator = new EndpointDocGenerator(); -let mockedApi: ReturnType; - -describe('Policy event filters layout', () => { - beforeEach(() => { - mockedContext = createAppRootMockRenderer(); - mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); - policyItem = generator.generatePolicyPackagePolicy(); - render = () => mockedContext.render(); - }); - - afterEach(() => reactTestingLibrary.cleanup()); - - it('should render layout with a loader', async () => { - const component = render(); - expect(component.getByTestId('policy-event-filters-loading-spinner')).toBeTruthy(); - }); - - it('should render layout with no assigned event filters data when there are not event filters', async () => { - mockedApi.responseProvider.eventFiltersList.mockReturnValue( - getFoundExceptionListItemSchemaMock(0) - ); - - const component = render(); - expect(await component.findByTestId('policy-event-filters-empty-unexisting')).not.toBeNull(); - }); - - it('should render layout with no assigned event filters data when there are event filters', async () => { - mockedApi.responseProvider.eventFiltersList.mockImplementation( - // @ts-expect-error - (args) => { - const params = args.query; - if ( - params && - params.filter === parsePoliciesAndFilterToKql({ policies: [policyItem.id, 'all'] }) - ) { - return getFoundExceptionListItemSchemaMock(0); - } else { - return getFoundExceptionListItemSchemaMock(1); - } - } - ); - - const component = render(); - - expect(await component.findByTestId('policy-event-filters-empty-unassigned')).not.toBeNull(); - }); - - it('should render layout with data', async () => { - mockedApi.responseProvider.eventFiltersList.mockReturnValue( - getFoundExceptionListItemSchemaMock(3) - ); - const component = render(); - expect(await component.findByTestId('policy-event-filters-header-section')).not.toBeNull(); - expect(await component.findByTestId('policy-event-filters-layout-about')).not.toBeNull(); - expect((await component.findByTestId('policy-event-filters-layout-about')).textContent).toMatch( - '3 event filters' - ); - }); - - it('should hide `Assign event filters to policy` on empty state with unassigned policies when downgraded to a gold or below license', () => { - getEndpointPrivilegesInitialStateMock({ - canCreateArtifactsByPolicy: false, - }); - mockedApi.responseProvider.eventFiltersList.mockReturnValue( - getFoundExceptionListItemSchemaMock(0) - ); - - const component = render(); - mockedContext.history.push(getPolicyDetailsArtifactsListPath(policyItem.id)); - expect(component.queryByTestId('assign-event-filter-button')).toBeNull(); - }); - - it('should hide the `Assign event filters to policy` button license is downgraded to gold or below', () => { - getEndpointPrivilegesInitialStateMock({ - canCreateArtifactsByPolicy: false, - }); - mockedApi.responseProvider.eventFiltersList.mockReturnValue( - getFoundExceptionListItemSchemaMock(5) - ); - - const component = render(); - mockedContext.history.push(getPolicyDetailsArtifactsListPath(policyItem.id)); - - expect(component.queryByTestId('eventFilters-assign-button')).toBeNull(); - }); - - it('should hide the `Assign event filters` flyout when license is downgraded to gold or below', () => { - getEndpointPrivilegesInitialStateMock({ - canCreateArtifactsByPolicy: false, - }); - mockedApi.responseProvider.eventFiltersList.mockReturnValue( - getFoundExceptionListItemSchemaMock(2) - ); - - const component = render(); - mockedContext.history.push( - `${getPolicyDetailsArtifactsListPath(policyItem.id)}/eventFilters?show=list` - ); - - expect(component.queryByTestId('eventFilters-assign-flyout')).toBeNull(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/layout/policy_event_filters_layout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/layout/policy_event_filters_layout.tsx deleted file mode 100644 index 850a303654c5..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/layout/policy_event_filters_layout.tsx +++ /dev/null @@ -1,180 +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, { useMemo, useCallback } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiTitle, - EuiPageHeader, - EuiPageHeaderSection, - EuiText, - EuiSpacer, - EuiLink, - EuiButton, - EuiPageContent, -} from '@elastic/eui'; -import { useAppUrl } from '../../../../../../common/lib/kibana'; -import { APP_UI_ID } from '../../../../../../../common/constants'; -import { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; -import { getEventFiltersListPath } from '../../../../../common/routing'; -import { useGetAllAssignedEventFilters, useGetAllEventFilters } from '../hooks'; -import { ManagementPageLoader } from '../../../../../components/management_page_loader'; -import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; -import { PolicyEventFiltersEmptyUnassigned, PolicyEventFiltersEmptyUnexisting } from '../empty'; -import { - usePolicyDetailsSelector, - usePolicyDetailsEventFiltersNavigateCallback, -} from '../../policy_hooks'; -import { getCurrentArtifactsLocation } from '../../../store/policy_details/selectors'; -import { PolicyEventFiltersFlyout } from '../flyout'; -import { PolicyEventFiltersList } from '../list'; - -interface PolicyEventFiltersLayoutProps { - policyItem?: ImmutableObject | undefined; -} -export const PolicyEventFiltersLayout = React.memo( - ({ policyItem }) => { - const { getAppUrl } = useAppUrl(); - const navigateCallback = usePolicyDetailsEventFiltersNavigateCallback(); - const { canCreateArtifactsByPolicy } = useUserPrivileges().endpointPrivileges; - const urlParams = usePolicyDetailsSelector(getCurrentArtifactsLocation); - - const { data: allAssigned, isLoading: isLoadingAllAssigned } = useGetAllAssignedEventFilters( - policyItem?.id || '', - !!policyItem?.id - ); - - const { data: allEventFilters, isLoading: isLoadingAllEventFilters } = useGetAllEventFilters(); - - const handleOnClickAssignButton = useCallback(() => { - navigateCallback({ show: 'list' }); - }, [navigateCallback]); - const handleOnCloseFlyout = useCallback(() => { - navigateCallback({ show: undefined }); - }, [navigateCallback]); - - const assignToPolicyButton = useMemo( - () => ( - - {i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.layout.assignToPolicy', - { - defaultMessage: 'Assign event filters to policy', - } - )} - - ), - [handleOnClickAssignButton] - ); - - const aboutInfo = useMemo(() => { - const link = ( - - - - ); - - return ( - - ); - }, [getAppUrl, allAssigned]); - - const isGlobalLoading = useMemo( - () => isLoadingAllAssigned || isLoadingAllEventFilters, - [isLoadingAllAssigned, isLoadingAllEventFilters] - ); - - const isEmptyState = useMemo(() => allAssigned && allAssigned.total === 0, [allAssigned]); - - if (!policyItem || isGlobalLoading) { - return ; - } - - if (isEmptyState) { - return ( - <> - {canCreateArtifactsByPolicy && urlParams.show === 'list' && ( - - )} - {allEventFilters && allEventFilters.total !== 0 ? ( - - ) : ( - - )} - - ); - } - - return ( -
    - - - -

    - {i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.layout.title', - { - defaultMessage: 'Assigned event filters', - } - )} -

    -
    - - - - -

    {aboutInfo}

    -
    -
    - - {canCreateArtifactsByPolicy && assignToPolicyButton} - -
    - {canCreateArtifactsByPolicy && urlParams.show === 'list' && ( - - )} - - - - -
    - ); - } -); - -PolicyEventFiltersLayout.displayName = 'PolicyEventFiltersLayout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx deleted file mode 100644 index 2c6d160d174e..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx +++ /dev/null @@ -1,198 +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, { useCallback, useMemo, useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiText, Pagination } from '@elastic/eui'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { useAppUrl } from '../../../../../../common/lib/kibana'; -import { APP_UI_ID } from '../../../../../../../common/constants'; -import { useSearchAssignedEventFilters } from '../hooks'; -import { SearchExceptions } from '../../../../../components/search_exceptions'; -import { useEndpointPoliciesToArtifactPolicies } from '../../../../../components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies'; -import { - MANAGEMENT_PAGE_SIZE_OPTIONS, - MANAGEMENT_DEFAULT_PAGE_SIZE, -} from '../../../../../common/constants'; -import { useGetEndpointSpecificPolicies } from '../../../../../services/policies/hooks'; -import { - ArtifactCardGrid, - ArtifactCardGridProps, -} from '../../../../../components/artifact_card_grid'; -import { - usePolicyDetailsEventFiltersNavigateCallback, - usePolicyDetailsSelector, -} from '../../policy_hooks'; -import { getCurrentArtifactsLocation } from '../../../store/policy_details/selectors'; -import { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; -import { PolicyEventFiltersDeleteModal } from '../delete_modal'; -import { isGlobalPolicyEffected } from '../../../../../components/effected_policy_select/utils'; -import { getEventFiltersListPath } from '../../../../../common/routing'; -import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; -import { useGetLinkTo } from '../empty/use_policy_event_filters_empty_hooks'; - -interface PolicyEventFiltersListProps { - policy: ImmutableObject; -} -export const PolicyEventFiltersList = React.memo(({ policy }) => { - const { getAppUrl } = useAppUrl(); - const { canCreateArtifactsByPolicy } = useUserPrivileges().endpointPrivileges; - const policiesRequest = useGetEndpointSpecificPolicies({ perPage: 1000 }); - const navigateCallback = usePolicyDetailsEventFiltersNavigateCallback(); - const urlParams = usePolicyDetailsSelector(getCurrentArtifactsLocation); - const [expandedItemsMap, setExpandedItemsMap] = useState>(new Map()); - const [exceptionItemToDelete, setExceptionItemToDelete] = useState< - ExceptionListItemSchema | undefined - >(); - const { state } = useGetLinkTo(policy.id, policy.name); - - const { - data: eventFilters, - isLoading: isLoadingEventFilters, - isRefetching: isRefetchingEventFilters, - } = useSearchAssignedEventFilters(policy.id, { - page: urlParams.page_index || 0, - perPage: urlParams.page_size || MANAGEMENT_DEFAULT_PAGE_SIZE, - filter: urlParams.filter, - }); - - const pagination: Pagination = { - pageSize: urlParams.page_size || MANAGEMENT_DEFAULT_PAGE_SIZE, - pageIndex: urlParams.page_index || 0, - pageSizeOptions: [...MANAGEMENT_PAGE_SIZE_OPTIONS], - totalItemCount: eventFilters?.total || 0, - }; - - const handleOnSearch = useCallback( - (filter) => { - navigateCallback({ filter }); - }, - [navigateCallback] - ); - - const handleOnExpandCollapse: ArtifactCardGridProps['onExpandCollapse'] = ({ - expanded, - collapsed, - }) => { - const newExpandedMap = new Map(expandedItemsMap); - for (const item of expanded) { - newExpandedMap.set(item.id, true); - } - for (const item of collapsed) { - newExpandedMap.set(item.id, false); - } - setExpandedItemsMap(newExpandedMap); - }; - const handleOnPageChange = useCallback( - ({ pageIndex, pageSize }) => { - if (eventFilters?.total) navigateCallback({ page_index: pageIndex, page_size: pageSize }); - }, - [eventFilters?.total, navigateCallback] - ); - - const totalItemsCountLabel = useMemo(() => { - return i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.list.totalItemCount', - { - defaultMessage: - 'Showing {totalItemsCount, plural, one {# event filter} other {# event filters}}', - values: { totalItemsCount: eventFilters?.data.length || 0 }, - } - ); - }, [eventFilters?.data.length]); - - const artifactCardPolicies = useEndpointPoliciesToArtifactPolicies(policiesRequest.data?.items); - const provideCardProps: ArtifactCardGridProps['cardComponentProps'] = (artifact) => { - const viewUrlPath = getEventFiltersListPath({ - filter: (artifact as ExceptionListItemSchema).item_id, - }); - const fullDetailsAction = { - icon: 'controlsHorizontal', - children: i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.list.fullDetailsAction', - { defaultMessage: 'View full details' } - ), - href: getAppUrl({ appId: APP_UI_ID, path: viewUrlPath }), - navigateAppId: APP_UI_ID, - navigateOptions: { path: viewUrlPath, state }, - 'data-test-subj': 'view-full-details-action', - }; - const item = artifact as ExceptionListItemSchema; - - const isGlobal = isGlobalPolicyEffected(item.tags); - const deleteAction = { - icon: 'trash', - children: i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeAction', - { defaultMessage: 'Remove from policy' } - ), - onClick: () => { - setExceptionItemToDelete(item); - }, - disabled: isGlobal, - toolTipContent: isGlobal - ? i18n.translate( - 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeActionNotAllowed', - { - defaultMessage: 'Globally applied event filters cannot be removed from policy.', - } - ) - : undefined, - toolTipPosition: 'top' as const, - 'data-test-subj': 'remove-from-policy-action', - }; - return { - expanded: expandedItemsMap.get(item.id) || false, - actions: canCreateArtifactsByPolicy ? [fullDetailsAction, deleteAction] : [fullDetailsAction], - policies: artifactCardPolicies, - }; - }; - - const handleDeleteModalClose = useCallback(() => { - setExceptionItemToDelete(undefined); - }, [setExceptionItemToDelete]); - - return ( - <> - {exceptionItemToDelete && ( - - )} - - - - {totalItemsCountLabel} - - - - - ); -}); - -PolicyEventFiltersList.displayName = 'PolicyEventFiltersList'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/assign_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/assign_flyout.test.tsx deleted file mode 100644 index f295509b101b..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/assign_flyout.test.tsx +++ /dev/null @@ -1,289 +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 { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import uuid from 'uuid'; -import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; -import { getExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { getFoundExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; -import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data'; -import { PolicyData } from '../../../../../../../common/endpoint/types'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { getPolicyHostIsolationExceptionsPath } from '../../../../../common/routing'; -import { - getHostIsolationExceptionItems, - updateOneHostIsolationExceptionItem, -} from '../../../../host_isolation_exceptions/service'; -import { PolicyHostIsolationExceptionsAssignFlyout } from './assign_flyout'; - -jest.mock('../../../../host_isolation_exceptions/service'); -jest.mock('../../../../../../common/components/user_privileges'); - -const useUserPrivilegesMock = useUserPrivileges as jest.Mock; -const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; -const updateOneHostIsolationExceptionItemMock = updateOneHostIsolationExceptionItem as jest.Mock; -const endpointGenerator = new EndpointDocGenerator('seed'); -const emptyList = { - data: [], - page: 1, - per_page: 10, - total: 0, -}; - -describe('Policy details host isolation exceptions assign flyout', () => { - let policyId: string; - let policy: PolicyData; - let render: () => ReturnType; - let renderResult: ReturnType; - let history: AppContextTestRender['history']; - let mockedContext: AppContextTestRender; - let onClose: () => void; - - beforeEach(() => { - getHostIsolationExceptionItemsMock.mockClear(); - updateOneHostIsolationExceptionItemMock.mockClear(); - useUserPrivilegesMock.mockReturnValue({ - endpointPrivileges: { - canIsolateHost: true, - }, - }); - policy = endpointGenerator.generatePolicyPackagePolicy(); - policyId = policy.id; - mockedContext = createAppRootMockRenderer(); - onClose = jest.fn(); - ({ history } = mockedContext); - render = () => - (renderResult = mockedContext.render( - - )); - - history.push(getPolicyHostIsolationExceptionsPath(policyId, { show: 'list' })); - }); - - it('should render a list of assignable policies and searchbar', async () => { - getHostIsolationExceptionItemsMock.mockImplementation(() => { - return getFoundExceptionListItemSchemaMock(1); - }); - render(); - await waitFor(() => { - expect(getHostIsolationExceptionItemsMock).toHaveBeenCalledWith( - expect.objectContaining({ - filter: `((not exception-list-agnostic.attributes.tags:"policy:${policyId}" AND not exception-list-agnostic.attributes.tags:"policy:all"))`, - }) - ); - }); - expect(await renderResult.findByTestId('artifactsList')).toBeTruthy(); - expect(renderResult.getByTestId('searchField')).toBeTruthy(); - }); - - it('should render "no items found" when searched for a term without data', async () => { - // first render - getHostIsolationExceptionItemsMock.mockImplementationOnce(() => { - return getFoundExceptionListItemSchemaMock(1); - }); - render(); - expect(await renderResult.findByTestId('artifactsList')).toBeTruthy(); - - // results for search - getHostIsolationExceptionItemsMock.mockResolvedValueOnce(emptyList); - - // do a search - userEvent.type(renderResult.getByTestId('searchField'), 'no results with this{enter}'); - - await waitFor(() => { - expect(getHostIsolationExceptionItemsMock).toHaveBeenCalledWith( - expect.objectContaining({ - filter: `((not exception-list-agnostic.attributes.tags:"policy:${policyId}" AND not exception-list-agnostic.attributes.tags:"policy:all")) AND ((exception-list-agnostic.attributes.item_id:(*no*results*with*this*) OR exception-list-agnostic.attributes.name:(*no*results*with*this*) OR exception-list-agnostic.attributes.description:(*no*results*with*this*) OR exception-list-agnostic.attributes.entries.value:(*no*results*with*this*)))`, - }) - ); - expect(renderResult.getByTestId('hostIsolationExceptions-no-items-found')).toBeTruthy(); - }); - }); - - it('should render "not assignable items" when no possible exceptions can be assigned', async () => { - // both exceptions list requests will return no results - getHostIsolationExceptionItemsMock.mockResolvedValue(emptyList); - render(); - expect( - await renderResult.findByTestId('hostIsolationExceptions-no-assignable-items') - ).toBeTruthy(); - }); - - it('should disable the submit button if no exceptions are selected', async () => { - getHostIsolationExceptionItemsMock.mockImplementationOnce(() => { - return getFoundExceptionListItemSchemaMock(1); - }); - render(); - expect(await renderResult.findByTestId('artifactsList')).toBeTruthy(); - expect( - renderResult.getByTestId('hostIsolationExceptions-assign-confirm-button') - ).toBeDisabled(); - }); - - it('should enable the submit button if an exeption is selected', async () => { - const exceptions = getFoundExceptionListItemSchemaMock(1); - const firstOneName = exceptions.data[0].name; - getHostIsolationExceptionItemsMock.mockResolvedValue(exceptions); - - render(); - expect(await renderResult.findByTestId('artifactsList')).toBeTruthy(); - - // click the first item - userEvent.click(renderResult.getByTestId(`${firstOneName}_checkbox`)); - - expect(renderResult.getByTestId('hostIsolationExceptions-assign-confirm-button')).toBeEnabled(); - }); - - it('should warn the user when there are over 100 results in the flyout', async () => { - getHostIsolationExceptionItemsMock.mockImplementation(() => { - return { - ...getFoundExceptionListItemSchemaMock(1), - total: 120, - }; - }); - render(); - expect(await renderResult.findByTestId('artifactsList')).toBeTruthy(); - expect(renderResult.getByTestId('hostIsolationExceptions-too-many-results')).toBeTruthy(); - }); - - describe('without privileges', () => { - beforeEach(() => { - useUserPrivilegesMock.mockReturnValue({ endpointPrivileges: { canIsolateHost: false } }); - }); - it('should not render if invoked without privileges', () => { - render(); - expect(renderResult.queryByTestId('hostIsolationExceptions-assign-flyout')).toBeNull(); - }); - - it('should call onClose if accessed without privileges', () => { - render(); - expect(onClose).toHaveBeenCalled(); - }); - }); - - describe('when submitting the form', () => { - const FIRST_ONE_NAME = uuid.v4(); - const SECOND_ONE_NAME = uuid.v4(); - const testTags = ['policy:1234', 'non-policy-tag', 'policy:4321']; - let exceptions: FoundExceptionListItemSchema; - - beforeEach(async () => { - exceptions = { - ...emptyList, - total: 2, - data: [ - getExceptionListItemSchemaMock({ - name: FIRST_ONE_NAME, - id: uuid.v4(), - tags: testTags, - }), - getExceptionListItemSchemaMock({ - name: SECOND_ONE_NAME, - id: uuid.v4(), - tags: testTags, - }), - ], - }; - getHostIsolationExceptionItemsMock.mockResolvedValue(exceptions); - - render(); - // wait fo the list to render - expect(await renderResult.findByTestId('artifactsList')).toBeTruthy(); - }); - - it('should submit the exception when submit is pressed (1 exception), display a toast and close the flyout', async () => { - updateOneHostIsolationExceptionItemMock.mockImplementation(async (_http, exception) => { - return exception; - }); - // click the first item - userEvent.click(renderResult.getByTestId(`${FIRST_ONE_NAME}_checkbox`)); - // submit the form - userEvent.click(renderResult.getByTestId('hostIsolationExceptions-assign-confirm-button')); - - // verify the request with the new tag - await waitFor(() => { - expect(updateOneHostIsolationExceptionItemMock).toHaveBeenCalledWith( - mockedContext.coreStart.http, - { - ...exceptions.data[0], - tags: [...testTags, `policy:${policyId}`], - } - ); - }); - - await waitFor(() => { - expect(mockedContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith({ - text: `"${FIRST_ONE_NAME}" has been added to your host isolation exceptions list.`, - title: 'Success', - }); - }); - expect(onClose).toHaveBeenCalled(); - }); - - it('should submit the exception when submit is pressed (2 exceptions), display a toast and close the flyout', async () => { - updateOneHostIsolationExceptionItemMock.mockImplementation(async (_http, exception) => { - return exception; - }); - // click the first two items - userEvent.click(renderResult.getByTestId(`${FIRST_ONE_NAME}_checkbox`)); - userEvent.click(renderResult.getByTestId(`${SECOND_ONE_NAME}_checkbox`)); - // submit the form - userEvent.click(renderResult.getByTestId('hostIsolationExceptions-assign-confirm-button')); - - // verify the request with the new tag - await waitFor(() => { - // first exception - expect(updateOneHostIsolationExceptionItemMock).toHaveBeenCalledWith( - mockedContext.coreStart.http, - { - ...exceptions.data[0], - tags: [...testTags, `policy:${policyId}`], - } - ); - // second exception - expect(updateOneHostIsolationExceptionItemMock).toHaveBeenCalledWith( - mockedContext.coreStart.http, - { - ...exceptions.data[1], - tags: [...testTags, `policy:${policyId}`], - } - ); - }); - - await waitFor(() => { - expect(mockedContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith({ - text: '2 host isolation exceptions have been added to your list.', - title: 'Success', - }); - }); - expect(onClose).toHaveBeenCalled(); - }); - - it('should show a toast error when the request fails and close the flyout', async () => { - updateOneHostIsolationExceptionItemMock.mockRejectedValue( - new Error('the server is too far away') - ); - // click first item - userEvent.click(renderResult.getByTestId(`${FIRST_ONE_NAME}_checkbox`)); - // submit the form - userEvent.click(renderResult.getByTestId('hostIsolationExceptions-assign-confirm-button')); - - await waitFor(() => { - expect(mockedContext.coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( - 'An error occurred updating artifacts' - ); - expect(onClose).toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/assign_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/assign_flyout.tsx deleted file mode 100644 index 8e27fea17381..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/assign_flyout.tsx +++ /dev/null @@ -1,323 +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 { - EuiButton, - EuiButtonEmpty, - EuiCallOut, - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { isEmpty, without } from 'lodash/fp'; -import pMap from 'p-map'; -import React, { useEffect, useMemo, useState } from 'react'; -import { useMutation, useQueryClient } from 'react-query'; -import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; -import { PolicyData } from '../../../../../../../common/endpoint/types'; -import { useHttp, useToasts } from '../../../../../../common/lib/kibana'; -import { SearchExceptions } from '../../../../../components/search_exceptions'; -import { updateOneHostIsolationExceptionItem } from '../../../../host_isolation_exceptions/service'; -import { useFetchHostIsolationExceptionsList } from '../../../../host_isolation_exceptions/view/hooks'; -import { PolicyArtifactsAssignableList } from '../../artifacts/assignable'; - -const MAX_ALLOWED_RESULTS = 100; - -export const PolicyHostIsolationExceptionsAssignFlyout = ({ - policy, - onClose, -}: { - policy: PolicyData; - onClose: () => void; -}) => { - const http = useHttp(); - const toasts = useToasts(); - const queryClient = useQueryClient(); - const privileges = useUserPrivileges().endpointPrivileges; - - useEffect(() => { - if (!privileges.canIsolateHost) { - onClose(); - } - }, [onClose, privileges.canIsolateHost]); - - const [selectedArtifactIds, setSelectedArtifactIds] = useState([]); - const [currentFilter, setCurrentFilter] = useState(''); - - const onUpdateSuccesss = (updatedExceptions: ExceptionListItemSchema[]) => { - if (updatedExceptions.length > 0) { - toasts.addSuccess({ - title: i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.layout.flyout.toastSuccess.title', - { - defaultMessage: 'Success', - } - ), - text: - updatedExceptions.length > 1 - ? i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.layout.flyout.toastSuccess.textMultiples', - { - defaultMessage: '{count} host isolation exceptions have been added to your list.', - values: { count: updatedExceptions.length }, - } - ) - : i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.layout.flyout.toastSuccess.textSingle', - { - defaultMessage: '"{name}" has been added to your host isolation exceptions list.', - values: { name: updatedExceptions[0].name }, - } - ), - }); - } - }; - - const onUpdateError = () => { - toasts.addDanger( - i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.layout.flyout.toastError.text', - { - defaultMessage: `An error occurred updating artifacts`, - } - ) - ); - }; - - const exceptionsRequest = useFetchHostIsolationExceptionsList({ - excludedPolicies: [policy.id, 'all'], - page: 0, - filter: currentFilter, - perPage: MAX_ALLOWED_RESULTS, - }); - - const allPossibleExceptionsRequest = useFetchHostIsolationExceptionsList({ - excludedPolicies: [policy.id, 'all'], - page: 0, - perPage: MAX_ALLOWED_RESULTS, - // only request if there's a filter and no results from the regular request - enabled: currentFilter !== '' && exceptionsRequest.data?.total === 0, - }); - - const mutation = useMutation( - () => { - const toMutate = exceptionsRequest.data?.data.filter((exception) => { - return selectedArtifactIds.includes(exception.id); - }); - - if (toMutate === undefined) { - return Promise.reject(new Error('no exceptions selected')); - } - - return pMap( - toMutate, - (exception) => { - exception.tags = [...exception.tags, `policy:${policy.id}`]; - return updateOneHostIsolationExceptionItem(http, exception); - }, - { - concurrency: 10, - } - ); - }, - { - onSuccess: onUpdateSuccesss, - onError: onUpdateError, - onSettled: () => { - queryClient.invalidateQueries(['endpointSpecificPolicies']); - queryClient.invalidateQueries(['hostIsolationExceptions']); - onClose(); - }, - } - ); - - const handleOnConfirmAction = () => { - mutation.mutate(); - }; - - const handleOnSearch = (term: string) => { - // reset existing selection - setSelectedArtifactIds([]); - setCurrentFilter(term); - }; - - const handleSelectArtifact = (artifactId: string, selected: boolean) => { - setSelectedArtifactIds((currentSelectedArtifactIds) => - selected - ? [...currentSelectedArtifactIds, artifactId] - : without([artifactId], currentSelectedArtifactIds) - ); - }; - - const searchWarningMessage = useMemo( - () => ( - <> - - {i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.layout.flyout.searchWarning.text', - { - defaultMessage: - 'Only the first 100 host isolation exceptions are displayed. Please use the search bar to refine the results.', - } - )} - - - - ), - [] - ); - - const noItemsMessage = useMemo(() => { - if (exceptionsRequest.isLoading || allPossibleExceptionsRequest.isLoading) { - return null; - } - - // there are no host isolation exceptions assignable to this policy - if ( - allPossibleExceptionsRequest.data?.total === 0 || - (exceptionsRequest.data?.total === 0 && currentFilter === '') - ) { - return ( - - } - /> - ); - } - - // there are no results for the current search - if (exceptionsRequest.data?.total === 0) { - return ( - - } - /> - ); - } - }, [ - allPossibleExceptionsRequest.data?.total, - allPossibleExceptionsRequest.isLoading, - currentFilter, - exceptionsRequest.data?.total, - exceptionsRequest.isLoading, - ]); - - // do not render if doesn't have adecuate privleges - if (!privileges.loading && !privileges.canIsolateHost) { - return null; - } - - return ( - - - -

    - -

    -
    - - -
    - - {(exceptionsRequest.data?.total || 0) > MAX_ALLOWED_RESULTS ? searchWarningMessage : null} - - - - - - {noItemsMessage} - - - - - - - - - - - - - - - -
    - ); -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.test.tsx deleted file mode 100644 index 5e750b5599d7..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.test.tsx +++ /dev/null @@ -1,121 +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 { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { act, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import uuid from 'uuid'; -import { getExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { getPolicyHostIsolationExceptionsPath } from '../../../../../common/routing'; -import { updateOneHostIsolationExceptionItem } from '../../../../host_isolation_exceptions/service'; -import { PolicyHostIsolationExceptionsDeleteModal } from './delete_modal'; - -jest.mock('../../../../host_isolation_exceptions/service'); - -const updateOneHostIsolationExceptionItemMock = updateOneHostIsolationExceptionItem as jest.Mock; - -describe('Policy details host isolation exceptions delete modal', () => { - let policyId: string; - let render: () => ReturnType; - let renderResult: ReturnType; - let history: AppContextTestRender['history']; - let mockedContext: AppContextTestRender; - let exception: ExceptionListItemSchema; - let onCancel: () => void; - - beforeEach(() => { - policyId = uuid.v4(); - mockedContext = createAppRootMockRenderer(); - exception = getExceptionListItemSchemaMock(); - onCancel = jest.fn(); - updateOneHostIsolationExceptionItemMock.mockClear(); - ({ history } = mockedContext); - render = () => - (renderResult = mockedContext.render( - - )); - - act(() => { - history.push(getPolicyHostIsolationExceptionsPath(policyId)); - }); - }); - - it('should render with enabled buttons', () => { - render(); - expect(renderResult.getByTestId('confirmModalCancelButton')).toBeEnabled(); - expect(renderResult.getByTestId('confirmModalConfirmButton')).toBeEnabled(); - }); - - it('should disable the submit button while deleting ', async () => { - updateOneHostIsolationExceptionItemMock.mockImplementation(() => { - return new Promise((resolve) => setImmediate(resolve)); - }); - render(); - const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); - userEvent.click(confirmButton); - - await waitFor(() => { - expect(confirmButton).toBeDisabled(); - }); - }); - - it('should call the API with the removed policy from the exception tags', async () => { - exception.tags = ['policy:1234', 'policy:4321', `policy:${policyId}`, 'not-a-policy-tag']; - render(); - const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); - userEvent.click(confirmButton); - - await waitFor(() => { - expect(updateOneHostIsolationExceptionItemMock).toHaveBeenCalledWith( - mockedContext.coreStart.http, - expect.objectContaining({ - id: exception.id, - tags: ['policy:1234', 'policy:4321', 'not-a-policy-tag'], - }) - ); - }); - }); - - it('should show a success toast if the operation was success', async () => { - updateOneHostIsolationExceptionItemMock.mockReturnValue('all good'); - render(); - const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); - userEvent.click(confirmButton); - - await waitFor(() => { - expect(updateOneHostIsolationExceptionItemMock).toHaveBeenCalled(); - }); - - expect(mockedContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalled(); - }); - - it('should show an error toast if the operation failed', async () => { - const error = new Error('the server is too far away'); - updateOneHostIsolationExceptionItemMock.mockRejectedValue(error); - render(); - const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); - userEvent.click(confirmButton); - - await waitFor(() => { - expect(updateOneHostIsolationExceptionItemMock).toHaveBeenCalled(); - }); - - expect(mockedContext.coreStart.notifications.toasts.addError).toHaveBeenCalledWith(error, { - title: 'Error while attempt to remove host isolation exception', - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.tsx deleted file mode 100644 index ad8868bf6834..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.tsx +++ /dev/null @@ -1,131 +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 { EuiCallOut, EuiConfirmModal, EuiSpacer, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import React from 'react'; -import { useMutation, useQueryClient } from 'react-query'; -import { useHttp, useToasts } from '../../../../../../common/lib/kibana'; -import { ServerApiError } from '../../../../../../common/types'; -import { updateOneHostIsolationExceptionItem } from '../../../../host_isolation_exceptions/service'; - -export const PolicyHostIsolationExceptionsDeleteModal = ({ - policyId, - policyName, - exception, - onCancel, -}: { - policyId: string; - policyName: string; - exception: ExceptionListItemSchema; - onCancel: () => void; -}) => { - const toasts = useToasts(); - const http = useHttp(); - const queryClient = useQueryClient(); - - const onDeleteError = (error: ServerApiError) => { - toasts.addError(error as unknown as Error, { - title: i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.removeDialog.errorToastTitle', - { - defaultMessage: 'Error while attempt to remove host isolation exception', - } - ), - }); - onCancel(); - }; - - const onDeleteSuccess = () => { - queryClient.invalidateQueries(['endpointSpecificPolicies']); - queryClient.invalidateQueries(['hostIsolationExceptions']); - toasts.addSuccess({ - title: i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.removeDialog.successToastTitle', - { defaultMessage: 'Successfully removed' } - ), - text: i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.removeDialog.successToastText', - { - defaultMessage: - '"{hostIsolationExceptionName}" has been removed from {policyName} policy', - values: { hostIsolationExceptionName: exception.name, policyName }, - } - ), - }); - onCancel(); - }; - - const mutation = useMutation( - async () => { - const modifiedException = { - ...exception, - tags: exception.tags.filter((tag) => tag !== `policy:${policyId}`), - }; - return updateOneHostIsolationExceptionItem(http, modifiedException); - }, - { - onSuccess: onDeleteSuccess, - onError: onDeleteError, - } - ); - - const handleModalConfirm = () => { - mutation.mutate(); - }; - - const handleCancel = () => { - if (!mutation.isLoading) { - onCancel(); - } - }; - - return ( - - -

    - -

    -
    - - - - -

    - -

    -
    -
    - ); -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/empty_unassigned.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/empty_unassigned.tsx deleted file mode 100644 index 40438338c521..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/empty_unassigned.tsx +++ /dev/null @@ -1,76 +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 { EuiButton, EuiEmptyPrompt, EuiLink, EuiPageTemplate } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useCallback } from 'react'; -import { PolicyData } from '../../../../../../../common/endpoint/types'; -import { usePolicyDetailsHostIsolationExceptionsNavigateCallback } from '../../policy_hooks'; -import { useGetLinkTo } from './use_policy_host_isolation_exceptions_empty_hooks'; - -export const PolicyHostIsolationExceptionsEmptyUnassigned = ({ - policy, -}: { - policy: PolicyData; -}) => { - const { onClickHandler, toRouteUrl } = useGetLinkTo(policy.id, policy.name); - const navigateCallback = usePolicyDetailsHostIsolationExceptionsNavigateCallback(); - const onClickPrimaryButtonHandler = useCallback( - () => - navigateCallback({ - show: 'list', - }), - [navigateCallback] - ); - - return ( - - - - - } - body={ - - } - actions={[ - - - , - // eslint-disable-next-line @elastic/eui/href-or-on-click - - - , - ]} - /> - - ); -}; - -PolicyHostIsolationExceptionsEmptyUnassigned.displayName = - 'PolicyHostIsolationExceptionsEmptyUnassigned'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/empty_unexisting.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/empty_unexisting.tsx deleted file mode 100644 index 94185904ce6c..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/empty_unexisting.tsx +++ /dev/null @@ -1,55 +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 { EuiButton, EuiEmptyPrompt, EuiPageTemplate } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React from 'react'; -import { PolicyData } from '../../../../../../../common/endpoint/types'; -import { useGetLinkTo } from './use_policy_host_isolation_exceptions_empty_hooks'; - -export const PolicyHostIsolationExceptionsEmptyUnexisting = ({ - policy, -}: { - policy: PolicyData; -}) => { - const { onClickHandler, toRouteUrl } = useGetLinkTo(policy.id, policy.name, { show: 'create' }); - - return ( - - - - - } - body={ - - } - actions={ - // eslint-disable-next-line @elastic/eui/href-or-on-click - - - - } - /> - - ); -}; - -PolicyHostIsolationExceptionsEmptyUnexisting.displayName = - 'PolicyHostIsolationExceptionsEmptyUnexisting'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.test.tsx deleted file mode 100644 index 17e3ace9a641..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.test.tsx +++ /dev/null @@ -1,220 +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 { act } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import uuid from 'uuid'; -import { getExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { getFoundExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; -import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { getPolicyHostIsolationExceptionsPath } from '../../../../../common/routing'; -import { PolicyHostIsolationExceptionsList } from './list'; -import { getHostIsolationExceptionItems } from '../../../../host_isolation_exceptions/service'; - -jest.mock('../../../../../../common/components/user_privileges'); -jest.mock('../../../../host_isolation_exceptions/service'); - -const useUserPrivilegesMock = useUserPrivileges as jest.Mock; -const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; - -const emptyList = { - data: [], - page: 1, - per_page: 10, - total: 0, -}; - -describe('Policy details host isolation exceptions tab', () => { - let policyId: string; - let render: () => ReturnType; - let renderResult: ReturnType; - let history: AppContextTestRender['history']; - let mockedContext: AppContextTestRender; - - beforeEach(() => { - policyId = uuid.v4(); - getHostIsolationExceptionItemsMock.mockClear(); - useUserPrivilegesMock.mockReturnValue({ - endpointPrivileges: { - canIsolateHost: true, - }, - }); - mockedContext = createAppRootMockRenderer(); - ({ history } = mockedContext); - render = () => - (renderResult = mockedContext.render( - - )); - - act(() => { - history.push(getPolicyHostIsolationExceptionsPath(policyId)); - }); - }); - - it('should display a searchbar and count even with no exceptions', async () => { - getHostIsolationExceptionItemsMock.mockResolvedValue(emptyList); - render(); - expect( - await renderResult.findByTestId('policyDetailsHostIsolationExceptionsSearchCount') - ).toHaveTextContent('Showing 0 host isolation exceptions'); - expect(renderResult.getByTestId('searchField')).toBeTruthy(); - }); - - it('should render the list of exceptions collapsed and expand it when clicked', async () => { - // render 3 - getHostIsolationExceptionItemsMock.mockResolvedValue(getFoundExceptionListItemSchemaMock(3)); - render(); - expect( - await renderResult.findAllByTestId('hostIsolationExceptions-collapsed-list-card') - ).toHaveLength(3); - expect( - renderResult.queryAllByTestId( - 'hostIsolationExceptions-collapsed-list-card-criteriaConditions' - ) - ).toHaveLength(0); - }); - - it('should expand an item when expand is clicked', async () => { - getHostIsolationExceptionItemsMock.mockResolvedValue(getFoundExceptionListItemSchemaMock(1)); - render(); - expect( - await renderResult.findAllByTestId('hostIsolationExceptions-collapsed-list-card') - ).toHaveLength(1); - - userEvent.click( - renderResult.getByTestId('hostIsolationExceptions-collapsed-list-card-header-expandCollapse') - ); - - expect( - renderResult.queryAllByTestId( - 'hostIsolationExceptions-collapsed-list-card-criteriaConditions' - ) - ).toHaveLength(1); - }); - - it('should change the address location when a filter is applied', async () => { - getHostIsolationExceptionItemsMock.mockResolvedValue(getFoundExceptionListItemSchemaMock(1)); - render(); - userEvent.type(await renderResult.findByTestId('searchField'), 'search me{enter}'); - expect(history.location.search).toBe('?filter=search%20me'); - }); - - it('should apply a filter when requested from location search params', async () => { - history.push(getPolicyHostIsolationExceptionsPath(policyId, { filter: 'my filter' })); - getHostIsolationExceptionItemsMock.mockResolvedValue(() => - getFoundExceptionListItemSchemaMock(4) - ); - render(); - expect(getHostIsolationExceptionItemsMock).toHaveBeenCalledWith({ - filter: `((exception-list-agnostic.attributes.tags:"policy:${policyId}" OR exception-list-agnostic.attributes.tags:"policy:all")) AND ((exception-list-agnostic.attributes.item_id:(*my*filter*) OR exception-list-agnostic.attributes.name:(*my*filter*) OR exception-list-agnostic.attributes.description:(*my*filter*) OR exception-list-agnostic.attributes.entries.value:(*my*filter*)))`, - http: mockedContext.coreStart.http, - page: 1, - perPage: 10, - }); - }); - - it('should disable the "remove from policy" option to global exceptions', async () => { - const testException = getExceptionListItemSchemaMock({ tags: ['policy:all'] }); - const exceptions = { - ...emptyList, - data: [testException], - total: 1, - }; - getHostIsolationExceptionItemsMock.mockResolvedValue(exceptions); - render(); - // click the actions button - userEvent.click( - await renderResult.findByTestId( - 'hostIsolationExceptions-collapsed-list-card-header-actions-button' - ) - ); - expect(renderResult.getByTestId('remove-from-policy-action')).toBeDisabled(); - }); - - it('should enable the "remove from policy" option to policy-specific exceptions ', async () => { - const testException = getExceptionListItemSchemaMock({ - tags: [`policy:${policyId}`, 'policy:1234', 'not-a-policy-tag'], - }); - const exceptions = { - ...emptyList, - data: [testException], - total: 1, - }; - getHostIsolationExceptionItemsMock.mockResolvedValue(exceptions); - render(); - // click the actions button - userEvent.click( - await renderResult.findByTestId( - 'hostIsolationExceptions-collapsed-list-card-header-actions-button' - ) - ); - expect(renderResult.getByTestId('remove-from-policy-action')).toBeEnabled(); - }); - - it('should enable the "view full details" action', async () => { - getHostIsolationExceptionItemsMock.mockResolvedValue(getFoundExceptionListItemSchemaMock(1)); - render(); - // click the actions button - userEvent.click( - await renderResult.findByTestId( - 'hostIsolationExceptions-collapsed-list-card-header-actions-button' - ) - ); - expect(renderResult.queryByTestId('view-full-details-action')).toBeTruthy(); - }); - - it('should render the delete dialog when the "remove from policy" button is clicked', async () => { - const testException = getExceptionListItemSchemaMock({ - tags: [`policy:${policyId}`, 'policy:1234', 'not-a-policy-tag'], - }); - const exceptions = { - ...emptyList, - data: [testException], - total: 1, - }; - getHostIsolationExceptionItemsMock.mockResolvedValue(exceptions); - render(); - // click the actions button - userEvent.click( - await renderResult.findByTestId( - 'hostIsolationExceptions-collapsed-list-card-header-actions-button' - ) - ); - userEvent.click(renderResult.getByTestId('remove-from-policy-action')); - - // check the dialog is there - expect(renderResult.getByTestId('remove-from-policy-dialog')).toBeTruthy(); - }); - - describe('without privileges', () => { - beforeEach(() => { - useUserPrivilegesMock.mockReturnValue({ - endpointPrivileges: { - canIsolateHost: false, - }, - }); - }); - - it('should not display the delete action, do show the full details', async () => { - getHostIsolationExceptionItemsMock.mockResolvedValue(getFoundExceptionListItemSchemaMock(1)); - render(); - // click the actions button - userEvent.click( - await renderResult.findByTestId( - 'hostIsolationExceptions-collapsed-list-card-header-actions-button' - ) - ); - expect(renderResult.queryByTestId('remove-from-policy-action')).toBeFalsy(); - expect(renderResult.queryByTestId('view-full-details-action')).toBeTruthy(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.tsx deleted file mode 100644 index 840d2c9f9809..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.tsx +++ /dev/null @@ -1,225 +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 { EuiSpacer, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import React, { useCallback, useMemo, useState } from 'react'; -import { useHistory } from 'react-router-dom'; -import { useAppUrl } from '../../../../../../common/lib/kibana'; -import { APP_UI_ID } from '../../../../../../../common/constants'; -import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; -import { - MANAGEMENT_DEFAULT_PAGE_SIZE, - MANAGEMENT_PAGE_SIZE_OPTIONS, -} from '../../../../../common/constants'; -import { - getHostIsolationExceptionsListPath, - getPolicyHostIsolationExceptionsPath, -} from '../../../../../common/routing'; -import { - ArtifactCardGrid, - ArtifactCardGridProps, -} from '../../../../../components/artifact_card_grid'; -import { useEndpointPoliciesToArtifactPolicies } from '../../../../../components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies'; -import { isGlobalPolicyEffected } from '../../../../../components/effected_policy_select/utils'; -import { SearchExceptions } from '../../../../../components/search_exceptions'; -import { useGetEndpointSpecificPolicies } from '../../../../../services/policies/hooks'; -import { getCurrentArtifactsLocation } from '../../../store/policy_details/selectors'; -import { usePolicyDetailsSelector } from '../../policy_hooks'; -import { PolicyHostIsolationExceptionsDeleteModal } from './delete_modal'; -import { useFetchHostIsolationExceptionsList } from '../../../../host_isolation_exceptions/view/hooks'; -import { useGetLinkTo } from './use_policy_host_isolation_exceptions_empty_hooks'; - -export const PolicyHostIsolationExceptionsList = ({ - policyId, - policyName, -}: { - policyId: string; - policyName: string; -}) => { - const history = useHistory(); - const { getAppUrl } = useAppUrl(); - - const privileges = useUserPrivileges().endpointPrivileges; - const location = usePolicyDetailsSelector(getCurrentArtifactsLocation); - - const { state } = useGetLinkTo(policyId, policyName); - - // load the list of policies> - const policiesRequest = useGetEndpointSpecificPolicies({ perPage: 1000 }); - const urlParams = usePolicyDetailsSelector(getCurrentArtifactsLocation); - - const [exceptionItemToDelete, setExceptionItemToDelete] = useState< - ExceptionListItemSchema | undefined - >(); - - const [expandedItemsMap, setExpandedItemsMap] = useState>(new Map()); - - const policySearchedExceptionsListRequest = useFetchHostIsolationExceptionsList({ - filter: location.filter, - page: location.page_index, - perPage: location.page_size, - policies: [policyId, 'all'], - }); - - const pagination = { - totalItemCount: policySearchedExceptionsListRequest?.data?.total ?? 0, - pageSize: policySearchedExceptionsListRequest?.data?.per_page ?? MANAGEMENT_DEFAULT_PAGE_SIZE, - pageSizeOptions: [...MANAGEMENT_PAGE_SIZE_OPTIONS], - pageIndex: (policySearchedExceptionsListRequest?.data?.page ?? 1) - 1, - }; - - const handlePageChange = useCallback( - ({ pageIndex, pageSize }) => { - history.push( - getPolicyHostIsolationExceptionsPath(policyId, { - ...urlParams, - // If user changed page size, then reset page index back to the first page - page_index: pageIndex, - page_size: pageSize, - }) - ); - }, - [history, policyId, urlParams] - ); - - const handleSearchInput = useCallback( - (filter: string) => { - history.push( - getPolicyHostIsolationExceptionsPath(policyId, { - ...urlParams, - filter, - }) - ); - }, - [history, policyId, urlParams] - ); - - const artifactCardPolicies = useEndpointPoliciesToArtifactPolicies(policiesRequest.data?.items); - const provideCardProps: ArtifactCardGridProps['cardComponentProps'] = (artifact) => { - const item = artifact as ExceptionListItemSchema; - const isGlobal = isGlobalPolicyEffected(item.tags); - const deleteAction = { - icon: 'trash', - children: i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.removeAction', - { defaultMessage: 'Remove from policy' } - ), - onClick: () => { - setExceptionItemToDelete(item); - }, - disabled: isGlobal, - toolTipContent: isGlobal - ? i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.removeActionNotAllowed', - { - defaultMessage: - 'Globally applied host isolation exceptions cannot be removed from policy.', - } - ) - : undefined, - toolTipPosition: 'top' as const, - 'data-test-subj': 'remove-from-policy-action', - }; - const viewUrlPath = getHostIsolationExceptionsListPath({ filter: item.item_id }); - - const fullDetailsAction = { - icon: 'controlsHorizontal', - children: i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.fullDetailsAction', - { defaultMessage: 'View full details' } - ), - href: getAppUrl({ appId: APP_UI_ID, path: viewUrlPath }), - navigateAppId: APP_UI_ID, - navigateOptions: { path: viewUrlPath, state }, - 'data-test-subj': 'view-full-details-action', - }; - - return { - expanded: expandedItemsMap.get(item.id) || false, - actions: privileges.canIsolateHost ? [fullDetailsAction, deleteAction] : [fullDetailsAction], - policies: artifactCardPolicies, - }; - }; - - const handleExpandCollapse: ArtifactCardGridProps['onExpandCollapse'] = ({ - expanded, - collapsed, - }) => { - const newExpandedMap = new Map(expandedItemsMap); - for (const item of expanded) { - newExpandedMap.set(item.id, true); - } - for (const item of collapsed) { - newExpandedMap.set(item.id, false); - } - setExpandedItemsMap(newExpandedMap); - }; - - const handleDeleteModalClose = useCallback(() => { - setExceptionItemToDelete(undefined); - }, [setExceptionItemToDelete]); - - const totalItemsCountLabel = useMemo(() => { - return i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.totalItemCount', - { - defaultMessage: - 'Showing {totalItemsCount, plural, one {# host isolation exception} other {# host isolation exceptions}}', - values: { totalItemsCount: pagination.totalItemCount }, - } - ); - }, [pagination.totalItemCount]); - - return ( - <> - {exceptionItemToDelete ? ( - - ) : null} - - - - {totalItemsCountLabel} - - - - - ); -}; -PolicyHostIsolationExceptionsList.displayName = 'PolicyHostIsolationExceptionsList'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/host_isolation_exceptions_tab.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/host_isolation_exceptions_tab.test.tsx deleted file mode 100644 index 2cebae47ed69..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/host_isolation_exceptions_tab.test.tsx +++ /dev/null @@ -1,165 +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 { waitFor } from '@testing-library/react'; -import React from 'react'; -import { getFoundExceptionListItemSchemaMock } from '../../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; -import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data'; -import { PolicyData } from '../../../../../../common/endpoint/types'; -import { useUserPrivileges } from '../../../../../common/components/user_privileges'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../common/mock/endpoint'; -import { getPolicyHostIsolationExceptionsPath } from '../../../../common/routing'; -import { getHostIsolationExceptionItems } from '../../../host_isolation_exceptions/service'; -import { PolicyHostIsolationExceptionsTab } from './host_isolation_exceptions_tab'; - -jest.mock('../../../host_isolation_exceptions/service'); -jest.mock('../../../../../common/components/user_privileges'); - -const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; -const useUserPrivilegesMock = useUserPrivileges as jest.Mock; - -const endpointGenerator = new EndpointDocGenerator('seed'); - -const emptyList = { - data: [], - page: 1, - per_page: 10, - total: 0, -}; - -describe('Policy details host isolation exceptions tab', () => { - let policyId: string; - let policy: PolicyData; - let render: () => ReturnType; - let renderResult: ReturnType; - let history: AppContextTestRender['history']; - let mockedContext: AppContextTestRender; - - beforeEach(() => { - getHostIsolationExceptionItemsMock.mockClear(); - policy = endpointGenerator.generatePolicyPackagePolicy(); - policyId = policy.id; - useUserPrivilegesMock.mockReturnValue({ - endpointPrivileges: { - canIsolateHost: true, - }, - }); - mockedContext = createAppRootMockRenderer(); - ({ history } = mockedContext); - render = () => - (renderResult = mockedContext.render()); - - history.push(getPolicyHostIsolationExceptionsPath(policyId)); - }); - - it('should display display a "loading" state while requests happen', async () => { - const promises: Array<() => void> = []; - getHostIsolationExceptionItemsMock.mockImplementation(() => { - return new Promise((resolve) => promises.push(resolve)); - }); - render(); - expect(await renderResult.findByTestId('policyHostIsolationExceptionsTabLoading')).toBeTruthy(); - // prevent memory leaks - promises.forEach((resolve) => resolve()); - }); - - it("should display an 'unexistent' empty state if there are no host isolation exceptions at all", async () => { - // mock no data for all requests - getHostIsolationExceptionItemsMock.mockResolvedValue({ - ...emptyList, - }); - render(); - expect( - await renderResult.findByTestId('policy-host-isolation-exceptions-empty-unexisting') - ).toBeTruthy(); - }); - - it("should display an 'unassigned' empty state and 'add' button if there are no host isolation exceptions assigned", async () => { - // mock no data for all requests - getHostIsolationExceptionItemsMock.mockImplementation((params) => { - // no filter = fetch all exceptions - if (!params.filter) { - return { - ...emptyList, - total: 1, - }; - } - return { - ...emptyList, - }; - }); - render(); - expect( - await renderResult.findByTestId('policy-host-isolation-exceptions-empty-unassigned') - ).toBeTruthy(); - expect(renderResult.getByTestId('empty-assign-host-isolation-exceptions-button')).toBeTruthy(); - }); - - it('Should display the count of total assigned policies', async () => { - getHostIsolationExceptionItemsMock.mockImplementation(() => { - return getFoundExceptionListItemSchemaMock(4); - }); - render(); - expect( - await renderResult.findByTestId('policyHostIsolationExceptionsTabSubtitle') - ).toHaveTextContent('There are 4 host isolation exceptions associated with this policy'); - }); - - describe('and the user is trying to assign policies', () => { - it('should not render the assign button if there are not existing exceptions', async () => { - getHostIsolationExceptionItemsMock.mockReturnValue(emptyList); - render(); - await waitFor(() => { - expect(getHostIsolationExceptionItemsMock).toHaveBeenCalledTimes(2); - }); - expect(renderResult.queryByTestId('hostIsolationExceptions-assign-button')).toBeFalsy(); - }); - - it('should not open the assign flyout if there are not existing exceptions', async () => { - history.push(getPolicyHostIsolationExceptionsPath(policyId, { show: 'list' })); - getHostIsolationExceptionItemsMock.mockReturnValue(emptyList); - render(); - await waitFor(() => { - expect(getHostIsolationExceptionItemsMock).toHaveBeenCalledTimes(2); - }); - expect(renderResult.queryByTestId('hostIsolationExceptions-assign-flyout')).toBeFalsy(); - }); - - it('should open the assign flyout if there are existing exceptions', async () => { - history.push(getPolicyHostIsolationExceptionsPath(policyId, { show: 'list' })); - getHostIsolationExceptionItemsMock.mockImplementation(() => { - return getFoundExceptionListItemSchemaMock(1); - }); - render(); - await waitFor(() => { - expect(getHostIsolationExceptionItemsMock).toHaveBeenCalledTimes(3); - }); - expect(await renderResult.findByTestId('hostIsolationExceptions-assign-flyout')).toBeTruthy(); - }); - }); - - describe('Without can isolate privileges', () => { - beforeEach(() => { - useUserPrivilegesMock.mockReturnValue({ - endpointPrivileges: { - canIsolateHost: false, - }, - }); - }); - it('should not display the assign policies button', async () => { - getHostIsolationExceptionItemsMock.mockImplementation(() => { - return getFoundExceptionListItemSchemaMock(5); - }); - render(); - expect(await renderResult.findByTestId('policyHostIsolationExceptionsTab')).toBeTruthy(); - expect(renderResult.queryByTestId('hostIsolationExceptions-assign-button')).toBeFalsy(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/host_isolation_exceptions_tab.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/host_isolation_exceptions_tab.tsx deleted file mode 100644 index f9ec756a8be7..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/host_isolation_exceptions_tab.tsx +++ /dev/null @@ -1,191 +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 { - EuiButton, - EuiLink, - EuiPageContent, - EuiPageHeader, - EuiPageHeaderSection, - EuiSpacer, - EuiText, - EuiTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useMemo } from 'react'; -import { useHistory } from 'react-router-dom'; -import { useUserPrivileges } from '../../../../../common/components/user_privileges'; -import { APP_UI_ID } from '../../../../../../common/constants'; -import { PolicyData } from '../../../../../../common/endpoint/types'; -import { useAppUrl } from '../../../../../common/lib/kibana'; -import { - MANAGEMENT_DEFAULT_PAGE, - MANAGEMENT_DEFAULT_PAGE_SIZE, -} from '../../../../common/constants'; -import { - getHostIsolationExceptionsListPath, - getPolicyHostIsolationExceptionsPath, -} from '../../../../common/routing'; -import { ManagementPageLoader } from '../../../../components/management_page_loader'; -import { useFetchHostIsolationExceptionsList } from '../../../host_isolation_exceptions/view/hooks'; -import { getCurrentArtifactsLocation } from '../../store/policy_details/selectors'; -import { usePolicyDetailsSelector } from '../policy_hooks'; -import { PolicyHostIsolationExceptionsAssignFlyout } from './components/assign_flyout'; -import { PolicyHostIsolationExceptionsEmptyUnassigned } from './components/empty_unassigned'; -import { PolicyHostIsolationExceptionsEmptyUnexisting } from './components/empty_unexisting'; -import { PolicyHostIsolationExceptionsList } from './components/list'; - -export const PolicyHostIsolationExceptionsTab = ({ policy }: { policy: PolicyData }) => { - const { getAppUrl } = useAppUrl(); - const privileges = useUserPrivileges().endpointPrivileges; - - const policyId = policy.id; - - const history = useHistory(); - const location = usePolicyDetailsSelector(getCurrentArtifactsLocation); - - const toHostIsolationList = getAppUrl({ - appId: APP_UI_ID, - path: getHostIsolationExceptionsListPath(), - }); - - const allPolicyExceptionsListRequest = useFetchHostIsolationExceptionsList({ - page: MANAGEMENT_DEFAULT_PAGE, - perPage: MANAGEMENT_DEFAULT_PAGE_SIZE, - policies: [policyId, 'all'], - }); - - const allExceptionsListRequest = useFetchHostIsolationExceptionsList({ - page: MANAGEMENT_DEFAULT_PAGE, - perPage: MANAGEMENT_DEFAULT_PAGE_SIZE, - // only do this request if no assigned policies found - enabled: allPolicyExceptionsListRequest.data?.total === 0, - }); - - const hasNoAssignedOrExistingExceptions = allPolicyExceptionsListRequest.data?.total === 0; - const hasNoExistingExceptions = allExceptionsListRequest.data?.total === 0; - - const subTitle = useMemo(() => { - const link = ( - - - - ); - - return allPolicyExceptionsListRequest.data ? ( - - ) : null; - }, [allPolicyExceptionsListRequest.data, toHostIsolationList]); - - const handleAssignButton = () => { - history.push( - getPolicyHostIsolationExceptionsPath(policyId, { - ...location, - show: 'list', - }) - ); - }; - - const handleFlyoutOnClose = () => { - history.push( - getPolicyHostIsolationExceptionsPath(policyId, { - ...location, - show: undefined, - }) - ); - }; - - const assignFlyout = - location.show === 'list' ? ( - - ) : null; - - const isLoading = - allPolicyExceptionsListRequest.isLoading || allExceptionsListRequest.isLoading || !policy; - - // render non-existent or non-assigned messages - if (!isLoading && (hasNoAssignedOrExistingExceptions || hasNoExistingExceptions)) { - if (hasNoExistingExceptions) { - return ; - } else { - return ( - <> - {assignFlyout} - - - ); - } - } - - // render header and list - return !isLoading ? ( -
    - {assignFlyout} - - - -

    - {i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.title', - { - defaultMessage: 'Assigned host isolation exceptions', - } - )} -

    -
    - - - - -

    {subTitle}

    -
    -
    - {privileges.canIsolateHost ? ( - - - {i18n.translate( - 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.layout.assignToPolicy', - { - defaultMessage: 'Assign host isolation exceptions to policy', - } - )} - - - ) : null} -
    - - - - - -
    - ) : ( - - ); -}; -PolicyHostIsolationExceptionsTab.displayName = 'PolicyHostIsolationExceptionsTab'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.test.tsx index 39139e5bb953..08b5475b4589 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.test.tsx @@ -58,7 +58,7 @@ describe('Fleet host isolation exceptions card filters card', () => { expect( renderResult.getByTestId('hostIsolationExceptions-fleet-integration-card') - ).toHaveTextContent('Host isolation exceptions applications5'); + ).toHaveTextContent('Host isolation exceptions5'); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.tsx index 66846c131ad1..7bb464e1ba6d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.tsx @@ -142,7 +142,7 @@ export const FleetIntegrationHostIsolationExceptionsCard = memo<{
    diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx index 7f7163b68e7c..1980877eea95 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx @@ -8,7 +8,7 @@ import React, { memo } from 'react'; import { i18n } from '@kbn/i18n'; import { useDispatch } from 'react-redux'; -import { OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { policyConfig } from '../../../store/policy_details/selectors'; import { setIn } from '../../../models/policy_details_config'; import { usePolicyDetailsSelector } from '../../policy_hooks'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx index 65b4ce9964d8..8bc1f0fcaf17 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx @@ -8,7 +8,7 @@ import React, { memo } from 'react'; import { i18n } from '@kbn/i18n'; import { useDispatch } from 'react-redux'; -import { OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { policyConfig } from '../../../store/policy_details/selectors'; import { setIn } from '../../../models/policy_details_config'; import { usePolicyDetailsSelector } from '../../policy_hooks'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx index eb48fc3ffa28..4ca72da6abfd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx @@ -8,7 +8,7 @@ import React, { memo } from 'react'; import { i18n } from '@kbn/i18n'; import { useDispatch } from 'react-redux'; -import { OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { policyConfig } from '../../../store/policy_details/selectors'; import { setIn } from '../../../models/policy_details_config'; import { usePolicyDetailsSelector } from '../../policy_hooks'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx index 4c358bc3e3a4..4d177c5cf6d3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx @@ -9,11 +9,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - Immutable, - OperatingSystem, - PolicyOperatingSystem, -} from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { Immutable, PolicyOperatingSystem } from '../../../../../../../common/endpoint/types'; import { BehaviorProtectionOSes } from '../../../types'; import { ConfigForm } from '../../components/config_form'; import { RadioButtons } from '../components/radio_buttons'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index e348b1b80222..9f9ac475d418 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -9,13 +9,10 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { APP_UI_ID } from '../../../../../../../common/constants'; import { SecurityPageName } from '../../../../../../app/types'; -import { - Immutable, - OperatingSystem, - PolicyOperatingSystem, -} from '../../../../../../../common/endpoint/types'; +import { Immutable, PolicyOperatingSystem } from '../../../../../../../common/endpoint/types'; import { MalwareProtectionOSes } from '../../../types'; import { ConfigForm } from '../../components/config_form'; import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/memory.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/memory.tsx index 82a14e4fa980..ae3b2f7a1abc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/memory.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/memory.tsx @@ -9,13 +9,10 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { APP_UI_ID } from '../../../../../../../common/constants'; import { SecurityPageName } from '../../../../../../app/types'; -import { - Immutable, - OperatingSystem, - PolicyOperatingSystem, -} from '../../../../../../../common/endpoint/types'; +import { Immutable, PolicyOperatingSystem } from '../../../../../../../common/endpoint/types'; import { MemoryProtectionOSes } from '../../../types'; import { ConfigForm } from '../../components/config_form'; import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx index 22266ef7351a..da1b2e06b3a0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx @@ -9,13 +9,10 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { APP_UI_ID } from '../../../../../../../common/constants'; import { SecurityPageName } from '../../../../../../app/types'; -import { - Immutable, - OperatingSystem, - PolicyOperatingSystem, -} from '../../../../../../../common/endpoint/types'; +import { Immutable, PolicyOperatingSystem } from '../../../../../../../common/endpoint/types'; import { RansomwareProtectionOSes } from '../../../types'; import { ConfigForm } from '../../components/config_form'; import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts index 2ea41063c2b7..2716f81d3230 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts @@ -5,10 +5,14 @@ * 2.0. */ -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { useSelector } from 'react-redux'; -import { i18n } from '@kbn/i18n'; +import { + ENDPOINT_BLOCKLISTS_LIST_ID, + ENDPOINT_EVENT_FILTERS_LIST_ID, + ENDPOINT_TRUSTED_APPS_LIST_ID, +} from '@kbn/securitysolution-list-constants'; import { PolicyDetailsArtifactsPageLocation, PolicyDetailsState } from '../types'; import { State } from '../../../../common/store'; import { @@ -16,18 +20,12 @@ import { MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, } from '../../../common/constants'; import { + getPolicyBlocklistsPath, getPolicyDetailsArtifactsListPath, getPolicyEventFiltersPath, getPolicyHostIsolationExceptionsPath, } from '../../../common/routing'; -import { - getCurrentArtifactsLocation, - getUpdateArtifacts, - getUpdateArtifactsLoaded, - getUpdateArtifactsIsFailed, - policyIdFromParams, -} from '../store/policy_details/selectors'; -import { useToasts } from '../../../../common/lib/kibana'; +import { getCurrentArtifactsLocation, policyIdFromParams } from '../store/policy_details/selectors'; /** * Narrows global state down to the PolicyDetailsState before calling the provided Policy Details Selector @@ -66,83 +64,40 @@ export function usePolicyDetailsNavigateCallback() { ); } -export function usePolicyDetailsEventFiltersNavigateCallback() { +export function usePolicyDetailsArtifactsNavigateCallback(listId: string) { const location = usePolicyDetailsSelector(getCurrentArtifactsLocation); const history = useHistory(); const policyId = usePolicyDetailsSelector(policyIdFromParams); - return useCallback( - (args: Partial) => - history.push( - getPolicyEventFiltersPath(policyId, { + const getPath = useCallback( + (args: Partial) => { + if (listId === ENDPOINT_TRUSTED_APPS_LIST_ID) { + return getPolicyDetailsArtifactsListPath(policyId, { ...location, ...args, - }) - ), - [history, location, policyId] + }); + } else if (listId === ENDPOINT_EVENT_FILTERS_LIST_ID) { + return getPolicyEventFiltersPath(policyId, { + ...location, + ...args, + }); + } else if (listId === ENDPOINT_BLOCKLISTS_LIST_ID) { + return getPolicyBlocklistsPath(policyId, { + ...location, + ...args, + }); + } else { + return getPolicyHostIsolationExceptionsPath(policyId, { + ...location, + ...args, + }); + } + }, + [listId, location, policyId] ); -} - -export function usePolicyDetailsHostIsolationExceptionsNavigateCallback() { - const location = usePolicyDetailsSelector(getCurrentArtifactsLocation); - const history = useHistory(); - const policyId = usePolicyDetailsSelector(policyIdFromParams); return useCallback( - (args: Partial) => - history.push( - getPolicyHostIsolationExceptionsPath(policyId, { - ...location, - ...args, - }) - ), - [history, location, policyId] + (args: Partial) => history.push(getPath(args)), + [getPath, history] ); } - -export const usePolicyTrustedAppsNotification = () => { - const updateSuccessfull = usePolicyDetailsSelector(getUpdateArtifactsLoaded); - const updateFailed = usePolicyDetailsSelector(getUpdateArtifactsIsFailed); - const updatedArtifacts = usePolicyDetailsSelector(getUpdateArtifacts); - const toasts = useToasts(); - const [wasAlreadyHandled] = useState(new WeakSet()); - - if (updateSuccessfull && updatedArtifacts && !wasAlreadyHandled.has(updatedArtifacts)) { - wasAlreadyHandled.add(updatedArtifacts); - const updateCount = updatedArtifacts.length; - - toasts.addSuccess({ - title: i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.toastSuccess.title', - { - defaultMessage: 'Success', - } - ), - text: - updateCount > 1 - ? i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.toastSuccess.textMultiples', - { - defaultMessage: '{count} trusted applications have been added to your list.', - values: { count: updateCount }, - } - ) - : i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.toastSuccess.textSingle', - { - defaultMessage: '"{name}" has been added to your trusted applications list.', - values: { name: updatedArtifacts[0].data.name }, - } - ), - }); - } else if (updateFailed) { - toasts.addDanger( - i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.toastError.text', - { - defaultMessage: `An error occurred updating artifacts`, - } - ) - ); - } -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts new file mode 100644 index 000000000000..9eb2d57a506b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +export const POLICY_ARTIFACT_BLOCKLISTS_LABELS = Object.freeze({ + deleteModalTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.list.removeDialog.title', + { + defaultMessage: 'Remove blocklist from policy', + } + ), + deleteModalImpactInfo: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.list.removeDialog.messageCallout', + { + defaultMessage: + 'This blocklist will be removed only from this policy and can still be found and managed from the artifact page.', + } + ), + deleteModalErrorMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.list.removeDialog.errorToastTitle', + { + defaultMessage: 'Error while attempting to remove blocklist', + } + ), + flyoutWarningCalloutMessage: (maxNumber: number) => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.searchWarning.text', + { + defaultMessage: + 'Only the first {maxNumber} blocklists are displayed. Please use the search bar to refine the results.', + values: { maxNumber }, + } + ), + flyoutNoArtifactsToBeAssignedMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.noAssignable', + { + defaultMessage: 'There are no blocklists that can be assigned to this policy.', + } + ), + flyoutTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.title', + { + defaultMessage: 'Assign blocklists', + } + ), + flyoutSubtitle: (policyName: string): string => + i18n.translate('xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.subtitle', { + defaultMessage: 'Select blocklists to add to {policyName}', + values: { policyName }, + }), + flyoutSearchPlaceholder: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.search.label', + { + defaultMessage: 'Search blocklists', + } + ), + flyoutErrorMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.toastError.text', + { + defaultMessage: `An error occurred updating blocklists`, + } + ), + flyoutSuccessMessageText: (updatedExceptions: ExceptionListItemSchema[]): string => + updatedExceptions.length > 1 + ? i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.toastSuccess.textMultiples', + { + defaultMessage: '{count} blocklists have been added to your list.', + values: { count: updatedExceptions.length }, + } + ) + : i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.toastSuccess.textSingle', + { + defaultMessage: '"{name}" has been added to your blocklist list.', + values: { name: updatedExceptions[0].name }, + } + ), + emptyUnassignedTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.empty.unassigned.title', + { defaultMessage: 'No assigned blocklists' } + ), + emptyUnassignedMessage: (policyName: string): string => + i18n.translate('xpack.securitySolution.endpoint.policy.blocklists.empty.unassigned.content', { + defaultMessage: + 'There are currently no blocklists assigned to {policyName}. Assign blocklists now or add and manage them on the blocklists page.', + values: { policyName }, + }), + emptyUnassignedPrimaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.empty.unassigned.primaryAction', + { + defaultMessage: 'Assign blocklists', + } + ), + emptyUnassignedSecondaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.empty.unassigned.secondaryAction', + { + defaultMessage: 'Manage blocklists', + } + ), + emptyUnexistingTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.empty.unexisting.title', + { defaultMessage: 'No blocklists exist' } + ), + emptyUnexistingMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.empty.unexisting.content', + { + defaultMessage: 'There are currently no blocklists applied to your endpoints.', + } + ), + emptyUnexistingPrimaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.empty.unexisting.action', + { defaultMessage: 'Add blocklists' } + ), + listTotalItemCountMessage: (totalItemsCount: number): string => + i18n.translate('xpack.securitySolution.endpoint.policy.blocklists.list.totalItemCount', { + defaultMessage: 'Showing {totalItemsCount, plural, one {# blocklist} other {# blocklists}}', + values: { totalItemsCount }, + }), + listRemoveActionNotAllowedMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.list.removeActionNotAllowed', + { + defaultMessage: 'Globally applied blocklist cannot be removed from policy.', + } + ), + listSearchPlaceholderMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.list.search.placeholder', + { + defaultMessage: `Search on the fields below: name, description, IP`, + } + ), + layoutTitle: i18n.translate('xpack.securitySolution.endpoint.policy.blocklists.layout.title', { + defaultMessage: 'Assigned blocklists', + }), + layoutAssignButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.assignToPolicy', + { + defaultMessage: 'Assign blocklists to policy', + } + ), + layoutViewAllLinkMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.about.viewAllLinkLabel', + { + defaultMessage: 'view all blocklists', + } + ), +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/event_filters_translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/event_filters_translations.ts new file mode 100644 index 000000000000..29b731a1eee5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/event_filters_translations.ts @@ -0,0 +1,152 @@ +/* + * Copyright 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 { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +export const POLICY_ARTIFACT_EVENT_FILTERS_LABELS = Object.freeze({ + deleteModalTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeDialog.title', + { + defaultMessage: 'Remove event filter from policy', + } + ), + deleteModalImpactInfo: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeDialog.messageCallout', + { + defaultMessage: + 'This event filter will be removed only from this policy and can still be found and managed from the artifact page.', + } + ), + deleteModalErrorMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeDialog.errorToastTitle', + { + defaultMessage: 'Error while attempting to remove event filter', + } + ), + flyoutWarningCalloutMessage: (maxNumber: number) => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.layout.flyout.searchWarning.text', + { + defaultMessage: + 'Only the first {maxNumber} event filters are displayed. Please use the search bar to refine the results.', + values: { maxNumber }, + } + ), + flyoutNoArtifactsToBeAssignedMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.layout.flyout.noAssignable', + { + defaultMessage: 'There are no event filters that can be assigned to this policy.', + } + ), + flyoutTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.layout.flyout.title', + { + defaultMessage: 'Assign event filters', + } + ), + flyoutSubtitle: (policyName: string): string => + i18n.translate('xpack.securitySolution.endpoint.policy.eventFilters.layout.flyout.subtitle', { + defaultMessage: 'Select event filters to add to {policyName}', + values: { policyName }, + }), + flyoutSearchPlaceholder: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.layout.search.label', + { + defaultMessage: 'Search event filters', + } + ), + flyoutErrorMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.layout.flyout.toastError.text', + { + defaultMessage: `An error occurred updating event filters`, + } + ), + flyoutSuccessMessageText: (updatedExceptions: ExceptionListItemSchema[]): string => + updatedExceptions.length > 1 + ? i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.layout.flyout.toastSuccess.textMultiples', + { + defaultMessage: '{count} event filters have been added to your list.', + values: { count: updatedExceptions.length }, + } + ) + : i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.layout.flyout.toastSuccess.textSingle', + { + defaultMessage: '"{name}" has been added to your event filter list.', + values: { name: updatedExceptions[0].name }, + } + ), + emptyUnassignedTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.empty.unassigned.title', + { defaultMessage: 'No assigned event filters' } + ), + emptyUnassignedMessage: (policyName: string): string => + i18n.translate('xpack.securitySolution.endpoint.policy.eventFilters.empty.unassigned.content', { + defaultMessage: + 'There are currently no event filters assigned to {policyName}. Assign event filters now or add and manage them on the event filters page.', + values: { policyName }, + }), + emptyUnassignedPrimaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.empty.unassigned.primaryAction', + { + defaultMessage: 'Assign event filters', + } + ), + emptyUnassignedSecondaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.empty.unassigned.secondaryAction', + { + defaultMessage: 'Manage event filters', + } + ), + emptyUnexistingTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.empty.unexisting.title', + { defaultMessage: 'No event filters exist' } + ), + emptyUnexistingMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.empty.unexisting.content', + { defaultMessage: 'There are currently no event filters applied to your endpoints.' } + ), + emptyUnexistingPrimaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.empty.unexisting.action', + { defaultMessage: 'Add event filters' } + ), + listTotalItemCountMessage: (totalItemsCount: number): string => + i18n.translate('xpack.securitySolution.endpoint.policy.eventFilters.list.totalItemCount', { + defaultMessage: + 'Showing {totalItemsCount, plural, one {# event filter} other {# event filters}}', + values: { totalItemsCount }, + }), + listRemoveActionNotAllowedMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeActionNotAllowed', + { + defaultMessage: 'Globally applied event filter cannot be removed from policy.', + } + ), + listSearchPlaceholderMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.list.search.placeholder', + { + defaultMessage: `Search on the fields below: name, description, comments, value`, + } + ), + layoutTitle: i18n.translate('xpack.securitySolution.endpoint.policy.eventFilters.layout.title', { + defaultMessage: 'Assigned event filters', + }), + layoutAssignButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.layout.assignToPolicy', + { + defaultMessage: 'Assign event filters to policy', + } + ), + layoutViewAllLinkMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.layout.about.viewAllLinkLabel', + { + defaultMessage: 'view all event filters', + } + ), +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/host_isolation_exceptions_translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/host_isolation_exceptions_translations.ts new file mode 100644 index 000000000000..2df0d18ac03d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/host_isolation_exceptions_translations.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 { i18n } from '@kbn/i18n'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +export const POLICY_ARTIFACT_HOST_ISOLATION_EXCEPTIONS_LABELS = Object.freeze({ + deleteModalTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.list.removeDialog.title', + { + defaultMessage: 'Remove host isolation exception from policy', + } + ), + deleteModalImpactInfo: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.list.removeDialog.messageCallout', + { + defaultMessage: + 'This host isolation exception will be removed only from this policy and can still be found and managed from the artifact page.', + } + ), + deleteModalErrorMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.list.removeDialog.errorToastTitle', + { + defaultMessage: 'Error while attempting to remove host isolation exception', + } + ), + flyoutWarningCalloutMessage: (maxNumber: number) => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.layout.flyout.searchWarning.text', + { + defaultMessage: + 'Only the first {maxNumber} host isolation exceptions are displayed. Please use the search bar to refine the results.', + values: { maxNumber }, + } + ), + flyoutNoArtifactsToBeAssignedMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.layout.flyout.noAssignable', + { + defaultMessage: 'There are no host isolation exceptions that can be assigned to this policy.', + } + ), + flyoutTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.layout.flyout.title', + { + defaultMessage: 'Assign host isolation exceptions', + } + ), + flyoutSubtitle: (policyName: string): string => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.layout.flyout.subtitle', + { + defaultMessage: 'Select host isolation exceptions to add to {policyName}', + values: { policyName }, + } + ), + flyoutSearchPlaceholder: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.layout.search.label', + { + defaultMessage: 'Search host isolation exceptions', + } + ), + flyoutErrorMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.layout.flyout.toastError.text', + { + defaultMessage: `An error occurred updating host isolation exceptions`, + } + ), + flyoutSuccessMessageText: (updatedExceptions: ExceptionListItemSchema[]): string => + updatedExceptions.length > 1 + ? i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.layout.flyout.toastSuccess.textMultiples', + { + defaultMessage: '{count} host isolation exceptions have been added to your list.', + values: { count: updatedExceptions.length }, + } + ) + : i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.layout.flyout.toastSuccess.textSingle', + { + defaultMessage: '"{name}" has been added to your host isolation exception list.', + values: { name: updatedExceptions[0].name }, + } + ), + emptyUnassignedTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.empty.unassigned.title', + { defaultMessage: 'No assigned host isolation exceptions' } + ), + emptyUnassignedMessage: (policyName: string): string => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.empty.unassigned.content', + { + defaultMessage: + 'There are currently no host isolation exceptions assigned to {policyName}. Assign host isolation exceptions now or add and manage them on the host isolation exceptions page.', + values: { policyName }, + } + ), + emptyUnassignedPrimaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.empty.unassigned.primaryAction', + { + defaultMessage: 'Assign host isolation exceptions', + } + ), + emptyUnassignedSecondaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.empty.unassigned.secondaryAction', + { + defaultMessage: 'Manage host isolation exceptions', + } + ), + emptyUnexistingTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.empty.unexisting.title', + { defaultMessage: 'No host isolation exceptions exist' } + ), + emptyUnexistingMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.empty.unexisting.content', + { + defaultMessage: 'There are currently no host isolation exceptions applied to your endpoints.', + } + ), + emptyUnexistingPrimaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.empty.unexisting.action', + { defaultMessage: 'Add host isolation exceptions' } + ), + listTotalItemCountMessage: (totalItemsCount: number): string => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.list.totalItemCount', + { + defaultMessage: + 'Showing {totalItemsCount, plural, one {# host isolation exception} other {# host isolation exceptions}}', + values: { totalItemsCount }, + } + ), + listRemoveActionNotAllowedMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.list.removeActionNotAllowed', + { + defaultMessage: 'Globally applied host isolation exception cannot be removed from policy.', + } + ), + listSearchPlaceholderMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.list.search.placeholder', + { + defaultMessage: `Search on the fields below: name, description, IP`, + } + ), + layoutTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.layout.title', + { + defaultMessage: 'Assigned host isolation exceptions', + } + ), + layoutAssignButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.layout.assignToPolicy', + { + defaultMessage: 'Assign host isolation exceptions to policy', + } + ), + layoutViewAllLinkMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.layout.about.viewAllLinkLabel', + { + defaultMessage: 'view all host isolation exceptions', + } + ), +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx index 02e0da1d0b91..17c880ffa626 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx @@ -7,16 +7,23 @@ import { EuiSpacer, EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import React, { useCallback, useEffect, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; -import { PolicyData } from '../../../../../../common/endpoint/types'; import { useUserPrivileges } from '../../../../../common/components/user_privileges'; import { getPolicyDetailPath, getPolicyEventFiltersPath, getPolicyHostIsolationExceptionsPath, getPolicyTrustedAppsPath, + getEventFiltersListPath, + getHostIsolationExceptionsListPath, + getTrustedAppsListPath, + getPolicyDetailsArtifactsListPath, + getBlocklistsListPath, + getPolicyBlocklistsPath, } from '../../../../common/routing'; +import { useHttp } from '../../../../../common/lib/kibana'; import { ManagementPageLoader } from '../../../../components/management_page_loader'; import { useFetchHostIsolationExceptionsList } from '../../../host_isolation_exceptions/view/hooks'; import { @@ -24,20 +31,32 @@ import { isOnPolicyEventFiltersView, isOnPolicyFormView, isOnPolicyTrustedAppsView, + isOnBlocklistsView, policyDetails, policyIdFromParams, } from '../../store/policy_details/selectors'; -import { PolicyEventFiltersLayout } from '../event_filters/layout'; -import { PolicyHostIsolationExceptionsTab } from '../host_isolation_exceptions/host_isolation_exceptions_tab'; +import { PolicyArtifactsLayout } from '../artifacts/layout/policy_artifacts_layout'; import { PolicyFormLayout } from '../policy_forms/components'; import { usePolicyDetailsSelector } from '../policy_hooks'; -import { PolicyTrustedAppsLayout } from '../trusted_apps/layout'; +import { POLICY_ARTIFACT_EVENT_FILTERS_LABELS } from './event_filters_translations'; +import { POLICY_ARTIFACT_TRUSTED_APPS_LABELS } from './trusted_apps_translations'; +import { POLICY_ARTIFACT_HOST_ISOLATION_EXCEPTIONS_LABELS } from './host_isolation_exceptions_translations'; +import { POLICY_ARTIFACT_BLOCKLISTS_LABELS } from './blocklists_translations'; +import { TrustedAppsApiClient } from '../../../trusted_apps/service/trusted_apps_api_client'; +import { EventFiltersApiClient } from '../../../event_filters/service/event_filters_api_client'; +import { BlocklistsApiClient } from '../../../blocklist/services/blocklists_api_client'; +import { HostIsolationExceptionsApiClient } from '../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; +import { SEARCHABLE_FIELDS as TRUSTED_APPS_SEARCHABLE_FIELDS } from '../../../trusted_apps/constants'; +import { SEARCHABLE_FIELDS as EVENT_FILTERS_SEARCHABLE_FIELDS } from '../../../event_filters/constants'; +import { SEARCHABLE_FIELDS as HOST_ISOLATION_EXCEPTIONS_SEARCHABLE_FIELDS } from '../../../host_isolation_exceptions/constants'; +import { SEARCHABLE_FIELDS as BLOCKLISTS_SEARCHABLE_FIELDS } from '../../../blocklist/constants'; const enum PolicyTabKeys { SETTINGS = 'settings', TRUSTED_APPS = 'trustedApps', EVENT_FILTERS = 'eventFilters', HOST_ISOLATION_EXCEPTIONS = 'hostIsolationExceptions', + BLOCKLISTS = 'blocklists', } interface PolicyTab { @@ -48,10 +67,12 @@ interface PolicyTab { export const PolicyTabs = React.memo(() => { const history = useHistory(); + const http = useHttp(); const isInSettingsTab = usePolicyDetailsSelector(isOnPolicyFormView); const isInTrustedAppsTab = usePolicyDetailsSelector(isOnPolicyTrustedAppsView); const isInEventFilters = usePolicyDetailsSelector(isOnPolicyEventFiltersView); const isInHostIsolationExceptionsTab = usePolicyDetailsSelector(isOnHostIsolationExceptionsView); + const isInBlocklistsTab = usePolicyDetailsSelector(isOnBlocklistsView); const policyId = usePolicyDetailsSelector(policyIdFromParams); const policyItem = usePolicyDetailsSelector(policyDetails); const privileges = useUserPrivileges().endpointPrivileges; @@ -76,7 +97,71 @@ export const PolicyTabs = React.memo(() => { } }, [canSeeHostIsolationExceptions, history, isInHostIsolationExceptionsTab, policyId]); + const getTrustedAppsApiClientInstance = useCallback( + () => TrustedAppsApiClient.getInstance(http), + [http] + ); + + const getEventFiltersApiClientInstance = useCallback( + () => EventFiltersApiClient.getInstance(http), + [http] + ); + + const getHostIsolationExceptionsApiClientInstance = useCallback( + () => HostIsolationExceptionsApiClient.getInstance(http), + [http] + ); + + const getBlocklistsApiClientInstance = useCallback( + () => BlocklistsApiClient.getInstance(http), + [http] + ); + const tabs: Record = useMemo(() => { + const trustedAppsLabels = { + ...POLICY_ARTIFACT_TRUSTED_APPS_LABELS, + layoutAboutMessage: (count: number, link: React.ReactElement): React.ReactNode => ( + + ), + }; + + const eventFiltersLabels = { + ...POLICY_ARTIFACT_EVENT_FILTERS_LABELS, + layoutAboutMessage: (count: number, link: React.ReactElement): React.ReactNode => ( + + ), + }; + + const hostIsolationExceptionsLabels = { + ...POLICY_ARTIFACT_HOST_ISOLATION_EXCEPTIONS_LABELS, + layoutAboutMessage: (count: number, link: React.ReactElement): React.ReactNode => ( + + ), + }; + + const blocklistsLabels = { + ...POLICY_ARTIFACT_BLOCKLISTS_LABELS, + layoutAboutMessage: (count: number, link: React.ReactElement): React.ReactNode => ( + + ), + }; + return { [PolicyTabKeys.SETTINGS]: { id: PolicyTabKeys.SETTINGS, @@ -98,7 +183,14 @@ export const PolicyTabs = React.memo(() => { content: ( <> - + ), }, @@ -110,7 +202,14 @@ export const PolicyTabs = React.memo(() => { content: ( <> - + ), }, @@ -126,13 +225,48 @@ export const PolicyTabs = React.memo(() => { content: ( <> - + ), } : undefined, + [PolicyTabKeys.BLOCKLISTS]: { + id: PolicyTabKeys.BLOCKLISTS, + name: i18n.translate('xpack.securitySolution.endpoint.policy.details.tabs.blocklists', { + defaultMessage: 'Blocklists', + }), + content: ( + <> + + + + ), + }, }; - }, [canSeeHostIsolationExceptions, policyItem]); + }, [ + canSeeHostIsolationExceptions, + getEventFiltersApiClientInstance, + getHostIsolationExceptionsApiClientInstance, + getBlocklistsApiClientInstance, + getTrustedAppsApiClientInstance, + policyItem, + privileges.canIsolateHost, + ]); // convert tabs object into an array EuiTabbedContent can understand const tabsList: PolicyTab[] = useMemo( @@ -152,10 +286,19 @@ export const PolicyTabs = React.memo(() => { selectedTab = tabs[PolicyTabKeys.EVENT_FILTERS]; } else if (isInHostIsolationExceptionsTab) { selectedTab = tabs[PolicyTabKeys.HOST_ISOLATION_EXCEPTIONS]; + } else if (isInBlocklistsTab) { + selectedTab = tabs[PolicyTabKeys.BLOCKLISTS]; } return selectedTab || defaultTab; - }, [tabs, isInSettingsTab, isInTrustedAppsTab, isInEventFilters, isInHostIsolationExceptionsTab]); + }, [ + tabs, + isInSettingsTab, + isInTrustedAppsTab, + isInEventFilters, + isInHostIsolationExceptionsTab, + isInBlocklistsTab, + ]); const onTabClickHandler = useCallback( (selectedTab: EuiTabbedContentTab) => { @@ -173,6 +316,9 @@ export const PolicyTabs = React.memo(() => { case PolicyTabKeys.HOST_ISOLATION_EXCEPTIONS: path = getPolicyHostIsolationExceptionsPath(policyId); break; + case PolicyTabKeys.BLOCKLISTS: + path = getPolicyBlocklistsPath(policyId); + break; } history.push(path); }, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_apps_translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_apps_translations.ts new file mode 100644 index 000000000000..f83568498df2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_apps_translations.ts @@ -0,0 +1,152 @@ +/* + * Copyright 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 { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +export const POLICY_ARTIFACT_TRUSTED_APPS_LABELS = Object.freeze({ + deleteModalTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeDialog.title', + { + defaultMessage: 'Remove trusted app from policy', + } + ), + deleteModalImpactInfo: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeDialog.messageCallout', + { + defaultMessage: + 'This trusted app will be removed only from this policy and can still be found and managed from the artifact page.', + } + ), + deleteModalErrorMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeDialog.errorToastTitle', + { + defaultMessage: 'Error while attempting to remove trusted app', + } + ), + flyoutWarningCalloutMessage: (maxNumber: number) => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.searchWarning.text', + { + defaultMessage: + 'Only the first {maxNumber} trusted apps are displayed. Please use the search bar to refine the results.', + values: { maxNumber }, + } + ), + flyoutNoArtifactsToBeAssignedMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.noAssignable', + { + defaultMessage: 'There are no trusted apps that can be assigned to this policy.', + } + ), + flyoutTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.title', + { + defaultMessage: 'Assign trusted apps', + } + ), + flyoutSubtitle: (policyName: string): string => + i18n.translate('xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.subtitle', { + defaultMessage: 'Select trusted apps to add to {policyName}', + values: { policyName }, + }), + flyoutSearchPlaceholder: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.search.label', + { + defaultMessage: 'Search trusted apps', + } + ), + flyoutErrorMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.toastError.text', + { + defaultMessage: `An error occurred updating trusted apps`, + } + ), + flyoutSuccessMessageText: (updatedExceptions: ExceptionListItemSchema[]): string => + updatedExceptions.length > 1 + ? i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.toastSuccess.textMultiples', + { + defaultMessage: '{count} trusted apps have been added to your list.', + values: { count: updatedExceptions.length }, + } + ) + : i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.toastSuccess.textSingle', + { + defaultMessage: '"{name}" has been added to your trusted app list.', + values: { name: updatedExceptions[0].name }, + } + ), + emptyUnassignedTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.empty.unassigned.title', + { defaultMessage: 'No assigned trusted apps' } + ), + emptyUnassignedMessage: (policyName: string): string => + i18n.translate('xpack.securitySolution.endpoint.policy.trustedApps.empty.unassigned.content', { + defaultMessage: + 'There are currently no trusted apps assigned to {policyName}. Assign trusted apps now or add and manage them on the trusted apps page.', + values: { policyName }, + }), + emptyUnassignedPrimaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.empty.unassigned.primaryAction', + { + defaultMessage: 'Assign trusted apps', + } + ), + emptyUnassignedSecondaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.empty.unassigned.secondaryAction', + { + defaultMessage: 'Manage trusted apps', + } + ), + emptyUnexistingTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.empty.unexisting.title', + { defaultMessage: 'No trusted apps exist' } + ), + emptyUnexistingMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.empty.unexisting.content', + { defaultMessage: 'There are currently no trusted apps applied to your endpoints.' } + ), + emptyUnexistingPrimaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.empty.unexisting.action', + { defaultMessage: 'Add trusted apps' } + ), + listTotalItemCountMessage: (totalItemsCount: number): string => + i18n.translate('xpack.securitySolution.endpoint.policy.trustedApps.list.totalItemCount', { + defaultMessage: + 'Showing {totalItemsCount, plural, one {# trusted app} other {# trusted apps}}', + values: { totalItemsCount }, + }), + listRemoveActionNotAllowedMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeActionNotAllowed', + { + defaultMessage: 'Globally applied trusted app cannot be removed from policy.', + } + ), + listSearchPlaceholderMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.list.search.placeholder', + { + defaultMessage: `Search on the fields below: name, description, value`, + } + ), + layoutTitle: i18n.translate('xpack.securitySolution.endpoint.policy.trustedApps.layout.title', { + defaultMessage: 'Assigned trusted apps', + }), + layoutAssignButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.assignToPolicy', + { + defaultMessage: 'Assign trusted apps to policy', + } + ), + layoutViewAllLinkMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.about.viewAllLinkLabel', + { + defaultMessage: 'view all trusted apps', + } + ), +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/index.ts deleted file mode 100644 index aa9048426c49..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/index.ts +++ /dev/null @@ -1,9 +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. - */ - -export { PolicyTrustedAppsEmptyUnassigned } from './policy_trusted_apps_empty_unassigned'; -export { PolicyTrustedAppsEmptyUnexisting } from './policy_trusted_apps_empty_unexisting'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/policy_trusted_apps_empty_unassigned.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/policy_trusted_apps_empty_unassigned.tsx deleted file mode 100644 index 3a7308fef75f..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/policy_trusted_apps_empty_unassigned.tsx +++ /dev/null @@ -1,80 +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, { memo, useCallback } from 'react'; -import { EuiEmptyPrompt, EuiButton, EuiPageTemplate, EuiLink } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { usePolicyDetailsNavigateCallback } from '../../policy_hooks'; -import { useGetLinkTo } from './use_policy_trusted_apps_empty_hooks'; -import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; - -interface CommonProps { - policyId: string; - policyName: string; -} - -export const PolicyTrustedAppsEmptyUnassigned = memo(({ policyId, policyName }) => { - const { canCreateArtifactsByPolicy } = useUserPrivileges().endpointPrivileges; - const navigateCallback = usePolicyDetailsNavigateCallback(); - const { onClickHandler, toRouteUrl } = useGetLinkTo(policyId, policyName); - const onClickPrimaryButtonHandler = useCallback( - () => - navigateCallback({ - show: 'list', - }), - [navigateCallback] - ); - return ( - - - - - } - body={ - - } - actions={[ - ...(canCreateArtifactsByPolicy - ? [ - - - , - ] - : []), - // eslint-disable-next-line @elastic/eui/href-or-on-click - - - , - ]} - /> - - ); -}); - -PolicyTrustedAppsEmptyUnassigned.displayName = 'PolicyTrustedAppsEmptyUnassigned'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/policy_trusted_apps_empty_unexisting.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/policy_trusted_apps_empty_unexisting.tsx deleted file mode 100644 index 1fe834a9fce4..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/policy_trusted_apps_empty_unexisting.tsx +++ /dev/null @@ -1,53 +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, { memo } from 'react'; -import { EuiEmptyPrompt, EuiButton, EuiPageTemplate } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { useGetLinkTo } from './use_policy_trusted_apps_empty_hooks'; - -interface CommonProps { - policyId: string; - policyName: string; -} - -export const PolicyTrustedAppsEmptyUnexisting = memo(({ policyId, policyName }) => { - const { onClickHandler, toRouteUrl } = useGetLinkTo(policyId, policyName, { show: 'create' }); - return ( - - - - - } - body={ - - } - actions={ - // eslint-disable-next-line @elastic/eui/href-or-on-click - - - - } - /> - - ); -}); - -PolicyTrustedAppsEmptyUnexisting.displayName = 'PolicyTrustedAppsEmptyUnexisting'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/use_policy_trusted_apps_empty_hooks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/use_policy_trusted_apps_empty_hooks.ts deleted file mode 100644 index f393f1f436d1..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/use_policy_trusted_apps_empty_hooks.ts +++ /dev/null @@ -1,65 +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 { useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { useNavigateToAppEventHandler } from '../../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; -import { useAppUrl } from '../../../../../../common/lib/kibana/hooks'; -import { getPolicyTrustedAppsPath, getTrustedAppsListPath } from '../../../../../common/routing'; -import { APP_UI_ID } from '../../../../../../../common/constants'; -import { TrustedAppsListPageLocation } from '../../../../trusted_apps/state'; - -export const useGetLinkTo = ( - policyId: string, - policyName: string, - location?: Partial -) => { - const { getAppUrl } = useAppUrl(); - const { toRoutePath, toRouteUrl } = useMemo(() => { - const path = getTrustedAppsListPath(location); - return { - toRoutePath: path, - toRouteUrl: getAppUrl({ path }), - }; - }, [getAppUrl, location]); - - const policyTrustedAppsPath = useMemo(() => getPolicyTrustedAppsPath(policyId), [policyId]); - const policyTrustedAppRouteState = useMemo(() => { - return { - backButtonLabel: i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.empty.unassigned.backButtonLabel', - { - defaultMessage: 'Back to {policyName} policy', - values: { - policyName, - }, - } - ), - onBackButtonNavigateTo: [ - APP_UI_ID, - { - path: policyTrustedAppsPath, - }, - ], - backButtonUrl: getAppUrl({ - appId: APP_UI_ID, - path: policyTrustedAppsPath, - }), - }; - }, [getAppUrl, policyName, policyTrustedAppsPath]); - - const onClickHandler = useNavigateToAppEventHandler(APP_UI_ID, { - state: policyTrustedAppRouteState, - path: toRoutePath, - }); - - return { - onClickHandler, - toRouteUrl, - state: policyTrustedAppRouteState, - }; -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/index.ts deleted file mode 100644 index d3090a340fa2..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/index.ts +++ /dev/null @@ -1,8 +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. - */ - -export { PolicyTrustedAppsFlyout } from './policy_trusted_apps_flyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx deleted file mode 100644 index a9aabbdf3498..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx +++ /dev/null @@ -1,192 +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 from 'react'; -import { PolicyTrustedAppsFlyout } from './policy_trusted_apps_flyout'; -import * as reactTestingLibrary from '@testing-library/react'; -import { fireEvent } from '@testing-library/dom'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { MiddlewareActionSpyHelper } from '../../../../../../common/store/test_utils'; - -import { PolicyDetailsState } from '../../../types'; -import { createLoadedResourceState, isLoadedResourceState } from '../../../../../state'; -import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; -import { trustedAppsAllHttpMocks } from '../../../../mocks'; -import { HttpFetchOptionsWithPath } from 'kibana/public'; -import { isArtifactByPolicy } from '../../../../../../../common/endpoint/service/artifacts'; - -jest.mock('../../../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'); - -let mockedContext: AppContextTestRender; -let waitForAction: MiddlewareActionSpyHelper['waitForAction']; -let render: () => ReturnType; -let mockedApis: ReturnType; -const act = reactTestingLibrary.act; -let getState: () => PolicyDetailsState; - -describe('Policy trusted apps flyout', () => { - beforeEach(() => { - mockedContext = createAppRootMockRenderer(); - waitForAction = mockedContext.middlewareSpy.waitForAction; - mockedApis = trustedAppsAllHttpMocks(mockedContext.coreStart.http); - getState = () => mockedContext.store.getState().management.policyDetails; - render = () => mockedContext.render(); - - const getTaListApiResponseMock = - mockedApis.responseProvider.trustedAppsList.getMockImplementation(); - mockedApis.responseProvider.trustedAppsList.mockImplementation((options) => { - const response = getTaListApiResponseMock!(options); - response.data = response.data.filter((ta) => isArtifactByPolicy(ta)); - return response; - }); - }); - - afterEach(() => reactTestingLibrary.cleanup()); - - it('should renders flyout open correctly without assignable data', async () => { - const waitAssignableListExist = waitForAction('policyArtifactsAssignableListExistDataChanged', { - validate: (action) => isLoadedResourceState(action.payload), - }); - - mockedApis.responseProvider.trustedAppsList.mockReturnValue({ - data: [], - total: 0, - per_page: 10, - page: 1, - }); - - const component = render(); - - mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { show: 'list' })); - - await waitForAction('policyArtifactsAssignableListPageDataChanged', { - validate: (action) => isLoadedResourceState(action.payload), - }); - await waitAssignableListExist; - - expect(component.getByTestId('confirmPolicyTrustedAppsFlyout')).not.toBeNull(); - expect(component.getByTestId('noAssignableItemsTrustedAppsFlyout')).not.toBeNull(); - }); - - it('should renders flyout open correctly without data', async () => { - mockedApis.responseProvider.trustedAppsList.mockReturnValue({ - data: [], - total: 0, - per_page: 10, - page: 1, - }); - const component = render(); - - mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { show: 'list' })); - await waitForAction('policyArtifactsAssignableListPageDataChanged', { - validate: (action) => isLoadedResourceState(action.payload), - }); - - mockedContext.store.dispatch({ - type: 'policyArtifactsAssignableListExistDataChanged', - payload: createLoadedResourceState(true), - }); - - expect(component.getByTestId('confirmPolicyTrustedAppsFlyout')).not.toBeNull(); - expect(component.getByTestId('noItemsFoundTrustedAppsFlyout')).not.toBeNull(); - }); - - it('should renders flyout open correctly', async () => { - const component = render(); - - mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { show: 'list' })); - await waitForAction('policyArtifactsAssignableListPageDataChanged', { - validate: (action) => isLoadedResourceState(action.payload), - }); - - expect(component.getByTestId('confirmPolicyTrustedAppsFlyout')).not.toBeNull(); - expect(component.getByTestId('Generated Exception (nng74)_checkbox')).not.toBeNull(); - }); - - it('should confirm flyout action', async () => { - const component = render(); - - mockedContext.history.push( - getPolicyDetailsArtifactsListPath('2d95bec3-b48f-4db7-9622-a2b061cc031d', { show: 'list' }) - ); - await waitForAction('policyArtifactsAssignableListPageDataChanged', { - validate: (action) => isLoadedResourceState(action.payload), - }); - - // TA name below in the selector matches the 3rd generated trusted app which is policy specific - const tACardCheckbox = component.getByTestId('Generated Exception (nng74)_checkbox'); - - act(() => { - fireEvent.click(tACardCheckbox); - }); - - const waitChangeUrl = waitForAction('userChangedUrl'); - const confirmButton = component.getByTestId('confirmPolicyTrustedAppsFlyout'); - - act(() => { - fireEvent.click(confirmButton); - }); - - await waitChangeUrl; - - const currentLocation = getState().artifacts.location; - - expect(currentLocation.show).toBeUndefined(); - }); - - it('should cancel flyout action', async () => { - const waitChangeUrl = waitForAction('userChangedUrl'); - const component = render(); - - mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { show: 'list' })); - await waitForAction('policyArtifactsAssignableListPageDataChanged', { - validate: (action) => isLoadedResourceState(action.payload), - }); - - const cancelButton = component.getByTestId('cancelPolicyTrustedAppsFlyout'); - - await act(async () => { - fireEvent.click(cancelButton); - }); - - await waitChangeUrl; - const currentLocation = getState().artifacts.location; - expect(currentLocation.show).toBeUndefined(); - }); - - it('should display warning message when too much results', async () => { - const listResponse = { - ...mockedApis.responseProvider.trustedAppsList.getMockImplementation()!({ - query: {}, - } as HttpFetchOptionsWithPath), - total: 101, - }; - mockedApis.responseProvider.trustedAppsList.mockReturnValue(listResponse); - - const component = render(); - - mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { show: 'list' })); - await waitForAction('policyArtifactsAssignableListPageDataChanged', { - validate: (action) => isLoadedResourceState(action.payload), - }); - - expect(component.getByTestId('tooMuchResultsWarningMessageTrustedAppsFlyout')).not.toBeNull(); - }); - - it('should not display warning message when few results', async () => { - const component = render(); - - mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { show: 'list' })); - await waitForAction('policyArtifactsAssignableListPageDataChanged', { - validate: (action) => isLoadedResourceState(action.payload), - }); - - expect(component.queryByTestId('tooMuchResultsWarningMessageTrustedAppsFlyout')).toBeNull(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.tsx deleted file mode 100644 index a5bbff4a644b..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.tsx +++ /dev/null @@ -1,264 +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, { useMemo, useState, useCallback, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; - -import { FormattedMessage } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; -import { isEmpty, without } from 'lodash/fp'; -import { - EuiButton, - EuiTitle, - EuiFlyout, - EuiFlyoutHeader, - EuiFlyoutBody, - EuiSpacer, - EuiFlyoutFooter, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiCallOut, - EuiEmptyPrompt, -} from '@elastic/eui'; -import { Dispatch } from 'redux'; -import { - policyDetails, - getAssignableArtifactsList, - getAssignableArtifactsListIsLoading, - getUpdateArtifactsIsLoading, - getUpdateArtifactsLoaded, - getAssignableArtifactsListExist, - getAssignableArtifactsListExistIsLoading, -} from '../../../store/policy_details/selectors'; -import { - usePolicyDetailsNavigateCallback, - usePolicyDetailsSelector, - usePolicyTrustedAppsNotification, -} from '../../policy_hooks'; -import { PolicyArtifactsAssignableList } from '../../artifacts/assignable'; -import { SearchExceptions } from '../../../../../components/search_exceptions'; -import { AppAction } from '../../../../../../common/store/actions'; -import { MaybeImmutable, TrustedApp } from '../../../../../../../common/endpoint/types'; - -export const PolicyTrustedAppsFlyout = React.memo(() => { - usePolicyTrustedAppsNotification(); - const dispatch = useDispatch>(); - const [selectedArtifactIds, setSelectedArtifactIds] = useState([]); - const policyItem = usePolicyDetailsSelector(policyDetails); - const assignableArtifactsList = usePolicyDetailsSelector(getAssignableArtifactsList); - const isAssignableArtifactsListLoading = usePolicyDetailsSelector( - getAssignableArtifactsListIsLoading - ); - const isUpdateArtifactsLoading = usePolicyDetailsSelector(getUpdateArtifactsIsLoading); - const isUpdateArtifactsLoaded = usePolicyDetailsSelector(getUpdateArtifactsLoaded); - const isAssignableArtifactsListExist = usePolicyDetailsSelector(getAssignableArtifactsListExist); - const isAssignableArtifactsListExistLoading = usePolicyDetailsSelector( - getAssignableArtifactsListExistIsLoading - ); - - const navigateCallback = usePolicyDetailsNavigateCallback(); - - const policyName = policyItem?.name ?? ''; - - const handleListFlyoutClose = useCallback( - () => - navigateCallback({ - show: undefined, - }), - [navigateCallback] - ); - - useEffect(() => { - if (isUpdateArtifactsLoaded) { - handleListFlyoutClose(); - dispatch({ - type: 'policyArtifactsUpdateTrustedAppsChanged', - payload: { type: 'UninitialisedResourceState' }, - }); - } - }, [dispatch, handleListFlyoutClose, isUpdateArtifactsLoaded]); - - const handleOnConfirmAction = useCallback(() => { - dispatch({ - type: 'policyArtifactsUpdateTrustedApps', - payload: { - action: 'assign', - artifacts: selectedArtifactIds.map>((selectedId) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return assignableArtifactsList?.data?.find((trustedApp) => trustedApp.id === selectedId)!; - }), - }, - }); - }, [assignableArtifactsList?.data, dispatch, selectedArtifactIds]); - - const handleOnSearch = useCallback( - (filter) => { - dispatch({ - type: 'policyArtifactsAssignableListPageDataFilter', - payload: { filter }, - }); - }, - [dispatch] - ); - - const searchWarningMessage = useMemo( - () => ( - <> - - {i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.searchWarning.text', - { - defaultMessage: - 'Only the first 100 trusted applications are displayed. Please use the search bar to refine the results.', - } - )} - - - - ), - [] - ); - - const canShowPolicyArtifactsAssignableList = useMemo( - () => - isAssignableArtifactsListExistLoading || - isAssignableArtifactsListLoading || - !isEmpty(assignableArtifactsList?.data), - [ - assignableArtifactsList?.data, - isAssignableArtifactsListExistLoading, - isAssignableArtifactsListLoading, - ] - ); - - const entriesExists = useMemo( - () => isEmpty(assignableArtifactsList?.data) && isAssignableArtifactsListExist, - [assignableArtifactsList?.data, isAssignableArtifactsListExist] - ); - - return ( - - - -

    - -

    -
    - - -
    - - {(assignableArtifactsList?.total || 0) > 100 ? searchWarningMessage : null} - - - - {canShowPolicyArtifactsAssignableList ? ( - { - setSelectedArtifactIds((currentSelectedArtifactIds) => - selected - ? [...currentSelectedArtifactIds, artifactId] - : without([artifactId], currentSelectedArtifactIds) - ); - }} - /> - ) : entriesExists ? ( - - } - /> - ) : ( - - } - /> - )} - - - - - - - - - - - - - - - -
    - ); -}); - -PolicyTrustedAppsFlyout.displayName = 'PolicyTrustedAppsFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/index.ts deleted file mode 100644 index 6819bc1695cf..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/index.ts +++ /dev/null @@ -1,8 +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. - */ - -export { PolicyTrustedAppsLayout } from './policy_trusted_apps_layout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx deleted file mode 100644 index c0e0cbc71500..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx +++ /dev/null @@ -1,198 +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 from 'react'; -import { PolicyTrustedAppsLayout } from './policy_trusted_apps_layout'; -import * as reactTestingLibrary from '@testing-library/react'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { MiddlewareActionSpyHelper } from '../../../../../../common/store/test_utils'; - -import { createLoadedResourceState, isLoadedResourceState } from '../../../../../state'; -import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; -import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data'; -import { policyListApiPathHandlers } from '../../../store/test_mock_utils'; -import { useEndpointPrivileges } from '../../../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'; -import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; -import { PACKAGE_POLICY_API_ROOT, AGENT_API_ROUTES } from '../../../../../../../../fleet/common'; -import { trustedAppsAllHttpMocks } from '../../../../mocks'; -import { HttpFetchOptionsWithPath } from 'kibana/public'; -import { ExceptionsListItemGenerator } from '../../../../../../../common/endpoint/data_generators/exceptions_list_item_generator'; - -jest.mock('../../../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'); -const mockUseEndpointPrivileges = useEndpointPrivileges as jest.Mock; - -let mockedContext: AppContextTestRender; -let waitForAction: MiddlewareActionSpyHelper['waitForAction']; -let render: () => ReturnType; -let coreStart: AppContextTestRender['coreStart']; -let http: typeof coreStart.http; -let mockedApis: ReturnType; -const generator = new EndpointDocGenerator(); - -describe('Policy trusted apps layout', () => { - beforeEach(() => { - mockedContext = createAppRootMockRenderer(); - http = mockedContext.coreStart.http; - - const policyListApiHandlers = policyListApiPathHandlers(); - - http.get.mockImplementation((...args) => { - const [path] = args; - if (typeof path === 'string') { - // GET datasouce - if (path === `${PACKAGE_POLICY_API_ROOT}/1234`) { - return Promise.resolve({ - item: generator.generatePolicyPackagePolicy(), - success: true, - }); - } - - // GET Agent status for agent policy - if (path === `${AGENT_API_ROUTES.STATUS_PATTERN}`) { - return Promise.resolve({ - results: { events: 0, total: 5, online: 3, error: 1, offline: 1 }, - success: true, - }); - } - - // Get package data - // Used in tests that route back to the list - if (policyListApiHandlers[path]) { - return Promise.resolve(policyListApiHandlers[path]()); - } - } - - return Promise.reject(new Error(`unknown API call (not MOCKED): ${path}`)); - }); - - mockedApis = trustedAppsAllHttpMocks(http); - waitForAction = mockedContext.middlewareSpy.waitForAction; - render = () => mockedContext.render(); - }); - - afterAll(() => { - mockUseEndpointPrivileges.mockReset(); - }); - - afterEach(() => reactTestingLibrary.cleanup()); - - it('should renders layout with no existing TA data', async () => { - mockedApis.responseProvider.trustedAppsList.mockImplementation(() => ({ - data: [], - page: 1, - per_page: 10, - total: 0, - })); - mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234')); - const component = render(); - - await waitForAction('policyArtifactsHasTrustedApps', { - validate: (action) => isLoadedResourceState(action.payload), - }); - - expect(component.getByTestId('policy-trusted-apps-empty-unexisting')).not.toBeNull(); - }); - - it('should renders layout with no assigned TA data', async () => { - mockedApis.responseProvider.trustedAppsList.mockImplementation(() => ({ - data: [], - page: 1, - per_page: 10, - total: 0, - })); - mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234')); - const component = render(); - - await waitForAction('policyArtifactsHasTrustedApps', { - validate: (action) => isLoadedResourceState(action.payload), - }); - - mockedContext.store.dispatch({ - type: 'policyArtifactsDeosAnyTrustedAppExists', - payload: createLoadedResourceState({ data: [], total: 1 }), - }); - - expect(component.getByTestId('policy-trusted-apps-empty-unassigned')).not.toBeNull(); - }); - - it('should renders layout with data', async () => { - mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234')); - const component = render(); - - await waitForAction('policyArtifactsHasTrustedApps', { - validate: (action) => isLoadedResourceState(action.payload), - }); - - expect(component.getAllByTestId('policyTrustedAppsGrid-card')).toHaveLength(10); - }); - - it('should renders layout with data but no results', async () => { - mockedApis.responseProvider.trustedAppsList.mockImplementation( - (options: HttpFetchOptionsWithPath) => { - const hasAnyQuery = - '(exception-list-agnostic.attributes.tags:"policy:1234" OR exception-list-agnostic.attributes.tags:"policy:all")'; - if (options.query?.filter === hasAnyQuery) { - const exceptionsGenerator = new ExceptionsListItemGenerator('seed'); - return { - data: Array.from({ length: 10 }, () => - exceptionsGenerator.generate({ os_types: ['windows'] }) - ), - total: 10, - page: 0, - per_page: 10, - }; - } else { - return { data: [], total: 0, page: 0, per_page: 10 }; - } - } - ); - - const component = render(); - mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { filter: 'search' })); - - await waitForAction('policyArtifactsHasTrustedApps', { - validate: (action) => isLoadedResourceState(action.payload), - }); - - expect(component.queryAllByTestId('policyTrustedAppsGrid-card')).toHaveLength(0); - expect(component.queryByTestId('policy-trusted-apps-empty-unassigned')).toBeNull(); - expect(component.queryByTestId('policy-trusted-apps-empty-unexisting')).toBeNull(); - }); - - it('should hide assign button on empty state with unassigned policies when downgraded to a gold or below license', async () => { - mockUseEndpointPrivileges.mockReturnValue( - getEndpointPrivilegesInitialStateMock({ - canCreateArtifactsByPolicy: false, - }) - ); - const component = render(); - mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234')); - - await waitForAction('assignedTrustedAppsListStateChanged'); - - mockedContext.store.dispatch({ - type: 'policyArtifactsDeosAnyTrustedAppExists', - payload: createLoadedResourceState(true), - }); - expect(component.queryByTestId('assign-ta-button')).toBeNull(); - }); - - it('should hide the `Assign trusted applications` button when there is data and the license is downgraded to gold or below', async () => { - mockUseEndpointPrivileges.mockReturnValue( - getEndpointPrivilegesInitialStateMock({ - canCreateArtifactsByPolicy: false, - }) - ); - const component = render(); - mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234')); - - await waitForAction('assignedTrustedAppsListStateChanged'); - expect(component.queryByTestId('assignTrustedAppButton')).toBeNull(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx deleted file mode 100644 index dd89cca43c10..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx +++ /dev/null @@ -1,179 +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, { useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiButton, - EuiTitle, - EuiPageHeader, - EuiPageHeaderSection, - EuiPageContent, - EuiText, - EuiSpacer, - EuiLink, -} from '@elastic/eui'; -import { PolicyTrustedAppsEmptyUnassigned, PolicyTrustedAppsEmptyUnexisting } from '../empty'; -import { - getCurrentArtifactsLocation, - getDoesTrustedAppExists, - policyDetails, - doesTrustedAppExistsLoading, - getTotalPolicyTrustedAppsListPagination, - getHasTrustedApps, - getIsLoadedHasTrustedApps, -} from '../../../store/policy_details/selectors'; -import { usePolicyDetailsNavigateCallback, usePolicyDetailsSelector } from '../../policy_hooks'; -import { PolicyTrustedAppsFlyout } from '../flyout'; -import { PolicyTrustedAppsList } from '../list/policy_trusted_apps_list'; -import { useAppUrl } from '../../../../../../common/lib/kibana'; -import { APP_UI_ID } from '../../../../../../../common/constants'; -import { getTrustedAppsListPath } from '../../../../../common/routing'; -import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; -import { ManagementPageLoader } from '../../../../../components/management_page_loader'; - -export const PolicyTrustedAppsLayout = React.memo(() => { - const { getAppUrl } = useAppUrl(); - const location = usePolicyDetailsSelector(getCurrentArtifactsLocation); - const doesTrustedAppExists = usePolicyDetailsSelector(getDoesTrustedAppExists); - const isDoesTrustedAppExistsLoading = usePolicyDetailsSelector(doesTrustedAppExistsLoading); - const policyItem = usePolicyDetailsSelector(policyDetails); - const navigateCallback = usePolicyDetailsNavigateCallback(); - const { canCreateArtifactsByPolicy } = useUserPrivileges().endpointPrivileges; - const totalAssignedCount = usePolicyDetailsSelector(getTotalPolicyTrustedAppsListPagination); - const hasTrustedApps = usePolicyDetailsSelector(getHasTrustedApps); - const isLoadedHasTrustedApps = usePolicyDetailsSelector(getIsLoadedHasTrustedApps); - - const showListFlyout = location.show === 'list'; - - const assignTrustedAppButton = useMemo( - () => ( - - navigateCallback({ - show: 'list', - }) - } - > - {i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.layout.assignToPolicy', - { - defaultMessage: 'Assign trusted applications to policy', - } - )} - - ), - [navigateCallback] - ); - - const isDisplaysEmptyStateLoading = useMemo( - () => !isLoadedHasTrustedApps || isDoesTrustedAppExistsLoading, - [isLoadedHasTrustedApps, isDoesTrustedAppExistsLoading] - ); - - const displaysEmptyState = useMemo( - () => !isDisplaysEmptyStateLoading && !hasTrustedApps, - [isDisplaysEmptyStateLoading, hasTrustedApps] - ); - - const displayHeaderAndContent = useMemo( - () => !isDisplaysEmptyStateLoading && !displaysEmptyState && isLoadedHasTrustedApps, - [displaysEmptyState, isDisplaysEmptyStateLoading, isLoadedHasTrustedApps] - ); - - const aboutInfo = useMemo(() => { - const link = ( - - - - ); - - return ( - - ); - }, [getAppUrl, totalAssignedCount]); - - return policyItem ? ( -
    - {displayHeaderAndContent ? ( - <> - - - -

    - {i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.layout.title', - { - defaultMessage: 'Assigned trusted applications', - } - )} -

    -
    - - - - -

    {aboutInfo}

    -
    -
    - - - {canCreateArtifactsByPolicy && assignTrustedAppButton} - -
    - - - - ) : null} - - {displaysEmptyState && !isDoesTrustedAppExistsLoading ? ( - doesTrustedAppExists ? ( - - ) : ( - - ) - ) : displayHeaderAndContent ? ( - - ) : ( - - )} - - {canCreateArtifactsByPolicy && showListFlyout ? : null} -
    - ) : null; -}); - -PolicyTrustedAppsLayout.displayName = 'PolicyTrustedAppsLayout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx deleted file mode 100644 index da304adc2db4..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx +++ /dev/null @@ -1,346 +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 { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; -import { PolicyTrustedAppsList, PolicyTrustedAppsListProps } from './policy_trusted_apps_list'; -import React from 'react'; -import { policyDetailsPageAllApiHttpMocks } from '../../../test_utils'; -import { isFailedResourceState, isLoadedResourceState } from '../../../../../state'; -import { fireEvent, within, act, waitFor } from '@testing-library/react'; -import { APP_UI_ID } from '../../../../../../../common/constants'; -import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; -import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; -import { EndpointPrivileges } from '../../../../../../../common/endpoint/types'; - -jest.mock('../../../../../../common/components/user_privileges'); -const mockUseUserPrivileges = useUserPrivileges as jest.Mock; - -describe('when rendering the PolicyTrustedAppsList', () => { - // The index (zero based) of the card created by the generator that is policy specific - const POLICY_SPECIFIC_CARD_INDEX = 2; - - let appTestContext: AppContextTestRender; - let renderResult: ReturnType; - let render: (waitForLoadedState?: boolean) => Promise>; - let mockedApis: ReturnType; - let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; - let componentRenderProps: PolicyTrustedAppsListProps; - - const loadedUserEndpointPrivilegesState = ( - endpointOverrides: Partial = {} - ): EndpointPrivileges => ({ - ...getEndpointPrivilegesInitialStateMock(), - ...endpointOverrides, - }); - - const getCardByIndexPosition = (cardIndex: number = 0) => { - const card = renderResult.getAllByTestId('policyTrustedAppsGrid-card')[cardIndex]; - - if (!card) { - throw new Error(`Card at index [${cardIndex}] not found`); - } - - return card; - }; - - const toggleCardExpandCollapse = (cardIndex: number = 0) => { - act(() => { - fireEvent.click( - within(getCardByIndexPosition(cardIndex)).getByTestId( - 'policyTrustedAppsGrid-card-header-expandCollapse' - ) - ); - }); - }; - - const toggleCardActionMenu = async (cardIndex: number = 0) => { - act(() => { - fireEvent.click( - within(getCardByIndexPosition(cardIndex)).getByTestId( - 'policyTrustedAppsGrid-card-header-actions-button' - ) - ); - }); - - await waitFor(() => - expect(renderResult.getByTestId('policyTrustedAppsGrid-card-header-actions-contextMenuPanel')) - ); - }; - - afterAll(() => { - mockUseUserPrivileges.mockReset(); - }); - beforeEach(() => { - appTestContext = createAppRootMockRenderer(); - mockUseUserPrivileges.mockReturnValue({ - ...mockUseUserPrivileges(), - endpointPrivileges: loadedUserEndpointPrivilegesState(), - }); - - mockedApis = policyDetailsPageAllApiHttpMocks(appTestContext.coreStart.http); - waitForAction = appTestContext.middlewareSpy.waitForAction; - componentRenderProps = { policyId: '9f08b220-342d-4c8d-8971-4cf96adcac29', policyName: 'test' }; - - render = async (waitForLoadedState: boolean = true) => { - appTestContext.history.push( - getPolicyDetailsArtifactsListPath('ddf6570b-9175-4a6d-b288-61a09771c647') - ); - const trustedAppDataReceived = waitForLoadedState - ? waitForAction('assignedTrustedAppsListStateChanged', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }) - : Promise.resolve(); - - const checkTrustedAppDataAssignedReceived = waitForLoadedState - ? waitForAction('policyArtifactsHasTrustedApps', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }) - : Promise.resolve(); - - renderResult = appTestContext.render(); - await checkTrustedAppDataAssignedReceived; - await trustedAppDataReceived; - - return renderResult; - }; - }); - - it('should show total number of of items being displayed', async () => { - await render(); - - expect(renderResult.getByTestId('policyDetailsTrustedAppsCount').textContent).toBe( - 'Showing 20 trusted applications' - ); - }); - - it('should NOT show total number if `hideTotalShowingLabel` prop is true', async () => { - componentRenderProps.hideTotalShowingLabel = true; - await render(); - - expect(renderResult.queryByTestId('policyDetailsTrustedAppsCount')).toBeNull(); - }); - - it('should show card grid', async () => { - await render(); - - expect(renderResult.getByTestId('policyTrustedAppsGrid')).toBeTruthy(); - await expect(renderResult.findAllByTestId('policyTrustedAppsGrid-card')).resolves.toHaveLength( - 10 - ); - }); - - it('should expand cards', async () => { - await render(); - // expand - toggleCardExpandCollapse(); - toggleCardExpandCollapse(4); - - await waitFor(() => - expect( - renderResult.queryAllByTestId('policyTrustedAppsGrid-card-criteriaConditions') - ).toHaveLength(2) - ); - }); - - it('should collapse cards', async () => { - await render(); - - // expand - toggleCardExpandCollapse(); - toggleCardExpandCollapse(4); - - await waitFor(() => - expect( - renderResult.queryAllByTestId('policyTrustedAppsGrid-card-criteriaConditions') - ).toHaveLength(2) - ); - - // collapse - toggleCardExpandCollapse(); - toggleCardExpandCollapse(4); - - await waitFor(() => - expect( - renderResult.queryAllByTestId('policyTrustedAppsGrid-card-criteriaConditions') - ).toHaveLength(0) - ); - }); - - it('should show action menu on card', async () => { - await render(); - expect( - renderResult.getAllByTestId('policyTrustedAppsGrid-card-header-actions-button') - ).toHaveLength(10); - }); - - it('should navigate to trusted apps page when view full details action is clicked', async () => { - await render(); - await toggleCardActionMenu(); - act(() => { - fireEvent.click(renderResult.getByTestId('policyTrustedAppsGrid-viewFullDetailsAction')); - }); - - expect(appTestContext.coreStart.application.navigateToApp).toHaveBeenCalledWith( - APP_UI_ID, - expect.objectContaining({ - path: '/administration/trusted_apps?filter=6f12b025-fcb0-4db4-99e5-4927e3502bb8', - }) - ); - }); - - it('should show dialog when remove action is clicked', async () => { - await render(); - await toggleCardActionMenu(POLICY_SPECIFIC_CARD_INDEX); - act(() => { - fireEvent.click(renderResult.getByTestId('policyTrustedAppsGrid-removeAction')); - }); - - await waitFor(() => expect(renderResult.getByTestId('confirmModalBodyText'))); - }); - - describe('and artifact is policy specific', () => { - const renderAndClickOnEffectScopePopupButton = async () => { - const retrieveAllPolicies = waitForAction('policyDetailsListOfAllPoliciesStateChanged', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }); - await render(); - await retrieveAllPolicies; - act(() => { - fireEvent.click( - within(getCardByIndexPosition(POLICY_SPECIFIC_CARD_INDEX)).getByTestId( - 'policyTrustedAppsGrid-card-header-effectScope-popupMenu-button' - ) - ); - }); - await waitFor(() => - expect( - renderResult.getByTestId( - 'policyTrustedAppsGrid-card-header-effectScope-popupMenu-popoverPanel' - ) - ) - ); - }; - - it('should display policy names on assignment context menu', async () => { - await renderAndClickOnEffectScopePopupButton(); - - expect( - renderResult.getByTestId('policyTrustedAppsGrid-card-header-effectScope-popupMenu-item-0') - .textContent - ).toEqual('Endpoint Policy 0View details'); - expect( - renderResult.getByTestId('policyTrustedAppsGrid-card-header-effectScope-popupMenu-item-1') - .textContent - ).toEqual('Endpoint Policy 1View details'); - }); - - it('should navigate to policy details when clicking policy on assignment context menu', async () => { - await renderAndClickOnEffectScopePopupButton(); - - act(() => { - fireEvent.click( - renderResult.getByTestId('policyTrustedAppsGrid-card-header-effectScope-popupMenu-item-0') - ); - }); - - expect(appTestContext.history.location.pathname).toEqual( - '/administration/policy/ddf6570b-9175-4a6d-b288-61a09771c647/settings' - ); - }); - }); - - it('should handle pagination changes', async () => { - await render(); - - expect(appTestContext.history.location.search).not.toBeTruthy(); - - act(() => { - fireEvent.click(renderResult.getByTestId('pagination-button-next')); - }); - - expect(appTestContext.history.location.search).toMatch('?page_index=1'); - }); - - it('should reset `pageIndex` when a new pageSize is selected', async () => { - await render(); - // page ahead - act(() => { - fireEvent.click(renderResult.getByTestId('pagination-button-next')); - }); - await waitFor(() => { - expect(appTestContext.history.location.search).toBeTruthy(); - }); - - // now change the page size - await act(async () => { - fireEvent.click(renderResult.getByTestId('tablePaginationPopoverButton')); - await waitFor(() => expect(renderResult.getByTestId('tablePagination-50-rows'))); - }); - act(() => { - fireEvent.click(renderResult.getByTestId('tablePagination-50-rows')); - }); - - expect(appTestContext.history.location.search).toMatch('?page_size=50'); - }); - - it('should show toast message if trusted app list api call fails', async () => { - const error = new Error('oh no'); - // @ts-expect-error - mockedApis.responseProvider.trustedAppsList.mockRejectedValue(error); - await render(false); - await act(async () => { - await waitForAction('assignedTrustedAppsListStateChanged', { - validate: ({ payload }) => isFailedResourceState(payload), - }); - }); - - expect(appTestContext.startServices.notifications.toasts.addError).toHaveBeenCalledWith( - error, - expect.objectContaining({ - title: expect.any(String), - }) - ); - }); - - it('does not show remove option in actions menu if license is downgraded to gold or below', async () => { - mockUseUserPrivileges.mockReturnValue({ - ...mockUseUserPrivileges(), - endpointPrivileges: loadedUserEndpointPrivilegesState({ - canCreateArtifactsByPolicy: false, - }), - }); - await render(); - await toggleCardActionMenu(POLICY_SPECIFIC_CARD_INDEX); - - expect(renderResult.queryByTestId('policyTrustedAppsGrid-removeAction')).toBeNull(); - }); - - it('should handle search changes', async () => { - await render(); - - expect(appTestContext.history.location.search).not.toBeTruthy(); - - act(() => { - fireEvent.change(renderResult.getByTestId('searchField'), { - target: { value: 'search' }, - }); - fireEvent.submit(renderResult.getByTestId('searchField')); - }); - - expect(appTestContext.history.location.search).toMatch('?filter=search'); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx deleted file mode 100644 index 54dabf87f474..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx +++ /dev/null @@ -1,285 +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, { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { EuiSpacer, EuiText, Pagination } from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; -import { i18n } from '@kbn/i18n'; -import { - ArtifactCardGrid, - ArtifactCardGridCardComponentProps, - ArtifactCardGridProps, -} from '../../../../../components/artifact_card_grid'; -import { usePolicyDetailsSelector, usePolicyDetailsNavigateCallback } from '../../policy_hooks'; -import { - getCurrentArtifactsLocation, - getPolicyTrustedAppList, - getPolicyTrustedAppListError, - getPolicyTrustedAppsListPagination, - getTrustedAppsAllPoliciesById, - isPolicyTrustedAppListLoading, - getCurrentPolicyArtifactsFilter, -} from '../../../store/policy_details/selectors'; -import { - getPolicyDetailPath, - getPolicyDetailsArtifactsListPath, - getTrustedAppsListPath, -} from '../../../../../common/routing'; -import { Immutable, TrustedApp } from '../../../../../../../common/endpoint/types'; -import { useAppUrl, useToasts } from '../../../../../../common/lib/kibana'; -import { APP_UI_ID } from '../../../../../../../common/constants'; -import { SearchExceptions } from '../../../../../components/search_exceptions'; -import { ContextMenuItemNavByRouterProps } from '../../../../../components/context_menu_with_router_support/context_menu_item_nav_by_router'; -import { ArtifactEntryCollapsibleCardProps } from '../../../../../components/artifact_entry_card'; -import { useTestIdGenerator } from '../../../../../components/hooks/use_test_id_generator'; -import { RemoveTrustedAppFromPolicyModal } from './remove_trusted_app_from_policy_modal'; -import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; -import { useGetLinkTo } from '../empty/use_policy_trusted_apps_empty_hooks'; - -const DATA_TEST_SUBJ = 'policyTrustedAppsGrid'; - -export interface PolicyTrustedAppsListProps { - hideTotalShowingLabel?: boolean; - policyId: string; - policyName: string; -} - -export const PolicyTrustedAppsList = memo( - ({ hideTotalShowingLabel = false, policyId, policyName }) => { - const getTestId = useTestIdGenerator(DATA_TEST_SUBJ); - const toasts = useToasts(); - const history = useHistory(); - const { getAppUrl } = useAppUrl(); - const { canCreateArtifactsByPolicy } = useUserPrivileges().endpointPrivileges; - const isLoading = usePolicyDetailsSelector(isPolicyTrustedAppListLoading); - const defaultFilter = usePolicyDetailsSelector(getCurrentPolicyArtifactsFilter); - const trustedAppItems = usePolicyDetailsSelector(getPolicyTrustedAppList); - const pagination = usePolicyDetailsSelector(getPolicyTrustedAppsListPagination); - const urlParams = usePolicyDetailsSelector(getCurrentArtifactsLocation); - const allPoliciesById = usePolicyDetailsSelector(getTrustedAppsAllPoliciesById); - const trustedAppsApiError = usePolicyDetailsSelector(getPolicyTrustedAppListError); - const navigateCallback = usePolicyDetailsNavigateCallback(); - const { state } = useGetLinkTo(policyId, policyName); - - const [isCardExpanded, setCardExpanded] = useState>({}); - const [trustedAppsForRemoval, setTrustedAppsForRemoval] = useState([]); - const [showRemovalModal, setShowRemovalModal] = useState(false); - - const handlePageChange = useCallback( - ({ pageIndex, pageSize }) => { - history.push( - getPolicyDetailsArtifactsListPath(policyId, { - ...urlParams, - // If user changed page size, then reset page index back to the first page - page_index: pageSize !== pagination.pageSize ? 0 : pageIndex, - page_size: pageSize, - }) - ); - }, - [history, pagination.pageSize, policyId, urlParams] - ); - - const handleExpandCollapse = useCallback( - ({ expanded, collapsed }) => { - const newCardExpandedSettings: Record = {}; - - for (const trustedApp of expanded) { - newCardExpandedSettings[trustedApp.id] = true; - } - - for (const trustedApp of collapsed) { - newCardExpandedSettings[trustedApp.id] = false; - } - - setCardExpanded(newCardExpandedSettings); - }, - [] - ); - - const totalItemsCountLabel = useMemo(() => { - return i18n.translate('xpack.securitySolution.endpoint.policy.trustedApps.list.totalCount', { - defaultMessage: - 'Showing {totalItemsCount, plural, one {# trusted application} other {# trusted applications}}', - values: { totalItemsCount: pagination.totalItemCount }, - }); - }, [pagination.totalItemCount]); - - const cardProps = useMemo< - Map, ArtifactCardGridCardComponentProps> - >(() => { - const newCardProps = new Map(); - - for (const trustedApp of trustedAppItems) { - const isGlobal = trustedApp.effectScope.type === 'global'; - const viewUrlPath = getTrustedAppsListPath({ filter: trustedApp.id }); - const assignedPoliciesMenuItems: ArtifactEntryCollapsibleCardProps['policies'] = - trustedApp.effectScope.type === 'global' - ? undefined - : trustedApp.effectScope.policies.reduce< - Required['policies'] - >((byIdPolicies, trustedAppAssignedPolicyId) => { - if (!allPoliciesById[trustedAppAssignedPolicyId]) { - byIdPolicies[trustedAppAssignedPolicyId] = { - children: trustedAppAssignedPolicyId, - }; - return byIdPolicies; - } - - const policyDetailsPath = getPolicyDetailPath(trustedAppAssignedPolicyId); - - const thisPolicyMenuProps: ContextMenuItemNavByRouterProps = { - navigateAppId: APP_UI_ID, - navigateOptions: { - path: policyDetailsPath, - }, - href: getAppUrl({ path: policyDetailsPath }), - children: allPoliciesById[trustedAppAssignedPolicyId].name, - }; - - byIdPolicies[trustedAppAssignedPolicyId] = thisPolicyMenuProps; - - return byIdPolicies; - }, {}); - - const fullDetailsAction: ArtifactCardGridCardComponentProps['actions'] = [ - { - icon: 'controlsHorizontal', - children: i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.list.viewAction', - { defaultMessage: 'View full details' } - ), - href: getAppUrl({ appId: APP_UI_ID, path: viewUrlPath }), - navigateAppId: APP_UI_ID, - navigateOptions: { path: viewUrlPath, state }, - 'data-test-subj': getTestId('viewFullDetailsAction'), - }, - ]; - const thisTrustedAppCardProps: ArtifactCardGridCardComponentProps = { - expanded: Boolean(isCardExpanded[trustedApp.id]), - actions: canCreateArtifactsByPolicy - ? [ - ...fullDetailsAction, - { - icon: 'trash', - children: i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeAction', - { defaultMessage: 'Remove from policy' } - ), - onClick: () => { - setTrustedAppsForRemoval([trustedApp]); - setShowRemovalModal(true); - }, - disabled: isGlobal, - toolTipContent: isGlobal - ? i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeActionNotAllowed', - { - defaultMessage: - 'Globally applied trusted applications cannot be removed from policy.', - } - ) - : undefined, - toolTipPosition: 'top', - 'data-test-subj': getTestId('removeAction'), - }, - ] - : fullDetailsAction, - - policies: assignedPoliciesMenuItems, - }; - - newCardProps.set(trustedApp, thisTrustedAppCardProps); - } - - return newCardProps; - }, [ - allPoliciesById, - getAppUrl, - getTestId, - isCardExpanded, - trustedAppItems, - canCreateArtifactsByPolicy, - state, - ]); - - const provideCardProps = useCallback['cardComponentProps']>( - (item) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return cardProps.get(item as Immutable)!; - }, - [cardProps] - ); - - const handleRemoveModalClose = useCallback(() => { - setShowRemovalModal(false); - }, []); - - // Anytime a new set of data (trusted apps) is retrieved, reset the card expand state - useEffect(() => { - setCardExpanded({}); - }, [trustedAppItems]); - - // if an error occurred while loading the data, show toast - useEffect(() => { - if (trustedAppsApiError) { - toasts.addError(trustedAppsApiError as unknown as Error, { - title: i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.list.apiError', - { - defaultMessage: 'Error while retrieving list of trusted applications', - } - ), - }); - } - }, [toasts, trustedAppsApiError]); - - return ( - <> - { - navigateCallback({ filter }); - }} - /> - - {!hideTotalShowingLabel && ( - - {totalItemsCountLabel} - - )} - - - - - - {showRemovalModal && ( - - )} - - ); - } -); -PolicyTrustedAppsList.displayName = 'PolicyTrustedAppsList'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/remove_trusted_app_from_policy_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/remove_trusted_app_from_policy_modal.test.tsx deleted file mode 100644 index 676080d180a6..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/remove_trusted_app_from_policy_modal.test.tsx +++ /dev/null @@ -1,258 +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 { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; -import { isFailedResourceState, isLoadedResourceState } from '../../../../../state'; -import React from 'react'; -import { fireEvent, act } from '@testing-library/react'; -import { policyDetailsPageAllApiHttpMocks } from '../../../test_utils'; -import { - RemoveTrustedAppFromPolicyModal, - RemoveTrustedAppFromPolicyModalProps, -} from './remove_trusted_app_from_policy_modal'; -import { - PolicyArtifactsUpdateTrustedApps, - PolicyDetailsTrustedAppsRemoveListStateChanged, -} from '../../../store/policy_details/action/policy_trusted_apps_action'; -import { Immutable } from '../../../../../../../common/endpoint/types'; -import { HttpFetchOptionsWithPath } from 'kibana/public'; -import { exceptionListItemToTrustedApp } from '../../../../trusted_apps/service/mappers'; - -describe('When using the RemoveTrustedAppFromPolicyModal component', () => { - let appTestContext: AppContextTestRender; - let renderResult: ReturnType; - let render: (waitForLoadedState?: boolean) => Promise>; - let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; - let mockedApis: ReturnType; - let onCloseHandler: jest.MockedFunction; - let trustedApps: RemoveTrustedAppFromPolicyModalProps['trustedApps']; - - beforeEach(() => { - appTestContext = createAppRootMockRenderer(); - waitForAction = appTestContext.middlewareSpy.waitForAction; - onCloseHandler = jest.fn(); - mockedApis = policyDetailsPageAllApiHttpMocks(appTestContext.coreStart.http); - trustedApps = [ - // The 3rd trusted app generated by the HTTP mock is a Policy Specific one - exceptionListItemToTrustedApp( - mockedApis.responseProvider.trustedAppsList({ query: {} } as HttpFetchOptionsWithPath) - .data[2] - ), - ]; - - // Delay the Update Trusted App API response so that we can test UI states while the update is underway. - mockedApis.responseProvider.trustedAppUpdate.mockDelay.mockImplementation( - () => - new Promise((resolve) => { - setTimeout(resolve, 100); - }) - ); - - render = async (waitForLoadedState: boolean = true) => { - const pendingDataLoadState = waitForLoadedState - ? Promise.all([ - waitForAction('serverReturnedPolicyDetailsData'), - waitForAction('assignedTrustedAppsListStateChanged', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }), - ]) - : Promise.resolve(); - - appTestContext.history.push( - getPolicyDetailsArtifactsListPath('ddf6570b-9175-4a6d-b288-61a09771c647') - ); - renderResult = appTestContext.render( - - ); - - await pendingDataLoadState; - - return renderResult; - }; - }); - - const getConfirmButton = (): HTMLButtonElement => - renderResult.getByTestId('confirmModalConfirmButton') as HTMLButtonElement; - - const clickConfirmButton = async ( - /* wait for the UI action to the store middleware to initialize the remove */ - waitForUpdateRequestActionDispatch: boolean = false, - /* wait for the removal to succeed */ - waitForRemoveSuccessActionDispatch: boolean = false - ): Promise< - Immutable< - Array - > - > => { - const pendingConfirmStoreAction = waitForAction('policyArtifactsUpdateTrustedApps'); - const pendingRemoveSuccessAction = waitForAction( - 'policyDetailsTrustedAppsRemoveListStateChanged', - { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - } - ); - - act(() => { - fireEvent.click(getConfirmButton()); - }); - - let response: Array< - PolicyArtifactsUpdateTrustedApps | PolicyDetailsTrustedAppsRemoveListStateChanged - > = []; - - if (waitForUpdateRequestActionDispatch || waitForRemoveSuccessActionDispatch) { - const pendingActions: Array< - Promise - > = []; - - if (waitForUpdateRequestActionDispatch) { - pendingActions.push(pendingConfirmStoreAction); - } - - if (waitForRemoveSuccessActionDispatch) { - pendingActions.push(pendingRemoveSuccessAction); - } - - await act(async () => { - response = await Promise.all(pendingActions); - }); - } - - return response; - }; - - const clickCancelButton = () => { - act(() => { - fireEvent.click(renderResult.getByTestId('confirmModalCancelButton')); - }); - }; - - const clickCloseButton = () => { - act(() => { - fireEvent.click(renderResult.baseElement.querySelector('button.euiModal__closeIcon')!); - }); - }; - - it.each([ - ['cancel', clickCancelButton], - ['close', clickCloseButton], - ])('should call `onClose` callback when %s button is clicked', async (__, clickButton) => { - await render(); - clickButton(); - - expect(onCloseHandler).toHaveBeenCalled(); - }); - - it('should dispatch action when confirmed', async () => { - await render(); - const confirmedAction = (await clickConfirmButton(true))[0]; - - expect(confirmedAction!.payload).toEqual({ - action: 'remove', - artifacts: trustedApps, - }); - }); - - it('should disable and show loading state on confirm button while update is underway', async () => { - await render(); - await clickConfirmButton(true); - const confirmButton = getConfirmButton(); - - expect(confirmButton.disabled).toBe(true); - expect(confirmButton.querySelector('.euiLoadingSpinner')).not.toBeNull(); - }); - - it.each([ - ['cancel', clickCancelButton], - ['close', clickCloseButton], - ])( - 'should prevent dialog dismissal if %s button is clicked while update is underway', - async (__, clickButton) => { - await render(); - await clickConfirmButton(true); - clickButton(); - - expect(onCloseHandler).not.toHaveBeenCalled(); - } - ); - - it('should show error toast if removal failed', async () => { - const error = new Error('oh oh'); - mockedApis.responseProvider.trustedAppUpdate.mockImplementation(() => { - throw error; - }); - await render(); - await clickConfirmButton(true); - await act(async () => { - await waitForAction('policyDetailsTrustedAppsRemoveListStateChanged', { - validate({ payload }) { - return isFailedResourceState(payload); - }, - }); - }); - - expect(appTestContext.coreStart.notifications.toasts.addError).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('oh oh') }), - expect.objectContaining({ title: expect.any(String) }) - ); - }); - - it('should show success toast and close modal when removed is successful', async () => { - await render(); - await clickConfirmButton(true, true); - - expect(appTestContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith({ - text: '"Generated Exception (nng74)" has been removed from Endpoint Policy policy', - title: 'Successfully removed', - }); - }); - - it('should show multiples removal success message', async () => { - trustedApps = [ - ...trustedApps, - { - ...trustedApps[0], - id: '123', - name: 'trusted app 2', - }, - ]; - - await render(); - await clickConfirmButton(true, true); - - expect(appTestContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith({ - text: '2 trusted applications have been removed from Endpoint Policy policy', - title: 'Successfully removed', - }); - }); - - it('should trigger a refresh of trusted apps list data on successful removal', async () => { - await render(); - const pendingActions = Promise.all([ - // request list refresh - waitForAction('policyDetailsTrustedAppsForceListDataRefresh'), - - // list data refresh received - waitForAction('assignedTrustedAppsListStateChanged', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }), - ]); - await clickConfirmButton(true, true); - - await expect(pendingActions).resolves.toHaveLength(2); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/remove_trusted_app_from_policy_modal.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/remove_trusted_app_from_policy_modal.tsx deleted file mode 100644 index 28ae58c7f11f..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/remove_trusted_app_from_policy_modal.tsx +++ /dev/null @@ -1,156 +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, { memo, useCallback, useEffect, useMemo } from 'react'; -import { EuiCallOut, EuiConfirmModal, EuiSpacer, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; -import { Immutable, TrustedApp } from '../../../../../../../common/endpoint/types'; -import { AppAction } from '../../../../../../common/store/actions'; -import { usePolicyDetailsSelector } from '../../policy_hooks'; -import { - getTrustedAppsIsRemoving, - getTrustedAppsRemovalError, - getTrustedAppsWasRemoveSuccessful, - policyDetails, -} from '../../../store/policy_details/selectors'; -import { useToasts } from '../../../../../../common/lib/kibana'; - -export interface RemoveTrustedAppFromPolicyModalProps { - trustedApps: Immutable; - onClose: () => void; -} - -export const RemoveTrustedAppFromPolicyModal = memo( - ({ trustedApps, onClose }) => { - const toasts = useToasts(); - const dispatch = useDispatch>(); - - const policyName = usePolicyDetailsSelector(policyDetails)?.name; - const isRemoving = usePolicyDetailsSelector(getTrustedAppsIsRemoving); - const removeError = usePolicyDetailsSelector(getTrustedAppsRemovalError); - const wasSuccessful = usePolicyDetailsSelector(getTrustedAppsWasRemoveSuccessful); - - const removedToastMessage: string = useMemo(() => { - const count = trustedApps.length; - - if (count === 0) { - return ''; - } - - if (count > 1) { - return i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeDialog.successMultiplesToastText', - { - defaultMessage: - '{count} trusted applications have been removed from {policyName} policy', - values: { count, policyName }, - } - ); - } - - return i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeDialog.successToastText', - { - defaultMessage: '"{trustedAppName}" has been removed from {policyName} policy', - values: { trustedAppName: trustedApps[0].name, policyName }, - } - ); - }, [policyName, trustedApps]); - - const handleModalClose = useCallback(() => { - if (!isRemoving) { - onClose(); - } - }, [isRemoving, onClose]); - - const handleModalConfirm = useCallback(() => { - dispatch({ - type: 'policyArtifactsUpdateTrustedApps', - payload: { action: 'remove', artifacts: trustedApps }, - }); - }, [dispatch, trustedApps]); - - useEffect(() => { - // When component is un-mounted, reset the state for remove in the store - return () => { - dispatch({ type: 'policyDetailsArtifactsResetRemove' }); - }; - }, [dispatch]); - - useEffect(() => { - if (removeError) { - toasts.addError(removeError as unknown as Error, { - title: i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeDialog.errorToastTitle', - { - defaultMessage: 'Error while attempt to remove trusted application', - } - ), - }); - } - }, [removeError, toasts]); - - useEffect(() => { - if (wasSuccessful) { - toasts.addSuccess({ - title: i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeDialog.successToastTitle', - { defaultMessage: 'Successfully removed' } - ), - text: removedToastMessage, - }); - handleModalClose(); - } - }, [handleModalClose, policyName, removedToastMessage, toasts, trustedApps, wasSuccessful]); - - return ( - - -

    - -

    -
    - - - - -

    - -

    -
    -
    - ); - } -); -RemoveTrustedAppFromPolicyModal.displayName = 'RemoveTrustedAppFromPolicyModal'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/mappers.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/mappers.ts index 8069d18169dd..f440a0a39463 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/mappers.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/mappers.ts @@ -18,13 +18,15 @@ import { } from '@kbn/securitysolution-io-ts-list-types'; import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '@kbn/securitysolution-list-constants'; import { - ConditionEntry, ConditionEntryField, + OperatingSystem, + TrustedAppEntryTypes, +} from '@kbn/securitysolution-utils'; +import { + ConditionEntry, EffectScope, NewTrustedApp, - OperatingSystem, TrustedApp, - TrustedAppEntryTypes, UpdateTrustedApp, } from '../../../../../common/endpoint/types'; import { tagsToEffectScope } from '../../../../../common/endpoint/service/trusted_apps/mapping'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts index 3f9e9d53f69e..22aeedca7312 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts @@ -5,9 +5,9 @@ * 2.0. */ +import { ConditionEntryField } from '@kbn/securitysolution-utils'; import { ConditionEntry, - ConditionEntryField, EffectScope, GlobalEffectScope, MacosLinuxConditionEntry, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts index 363da5cd2739..431894274ee0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts @@ -5,12 +5,8 @@ * 2.0. */ -import { - ConditionEntry, - ConditionEntryField, - NewTrustedApp, - OperatingSystem, -} from '../../../../../common/endpoint/types'; +import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { ConditionEntry, NewTrustedApp } from '../../../../../common/endpoint/types'; import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../../common/constants'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts index 3c2f17752027..32e1867db567 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts @@ -6,7 +6,8 @@ */ import { combineReducers, createStore } from 'redux'; -import { TrustedApp, OperatingSystem } from '../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { TrustedApp } from '../../../../../common/endpoint/types'; import { RoutingAction } from '../../../../common/store/routing'; import { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.test.tsx index 9d6c35d64b2d..4ea42c896847 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.test.tsx @@ -8,11 +8,8 @@ import { shallow, mount } from 'enzyme'; import React from 'react'; import { keys } from 'lodash'; -import { - ConditionEntry, - ConditionEntryField, - OperatingSystem, -} from '../../../../../../../common/endpoint/types'; +import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { ConditionEntry } from '../../../../../../../common/endpoint/types'; import { ConditionEntryInput } from '.'; import { EuiSuperSelectProps } from '@elastic/eui'; @@ -53,6 +50,7 @@ describe('Condition entry input', () => { /> ); + // @ts-ignore it.each(keys(ConditionEntryField).map((k) => [k]))( 'should call on change for field input with value %s', (field) => { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.tsx index f487a38401ef..4f4f89b80f28 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.tsx @@ -16,13 +16,8 @@ import { EuiSuperSelectOption, EuiText, } from '@elastic/eui'; - -import { - ConditionEntry, - ConditionEntryField, - OperatorFieldIds, - OperatingSystem, -} from '../../../../../../../common/endpoint/types'; +import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { ConditionEntry, OperatorFieldIds } from '../../../../../../../common/endpoint/types'; import { CONDITION_FIELD_DESCRIPTION, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_group/index.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_group/index.tsx index fb7135b1173e..aed69128847f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_group/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_group/index.tsx @@ -9,7 +9,8 @@ import React, { memo } from 'react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiHideFor, EuiSpacer } from '@elastic/eui'; import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ConditionEntry, OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { ConditionEntry } from '../../../../../../../common/endpoint/types'; import { AndOrBadge } from '../../../../../../common/components/and_or_badge'; import { ConditionEntryInput, ConditionEntryInputProps } from '../condition_entry_input'; import { useTestIdGenerator } from '../../../../../components/hooks/use_test_id_generator'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx index cc2c51c5f4c4..68dd43fa4115 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx @@ -9,11 +9,8 @@ import React from 'react'; import * as reactTestingLibrary from '@testing-library/react'; import { fireEvent, getByTestId } from '@testing-library/dom'; -import { - ConditionEntryField, - NewTrustedApp, - OperatingSystem, -} from '../../../../../../common/endpoint/types'; +import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { NewTrustedApp } from '../../../../../../common/endpoint/types'; import { AppContextTestRender, createAppRootMockRenderer, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx index 7cff989f008a..2812bdc9c3c0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx @@ -18,20 +18,23 @@ import { EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { + hasSimpleExecutableName, + isPathValid, + ConditionEntryField, + OperatingSystem, +} from '@kbn/securitysolution-utils'; import { EuiFormProps } from '@elastic/eui/src/components/form/form'; + import { ConditionEntry, - ConditionEntryField, EffectScope, MacosLinuxConditionEntry, MaybeImmutable, NewTrustedApp, - OperatingSystem, } from '../../../../../../common/endpoint/types'; import { isValidHash, - isPathValid, - hasSimpleExecutableName, getDuplicateFields, } from '../../../../../../common/endpoint/service/trusted_apps/validations'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts index dd9b8fe4324c..3d8a56ad7431 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts @@ -6,10 +6,10 @@ */ import { i18n } from '@kbn/i18n'; +import { ConditionEntryField } from '@kbn/securitysolution-utils'; import { MacosLinuxConditionEntry, WindowsConditionEntry, - ConditionEntryField, OperatorFieldIds, } from '../../../../../common/endpoint/types'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 82169fcd19c1..6c1319ca6e9e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -11,11 +11,8 @@ import { TrustedAppsPage } from './trusted_apps_page'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { fireEvent } from '@testing-library/dom'; import { MiddlewareActionSpyHelper } from '../../../../common/store/test_utils'; -import { - ConditionEntryField, - OperatingSystem, - TrustedApp, -} from '../../../../../common/endpoint/types'; +import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { TrustedApp } from '../../../../../common/endpoint/types'; import { HttpFetchOptions, HttpFetchOptionsWithPath } from 'kibana/public'; import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from '../../../components/effected_policy_select/test_utils'; @@ -24,6 +21,7 @@ import { licenseService } from '../../../../common/hooks/use_license'; import { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { EXCEPTION_LIST_ITEM_URL } from '@kbn/securitysolution-list-constants'; import { trustedAppsAllHttpMocks } from '../../mocks'; +import { waitFor } from '@testing-library/react'; jest.mock('../../../../common/hooks/use_license', () => { const licenseServiceInstance = { @@ -49,11 +47,17 @@ describe('When on the Trusted Apps Page', () => { let coreStart: AppContextTestRender['coreStart']; let waitForAction: MiddlewareActionSpyHelper['waitForAction']; let render: () => ReturnType; + let renderResult: ReturnType; let mockedApis: ReturnType; + let getFakeTrustedApp = jest.fn(); const originalScrollTo = window.scrollTo; const act = reactTestingLibrary.act; - const getFakeTrustedApp = jest.fn(); + const waitForListUI = async (): Promise => { + await waitFor(() => { + expect(renderResult.getByTestId('trustedAppsListPageContent')).toBeTruthy(); + }); + }; beforeAll(() => { window.scrollTo = () => {}; @@ -65,7 +69,7 @@ describe('When on the Trusted Apps Page', () => { beforeEach(() => { mockedContext = createAppRootMockRenderer(); - getFakeTrustedApp.mockImplementation( + getFakeTrustedApp = jest.fn( (): TrustedApp => ({ id: '2d95bec3-b48f-4db7-9622-a2b061cc031d', version: 'abc123', @@ -93,7 +97,7 @@ describe('When on the Trusted Apps Page', () => { (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); waitForAction = mockedContext.middlewareSpy.waitForAction; mockedApis = trustedAppsAllHttpMocks(coreStart.http); - render = () => mockedContext.render(); + render = () => (renderResult = mockedContext.render()); reactTestingLibrary.act(() => { history.push('/administration/trusted_apps'); }); @@ -104,10 +108,11 @@ describe('When on the Trusted Apps Page', () => { describe('and there are trusted app entries', () => { const renderWithListData = async () => { - const renderResult = render(); + render(); await act(async () => { - await waitForAction('trustedAppsListResourceStateChanged'); + await waitForListUI(); }); + return renderResult; }; @@ -123,15 +128,13 @@ describe('When on the Trusted Apps Page', () => { }); it('should display the searchExceptions', async () => { - const renderResult = await renderWithListData(); + await renderWithListData(); expect(await renderResult.findByTestId('searchExceptions')).not.toBeNull(); }); describe('and the Grid view is being displayed', () => { - let renderResult: ReturnType; - const renderWithListDataAndClickOnEditCard = async () => { - renderResult = await renderWithListData(); + await renderWithListData(); await act(async () => { // The 3rd Trusted app to be rendered will be a policy specific one @@ -146,7 +149,7 @@ describe('When on the Trusted Apps Page', () => { const renderWithListDataAndClickAddButton = async (): Promise< ReturnType > => { - renderResult = await renderWithListData(); + await renderWithListData(); act(() => { const addButton = renderResult.getByTestId('trustedAppsListAddButton'); @@ -321,7 +324,7 @@ describe('When on the Trusted Apps Page', () => { } ); - renderResult = await renderWithListData(); + await renderWithListData(); await reactTestingLibrary.act(async () => { await apiResponseForEditTrustedApp; @@ -337,7 +340,7 @@ describe('When on the Trusted Apps Page', () => { }); it('should retrieve trusted app via API using url `id`', async () => { - renderResult = await renderAndWaitForGetApi(); + await renderAndWaitForGetApi(); expect(coreStart.http.get.mock.calls).toContainEqual([ EXCEPTION_LIST_ITEM_URL, @@ -392,7 +395,7 @@ describe('When on the Trusted Apps Page', () => { const renderAndClickAddButton = async (): Promise< ReturnType > => { - const renderResult = render(); + render(); await act(async () => { await Promise.all([ waitForAction('trustedAppsListResourceStateChanged'), @@ -460,7 +463,7 @@ describe('When on the Trusted Apps Page', () => { it('should have list of policies populated', async () => { const resetEnv = forceHTMLElementOffsetWidth(); - const renderResult = await renderAndClickAddButton(); + await renderAndClickAddButton(); act(() => { fireEvent.click(renderResult.getByTestId('perPolicy')); }); @@ -509,8 +512,7 @@ describe('When on the Trusted Apps Page', () => { }; it('should enable the Flyout Add button', async () => { - const renderResult = await renderAndClickAddButton(); - + await renderAndClickAddButton(); await fillInCreateForm(); const flyoutAddButton = renderResult.getByTestId( @@ -521,7 +523,6 @@ describe('When on the Trusted Apps Page', () => { }); describe('and the Flyout Add button is clicked', () => { - let renderResult: ReturnType; let releasePostCreateApi: () => void; beforeEach(async () => { @@ -533,7 +534,7 @@ describe('When on the Trusted Apps Page', () => { }) ); - renderResult = await renderAndClickAddButton(); + await renderAndClickAddButton(); await fillInCreateForm(); const userClickedSaveActionWatcher = waitForAction('trustedAppCreationDialogConfirmed'); @@ -671,7 +672,7 @@ describe('When on the Trusted Apps Page', () => { describe('and when the form data is not valid', () => { it('should not enable the Flyout Add button with an invalid hash', async () => { - const renderResult = await renderAndClickAddButton(); + await renderAndClickAddButton(); const { getByTestId } = renderResult; reactTestingLibrary.act(() => { @@ -729,12 +730,12 @@ describe('When on the Trusted Apps Page', () => { }); it('should show a loader until trusted apps existence can be confirmed', async () => { - const renderResult = render(); + render(); expect(await renderResult.findByTestId('trustedAppsListLoader')).not.toBeNull(); }); it('should show Empty Prompt if not entries exist', async () => { - const renderResult = render(); + render(); await act(async () => { await waitForAction('trustedAppsExistStateChanged'); }); @@ -742,7 +743,7 @@ describe('When on the Trusted Apps Page', () => { }); it('should hide empty prompt and show list after one trusted app is added', async () => { - const renderResult = render(); + render(); await act(async () => { await waitForAction('trustedAppsExistStateChanged'); }); @@ -784,7 +785,7 @@ describe('When on the Trusted Apps Page', () => { per_page: 1, }); - const renderResult = render(); + render(); await act(async () => { await waitForAction('trustedAppsExistStateChanged'); @@ -803,7 +804,7 @@ describe('When on the Trusted Apps Page', () => { }); it('should not display the searchExceptions', async () => { - const renderResult = render(); + render(); await act(async () => { await waitForAction('trustedAppsExistStateChanged'); }); @@ -812,14 +813,13 @@ describe('When on the Trusted Apps Page', () => { }); describe('and the search is dispatched', () => { - let renderResult: ReturnType; beforeEach(async () => { reactTestingLibrary.act(() => { history.push('/administration/trusted_apps?filter=test'); }); - renderResult = render(); + render(); await act(async () => { - await waitForAction('trustedAppsListResourceStateChanged'); + await waitForListUI(); }); }); @@ -836,11 +836,10 @@ describe('When on the Trusted Apps Page', () => { }); describe('and the back button is present', () => { - let renderResult: ReturnType; beforeEach(async () => { - renderResult = render(); + render(); await act(async () => { - await waitForAction('trustedAppsListResourceStateChanged'); + await waitForListUI(); }); reactTestingLibrary.act(() => { history.push('/administration/trusted_apps', { @@ -868,9 +867,8 @@ describe('When on the Trusted Apps Page', () => { }); describe('and the back button is not present', () => { - let renderResult: ReturnType; beforeEach(async () => { - renderResult = render(); + render(); await act(async () => { await waitForAction('trustedAppsListResourceStateChanged'); }); diff --git a/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.test.ts b/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.test.ts index e5c8d110b63f..c27d983fec12 100644 --- a/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.test.ts +++ b/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.test.ts @@ -272,5 +272,24 @@ describe('Exceptions List Api Client', () => { await expect(exceptionsListApiClientInstance.hasData()).resolves.toBe(false); }); + + it('return new instance when HttpCore changes', async () => { + const initialInstance = ExceptionsListApiClient.getInstance( + fakeHttpServices, + getFakeListId(), + getFakeListDefinition() + ); + + fakeCoreStart = coreMock.createStart({ basePath: '/mock' }); + fakeHttpServices = fakeCoreStart.http as jest.Mocked; + + const newInstance = ExceptionsListApiClient.getInstance( + fakeHttpServices, + getFakeListId(), + getFakeListDefinition() + ); + + expect(initialInstance).not.toStrictEqual(newInstance); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.ts b/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.ts index c995754dd190..81f9f20182be 100644 --- a/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.ts +++ b/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.ts @@ -83,6 +83,10 @@ export class ExceptionsListApiClient { } } + public isHttp(coreHttp: HttpStart): boolean { + return this.http === coreHttp; + } + /** * Static method to get a fresh or existing instance. * It will ensure we only check and create the list once. @@ -92,7 +96,10 @@ export class ExceptionsListApiClient { listId: string, listDefinition: CreateExceptionListSchema ): ExceptionsListApiClient { - if (!ExceptionsListApiClient.instance.has(listId)) { + if ( + !ExceptionsListApiClient.instance.has(listId) || + !ExceptionsListApiClient.instance.get(listId)?.isHttp(http) + ) { ExceptionsListApiClient.instance.set( listId, new ExceptionsListApiClient(http, listId, listDefinition) diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx index 82c45c8e5bf7..28c02c6d8ed4 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx @@ -12,11 +12,11 @@ import '../../../common/mock/match_media'; import { TestProviders } from '../../../common/mock'; import { HostOverview } from './index'; -import { useHostRiskScore } from '../../../hosts/containers/host_risk_score'; import { mockData } from './mock'; import { mockAnomalies } from '../../../common/components/ml/mock'; +import { useHostRiskScore } from '../../../risk_score/containers/all'; -jest.mock('../../../hosts/containers/host_risk_score', () => ({ +jest.mock('../../../risk_score/containers/all', () => ({ useHostRiskScore: jest.fn().mockReturnValue([ true, { diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx index 479580a6bfb9..5c1d6a62df5b 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx @@ -10,7 +10,12 @@ import { euiLightVars as lightTheme, euiDarkVars as darkTheme } from '@kbn/ui-th import { getOr } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; -import { DocValueFields, HostItem, HostRiskSeverity } from '../../../../common/search_strategy'; +import { + buildHostNamesFilter, + DocValueFields, + HostItem, + RiskSeverity, +} from '../../../../common/search_strategy'; import { DEFAULT_DARK_MODE } from '../../../../common/constants'; import { DescriptionList } from '../../../../common/utility_types'; import { useUiSetting$ } from '../../../common/lib/kibana'; @@ -35,8 +40,8 @@ import { import * as i18n from './translations'; import { EndpointOverview } from './endpoint_overview'; import { OverviewDescriptionList } from '../../../common/components/overview_description_list'; -import { HostRiskScore } from '../../../hosts/components/common/host_risk_score'; -import { useHostRiskScore } from '../../../hosts/containers/host_risk_score'; +import { useHostRiskScore } from '../../../risk_score/containers'; +import { RiskScore } from '../../../common/components/severity/common'; interface HostSummaryProps { contextID?: string; // used to provide unique draggable context when viewing in the side panel @@ -81,7 +86,7 @@ export const HostOverview = React.memo( const userPermissions = hasMlUserPermissions(capabilities); const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); const [_, { data: hostRisk, isModuleEnabled }] = useHostRiskScore({ - hostName, + filterQuery: hostName ? buildHostNamesFilter([hostName]) : undefined, }); const getDefaultRenderer = useCallback( @@ -114,10 +119,7 @@ export const HostOverview = React.memo( description: ( <> {hostRiskData ? ( - + ) : ( getEmptyTagValue() )} diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.test.tsx index fc36a0c4337c..a804e2efc458 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.test.tsx @@ -49,7 +49,7 @@ describe('CtiEnabledModule', () => { - + diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx index a339676ac361..4341cab4ec98 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx @@ -12,7 +12,7 @@ import { useCtiDashboardLinks } from '../../containers/overview_cti_links'; import { ThreatIntelPanelView } from './threat_intel_panel_view'; export const CtiEnabledModuleComponent: React.FC = (props) => { - const { to, from, allIntegrationsInstalled, allTiDataSources, setQuery, deleteQuery } = props; + const { to, from, allTiDataSources, setQuery, deleteQuery } = props; const { tiDataSources, totalCount } = useTiDataSources({ to, from, @@ -22,13 +22,7 @@ export const CtiEnabledModuleComponent: React.FC = (p }); const { listItems } = useCtiDashboardLinks({ to, from, tiDataSources }); - return ( - - ); + return ; }; export const CtiEnabledModule = React.memo(CtiEnabledModuleComponent); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.test.tsx index 71d6d5eb0c58..26c306b7a587 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.test.tsx @@ -49,7 +49,7 @@ describe('ThreatIntelLinkPanel', () => { - + @@ -59,29 +59,12 @@ describe('ThreatIntelLinkPanel', () => { expect(wrapper.find('[data-test-subj="cti-enable-integrations-button"]').length).toEqual(0); }); - it('renders Enable source buttons when not all integrations installed', () => { - const wrapper = mount( - - - - - - - - ); - expect(wrapper.find('[data-test-subj="cti-enable-integrations-button"]').length).not.toBe(0); - }); - it('renders CtiDisabledModule when Threat Intel module is disabled', () => { const wrapper = mount( - + diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx index c89199c2cb0c..5428c8c8b032 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx @@ -16,20 +16,15 @@ export type ThreatIntelLinkPanelProps = Pick< GlobalTimeArgs, 'from' | 'to' | 'deleteQuery' | 'setQuery' > & { - allIntegrationsInstalled: boolean | undefined; allTiDataSources: TiDataSources[]; }; const ThreatIntelLinkPanelComponent: React.FC = (props) => { - const { allIntegrationsInstalled, allTiDataSources } = props; + const { allTiDataSources } = props; const isThreatIntelModuleEnabled = allTiDataSources.length > 0; return isThreatIntelModuleEnabled ? (
    - +
    ) : (
    diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx index 3697d27015fd..0f8018575026 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx @@ -6,17 +6,16 @@ */ import React, { useMemo } from 'react'; -import { EuiButton, EuiTableFieldDataColumnType } from '@elastic/eui'; +import { EuiTableFieldDataColumnType } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import * as i18n from './translations'; -import { LinkPanel, InnerLinkPanel, LinkPanelListItem } from '../link_panel'; +import { LinkPanel, LinkPanelListItem } from '../link_panel'; import { LinkPanelViewProps } from '../link_panel/types'; import { shortenCountIntoString } from '../../../common/utils/shorten_count_into_string'; import { Link } from '../link_panel/link'; import { ID as CTIEventCountQueryId } from '../../containers/overview_cti_links/use_ti_data_sources'; import { LINK_COPY } from '../overview_risky_host_links/translations'; -import { useIntegrationsPageLink } from './use_integrations_page_link'; const columns: Array> = [ { name: 'Name', field: 'title', sortable: true, truncateText: true, width: '100%' }, @@ -43,40 +42,12 @@ export const ThreatIntelPanelView: React.FC = ({ listItems, splitPanel, totalCount = 0, - allIntegrationsInstalled, }) => { - const integrationsLink = useIntegrationsPageLink(); - return ( ( - <> - {allIntegrationsInstalled === false ? ( - - {i18n.DANGER_BUTTON} - - } - /> - ) : null} - - ), - [allIntegrationsInstalled, integrationsLink] - ), inspectQueryId: isInspectEnabled ? CTIEventCountQueryId : undefined, listItems, panelTitle: i18n.PANEL_TITLE, diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/translations.ts b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/translations.ts index e112942b0974..ab3c9559ea29 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/translations.ts +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/translations.ts @@ -72,13 +72,6 @@ export const VIEW_DASHBOARD = i18n.translate('xpack.securitySolution.overview.ct defaultMessage: 'View dashboard', }); -export const SOME_MODULES_DISABLE_TITLE = i18n.translate( - 'xpack.securitySolution.overview.ctiDashboardSomeModulesDisabledTItle', - { - defaultMessage: 'Some threat intel sources are disabled', - } -); - export const OTHER_DATA_SOURCE_TITLE = i18n.translate( 'xpack.securitySolution.overview.ctiDashboardOtherDatasourceTitle', { diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx index 8f34a94ea6aa..575ab0057073 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx @@ -22,11 +22,11 @@ import { } from '../../../common/mock'; import { useRiskyHostsDashboardButtonHref } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'; import { useRiskyHostsDashboardLinks } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links'; -import { useHostRiskScore } from '../../../hosts/containers/host_risk_score'; +import { useHostRiskScore } from '../../../risk_score/containers'; jest.mock('../../../common/lib/kibana'); -jest.mock('../../../hosts/containers/host_risk_score'); +jest.mock('../../../risk_score/containers'); const useHostRiskScoreMock = useHostRiskScore as jest.Mock; jest.mock('../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx index 224e55ecb10b..dc9c48054a1b 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx @@ -9,10 +9,9 @@ import React from 'react'; import { RiskyHostsEnabledModule } from './risky_hosts_enabled_module'; import { RiskyHostsDisabledModule } from './risky_hosts_disabled_module'; -import { useHostRiskScore } from '../../../hosts/containers/host_risk_score'; import { useQueryInspector } from '../../../common/components/page/manage_query'; -import { HostRiskScoreQueryId } from '../../../common/containers/hosts_risk/types'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; +import { useHostRiskScore, HostRiskScoreQueryId } from '../../../risk_score/containers'; export interface RiskyHostLinksProps extends Pick { timerange: { to: string; from: string }; } diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx index f508da6c1c99..51be0e1f9fb9 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx @@ -15,7 +15,7 @@ import { Link } from '../link_panel/link'; import * as i18n from './translations'; import { VIEW_DASHBOARD } from '../overview_cti_links/translations'; import { NavigateToHost } from './navigate_to_host'; -import { HostRiskScoreQueryId } from '../../../common/containers/hosts_risk/types'; +import { HostRiskScoreQueryId } from '../../../risk_score/containers'; const columns: Array> = [ { diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx index 03de4deabfe4..201981157088 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx @@ -12,11 +12,11 @@ import { APP_ID } from '../../../../common/constants'; const MAX_CASES_TO_SHOW = 3; const RecentCasesComponent = () => { - const { cases: casesUi } = useKibana().services; + const { cases } = useKibana().services; const userCanCrud = useGetUserCasesPermissions()?.crud ?? false; - return casesUi.getRecentCases({ + return cases.ui.getRecentCases({ userCanCrud, maxCasesToShow: MAX_CASES_TO_SHOW, owner: [APP_ID], diff --git a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx index dbb28b6efcfb..eaa4c9d6fbe4 100644 --- a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx @@ -11,19 +11,18 @@ import { waitFor } from '@testing-library/react'; import { TestProviders } from '../../../common/mock'; import { Sidebar } from './sidebar'; import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana'; -import { casesPluginMock } from '../../../../../cases/public/mocks'; -import { CasesUiStart } from '../../../../../cases/public'; +import { casesPluginMock, CaseUiClientMock } from '../../../../../cases/public/mocks'; jest.mock('../../../common/lib/kibana'); const useKibanaMock = useKibana as jest.MockedFunction; describe('Sidebar', () => { - let casesMock: jest.Mocked; + let casesMock: CaseUiClientMock; beforeEach(() => { casesMock = casesPluginMock.createStartContract(); - casesMock.getRecentCases.mockImplementation(() => <>{'test'}); + casesMock.ui.getRecentCases.mockImplementation(() => <>{'test'}); useKibanaMock.mockReturnValue({ services: { cases: casesMock, @@ -50,7 +49,7 @@ describe('Sidebar', () => { ) ); - expect(casesMock.getRecentCases).not.toHaveBeenCalled(); + expect(casesMock.ui.getRecentCases).not.toHaveBeenCalled(); }); it('does render the recently created cases section when the user has read permissions', async () => { @@ -67,6 +66,6 @@ describe('Sidebar', () => { ) ); - expect(casesMock.getRecentCases).toHaveBeenCalled(); + expect(casesMock.ui.getRecentCases).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/user_overview/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/overview/components/user_overview/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000000..66b76495b7e6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/user_overview/__snapshots__/index.test.tsx.snap @@ -0,0 +1,359 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`User Summary Component rendering it renders the default User Summary 1`] = ` + +`; + +exports[`User Summary Component rendering it renders the panel view User Summary 1`] = ` + +`; diff --git a/x-pack/plugins/security_solution/public/overview/components/user_overview/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/user_overview/index.test.tsx new file mode 100644 index 000000000000..a93757507645 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/user_overview/index.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 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 { shallow } from 'enzyme'; +import { render } from '@testing-library/react'; +import React from 'react'; +import '../../../common/mock/match_media'; +import { TestProviders } from '../../../common/mock'; + +import { mockAnomalies } from '../../../common/components/ml/mock'; +import { useUserRiskScore } from '../../../risk_score/containers/all'; +import { UserOverview, UserSummaryProps } from '.'; + +jest.mock('../../../risk_score/containers/all', () => ({ + useUserRiskScore: jest.fn().mockReturnValue([ + true, + { + data: [], + isModuleEnabled: false, + }, + ]), +})); + +describe('User Summary Component', () => { + describe('rendering', () => { + const mockProps: UserSummaryProps = { + anomaliesData: mockAnomalies, + data: { + user: { + id: ['aa7ca589f1b8220002f2fc61c64cfbf1'], + name: ['username'], + domain: ['domain'], + }, + host: { + ip: ['10.142.0.7', 'fe80::4001:aff:fe8e:7'], + os: { + family: ['debian'], + name: ['Debian GNU/Linux'], + }, + }, + lastSeen: undefined, + firstSeen: undefined, + }, + endDate: '2019-06-18T06:00:00.000Z', + id: 'userOverview', + isInDetailsSidePanel: false, + isLoadingAnomaliesData: false, + loading: false, + narrowDateRange: jest.fn(), + startDate: '2019-06-15T06:00:00.000Z', + userName: 'testUserName', + }; + + test('it renders the default User Summary', () => { + const wrapper = shallow( + + + + ); + + expect(wrapper.find('UserOverview')).toMatchSnapshot(); + }); + + test('it renders the panel view User Summary', () => { + const panelViewProps = { + ...mockProps, + isInDetailsSidePanel: true, + }; + + const wrapper = shallow( + + + + ); + + expect(wrapper.find('UserOverview')).toMatchSnapshot(); + }); + + test('it renders user risk score and level', () => { + const panelViewProps = { + ...mockProps, + isInDetailsSidePanel: true, + }; + const risk = 'very high hos risk'; + const riskScore = 9999999; + + (useUserRiskScore as jest.Mock).mockReturnValue([ + false, + { + data: [ + { + host: { + name: 'testUsermame', + }, + risk, + risk_stats: { + rule_risks: [], + risk_score: riskScore, + }, + }, + ], + isModuleEnabled: true, + }, + ]); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId('user-risk-overview')).toHaveTextContent(risk); + expect(getByTestId('user-risk-overview')).toHaveTextContent(riskScore.toString()); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/user_overview/index.tsx b/x-pack/plugins/security_solution/public/overview/components/user_overview/index.tsx new file mode 100644 index 000000000000..c098bc47cb54 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/user_overview/index.tsx @@ -0,0 +1,249 @@ +/* + * Copyright 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, EuiFlexGroup } from '@elastic/eui'; +import { euiLightVars as lightTheme, euiDarkVars as darkTheme } from '@kbn/ui-theme'; +import { getOr } from 'lodash/fp'; +import React, { useCallback, useMemo } from 'react'; +import styled from 'styled-components'; +import { buildUserNamesFilter, RiskSeverity } from '../../../../common/search_strategy'; +import { DEFAULT_DARK_MODE } from '../../../../common/constants'; +import { DescriptionList } from '../../../../common/utility_types'; +import { useUiSetting$ } from '../../../common/lib/kibana'; +import { getEmptyTagValue } from '../../../common/components/empty_value'; +import { + dateRenderer, + DefaultFieldRenderer, +} from '../../../timelines/components/field_renderers/field_renderers'; +import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; +import { Loader } from '../../../common/components/loader'; +import { NetworkDetailsLink } from '../../../common/components/links'; +import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; +import { AnomalyScores } from '../../../common/components/ml/score/anomaly_scores'; +import { Anomalies, NarrowDateRange } from '../../../common/components/ml/types'; +import { DescriptionListStyled, OverviewWrapper } from '../../../common/components/page'; + +import * as i18n from './translations'; + +import { OverviewDescriptionList } from '../../../common/components/overview_description_list'; +import { useUserRiskScore } from '../../../risk_score/containers'; +import { RiskScore } from '../../../common/components/severity/common'; +import { UserItem } from '../../../../common/search_strategy/security_solution/users/common'; + +export interface UserSummaryProps { + contextID?: string; // used to provide unique draggable context when viewing in the side panel + data: UserItem; + id: string; + isDraggable?: boolean; + isInDetailsSidePanel: boolean; + loading: boolean; + isLoadingAnomaliesData: boolean; + anomaliesData: Anomalies | null; + startDate: string; + endDate: string; + narrowDateRange: NarrowDateRange; + userName: string; +} + +const UserRiskOverviewWrapper = styled(EuiFlexGroup)` + padding-top: ${({ theme }) => theme.eui.euiSizeM}; + width: 66.6%; +`; + +export const UserOverview = React.memo( + ({ + anomaliesData, + contextID, + data, + id, + isDraggable = false, + isInDetailsSidePanel = false, // Rather than duplicate the component, alter the structure based on it's location + isLoadingAnomaliesData, + loading, + narrowDateRange, + startDate, + endDate, + userName, + }) => { + const capabilities = useMlCapabilities(); + const userPermissions = hasMlUserPermissions(capabilities); + const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); + const [_, { data: userRisk, isModuleEnabled }] = useUserRiskScore({ + filterQuery: userName ? buildUserNamesFilter([userName]) : undefined, + }); + + const getDefaultRenderer = useCallback( + (fieldName: string, fieldData: UserItem) => ( + + ), + [contextID, isDraggable] + ); + + const [userRiskScore, userRiskLevel] = useMemo(() => { + if (isModuleEnabled) { + const userRiskData = userRisk && userRisk.length > 0 ? userRisk[0] : undefined; + return [ + { + title: i18n.USER_RISK_SCORE, + description: ( + <> + {userRiskData ? Math.round(userRiskData.risk_stats.risk_score) : getEmptyTagValue()} + + ), + }, + { + title: i18n.USER_RISK_CLASSIFICATION, + description: ( + <> + {userRiskData ? ( + + ) : ( + getEmptyTagValue() + )} + + ), + }, + ]; + } + return [undefined, undefined]; + }, [userRisk, isModuleEnabled]); + + const column = useMemo( + () => [ + { + title: i18n.USER_ID, + description: data && data.user ? getDefaultRenderer('user.id', data) : getEmptyTagValue(), + }, + { + title: i18n.USER_DOMAIN, + description: + data && data.user ? getDefaultRenderer('user.domain', data) : getEmptyTagValue(), + }, + ], + [data, getDefaultRenderer] + ); + + const firstColumn = useMemo( + () => + userPermissions + ? [ + ...column, + { + title: i18n.MAX_ANOMALY_SCORE_BY_JOB, + description: ( + + ), + }, + ] + : column, + [ + anomaliesData, + column, + endDate, + isLoadingAnomaliesData, + narrowDateRange, + startDate, + userPermissions, + ] + ); + + const descriptionLists: Readonly = useMemo( + () => [ + firstColumn, + [ + { + title: i18n.FIRST_SEEN, + description: data ? dateRenderer(data.firstSeen) : getEmptyTagValue(), + }, + { + title: i18n.LAST_SEEN, + description: data ? dateRenderer(data.lastSeen) : getEmptyTagValue(), + }, + ], + [ + { + title: i18n.HOST_OS, + description: getDefaultRenderer('host.os.name', data), + }, + + { + title: i18n.HOST_FAMILY, + description: getDefaultRenderer('host.os.family', data), + }, + { + title: i18n.HOST_IP, + description: ( + (ip != null ? : getEmptyTagValue())} + /> + ), + }, + ], + ], + [data, getDefaultRenderer, contextID, isDraggable, firstColumn] + ); + return ( + <> + + + {!isInDetailsSidePanel && ( + + )} + {descriptionLists.map((descriptionList, index) => ( + + ))} + + {loading && ( + + )} + + + {userRiskScore && userRiskLevel && ( + + + + + + + + + )} + + ); + } +); + +UserOverview.displayName = 'UserOverview'; diff --git a/x-pack/plugins/security_solution/public/overview/components/user_overview/translations.ts b/x-pack/plugins/security_solution/public/overview/components/user_overview/translations.ts new file mode 100644 index 000000000000..88ee4f726653 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/user_overview/translations.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const USER_ID = i18n.translate('xpack.securitySolution.user.details.overview.userIdTitle', { + defaultMessage: 'User ID', +}); + +export const USER_DOMAIN = i18n.translate( + 'xpack.securitySolution.user.details.overview.userDomainTitle', + { + defaultMessage: 'Domain', + } +); + +export const HOST_FAMILY = i18n.translate( + 'xpack.securitySolution.user.details.overview.familyTitle', + { + defaultMessage: 'Family', + } +); + +export const HOST_IP = i18n.translate( + 'xpack.securitySolution.user.details.overview.ipAddressesTitle', + { + defaultMessage: 'IP addresses', + } +); + +export const HOST_OS = i18n.translate('xpack.securitySolution.user.details.overview.osTitle', { + defaultMessage: 'Operating system', +}); + +export const FIRST_SEEN = i18n.translate( + 'xpack.securitySolution.network.ipDetails.ipOverview.firstSeenTitle', + { + defaultMessage: 'First seen', + } +); + +export const LAST_SEEN = i18n.translate( + 'xpack.securitySolution.user.ipDetails.ipOverview.lastSeenTitle', + { + defaultMessage: 'Last seen', + } +); +export const INSPECT_TITLE = i18n.translate( + 'xpack.securitySolution.user.details.overview.inspectTitle', + { + defaultMessage: 'User overview', + } +); + +export const MAX_ANOMALY_SCORE_BY_JOB = i18n.translate( + 'xpack.securitySolution.user.details.overview.maxAnomalyScoreByJobTitle', + { + defaultMessage: 'Max anomaly score by job', + } +); + +export const USER_RISK_SCORE = i18n.translate( + 'xpack.securitySolution.user.details.overview.userRiskScoreTitle', + { + defaultMessage: 'User risk score', + } +); + +export const USER_RISK_CLASSIFICATION = i18n.translate( + 'xpack.securitySolution.user.details.overview.userRiskClassification', + { + defaultMessage: 'User risk classification', + } +); diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/use_ti_integrations.ts b/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/use_ti_integrations.ts deleted file mode 100644 index 24bdc191b3d6..000000000000 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/use_ti_integrations.ts +++ /dev/null @@ -1,55 +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 { useEffect, useState } from 'react'; - -import { installationStatuses } from '../../../../../fleet/common'; -import { TI_INTEGRATION_PREFIX } from '../../../../common/cti/constants'; -import { fetchFleetIntegrations, IntegrationResponse } from './api'; - -export interface Integration { - id: string; - dashboardIds: string[]; -} - -interface TiIntegrationStatus { - allIntegrationsInstalled: boolean; -} - -export const useTiIntegrations = () => { - const [tiIntegrationsStatus, setTiIntegrationsStatus] = useState( - null - ); - - useEffect(() => { - const getPackages = async () => { - try { - const { response: integrations } = await fetchFleetIntegrations(); - const tiIntegrations = integrations.filter((integration: IntegrationResponse) => - integration.id.startsWith(TI_INTEGRATION_PREFIX) - ); - - const allIntegrationsInstalled = tiIntegrations.every( - (integration: IntegrationResponse) => - integration.status === installationStatuses.Installed - ); - - setTiIntegrationsStatus({ - allIntegrationsInstalled, - }); - } catch (e) { - setTiIntegrationsStatus({ - allIntegrationsInstalled: false, - }); - } - }; - - getPackages(); - }, []); - - return tiIntegrationsStatus; -}; diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index 0226617725e6..200d42075180 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -21,13 +21,12 @@ import { useUserPrivileges } from '../../common/components/user_privileges'; import { useSourcererDataView } from '../../common/containers/sourcerer'; import { useFetchIndex } from '../../common/containers/source'; import { useAllTiDataSources } from '../containers/overview_cti_links/use_all_ti_data_sources'; -import { useTiIntegrations } from '../containers/overview_cti_links/use_ti_integrations'; import { mockCtiLinksResponse, mockTiDataSources } from '../components/overview_cti_links/mock'; import { useCtiDashboardLinks } from '../containers/overview_cti_links'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; import { initialUserPrivilegesState } from '../../common/components/user_privileges/user_privileges_context'; import { EndpointPrivileges } from '../../../common/endpoint/types'; -import { useHostRiskScore } from '../../hosts/containers/host_risk_score'; +import { useHostRiskScore } from '../../risk_score/containers'; jest.mock('../../common/lib/kibana'); jest.mock('../../common/containers/source'); @@ -76,11 +75,7 @@ jest.mock('../containers/overview_cti_links/use_all_ti_data_sources'); const useAllTiDataSourcesMock = useAllTiDataSources as jest.Mock; useAllTiDataSourcesMock.mockReturnValue(mockTiDataSources); -jest.mock('../containers/overview_cti_links/use_ti_integrations'); -const useTiIntegrationsMock = useTiIntegrations as jest.Mock; -useTiIntegrationsMock.mockReturnValue({}); - -jest.mock('../../hosts/containers/host_risk_score'); +jest.mock('../../risk_score/containers'); const useHostRiskScoreMock = useHostRiskScore as jest.Mock; useHostRiskScoreMock.mockReturnValue([false, { data: [], isModuleEnabled: false }]); diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 91c3be79a680..ca95f41e0ea1 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -30,7 +30,6 @@ import { useSourcererDataView } from '../../common/containers/sourcerer'; import { useDeepEqualSelector } from '../../common/hooks/use_selector'; import { ThreatIntelLinkPanel } from '../components/overview_cti_links'; import { useAllTiDataSources } from '../containers/overview_cti_links/use_all_ti_data_sources'; -import { useTiIntegrations } from '../containers/overview_cti_links/use_ti_integrations'; import { useUserPrivileges } from '../../common/components/user_privileges'; import { RiskyHostLinks } from '../components/overview_risky_host_links'; import { useAlertsPrivileges } from '../../detections/containers/detection_engine/alerts/use_alerts_privileges'; @@ -67,10 +66,7 @@ const OverviewComponent = () => { endpointPrivileges: { canAccessFleet }, } = useUserPrivileges(); const { hasIndexRead, hasKibanaREAD } = useAlertsPrivileges(); - const { tiDataSources: allTiDataSources, isInitiallyLoaded: allTiDataSourcesLoaded } = - useAllTiDataSources(); - const tiIntegrationStatus = useTiIntegrations(); - const isTiLoaded = tiIntegrationStatus && allTiDataSourcesLoaded; + const { tiDataSources: allTiDataSources, isInitiallyLoaded: isTiLoaded } = useAllTiDataSources(); const riskyHostsEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled'); @@ -149,7 +145,6 @@ const OverviewComponent = () => { {isTiLoaded && ( SideEffectSimulator = () => { contentRect, borderBoxSize: [{ inlineSize: 0, blockSize: 0 }], contentBoxSize: [{ inlineSize: 0, blockSize: 0 }], + devicePixelContentBoxSize: [], }, ]; this.callback(entries, this); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/host_risk_score/index.tsx b/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx similarity index 63% rename from x-pack/plugins/security_solution/public/hosts/containers/host_risk_score/index.tsx rename to x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx index 7f2c41f1414c..b04d9dd05f28 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/host_risk_score/index.tsx +++ b/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx @@ -10,16 +10,17 @@ import { noop } from 'lodash/fp'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Subscription } from 'rxjs'; -import { inputsModel } from '../../../common/store'; import { createFilter } from '../../../common/containers/helpers'; import { useKibana } from '../../../common/lib/kibana'; import { - HostsQueries, - HostsRiskScoreStrategyResponse, + RiskScoreStrategyResponse, getHostRiskIndex, HostsRiskScore, - HostRiskScoreSortField, - HostsRiskScoreRequestOptions, + UsersRiskScore, + RiskScoreSortField, + RiskScoreRequestOptions, + RiskQueries, + getUserRiskIndex, } from '../../../../common/search_strategy'; import { ESQuery } from '../../../../common/typed_json'; @@ -31,9 +32,11 @@ import { useTransforms } from '../../../transforms/containers/use_transforms'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; import { isIndexNotFoundError } from '../../../common/utils/exceptions'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { inputsModel } from '../../../common/store'; +import { useSpaceId } from '../common'; -export interface HostRiskScoreState { - data?: HostsRiskScore[]; +export interface RiskScoreState { + data?: RiskScoreType; inspect: InspectResponse; isInspected: boolean; refetch: inputsModel.Refetch; @@ -41,48 +44,101 @@ export interface HostRiskScoreState { isModuleEnabled: boolean | undefined; } -interface UseHostRiskScore { - sort?: HostRiskScoreSortField; +interface UseRiskScore { + sort?: RiskScoreSortField; filterQuery?: ESQuery | string; skip?: boolean; timerange?: { to: string; from: string }; - hostName?: string; onlyLatest?: boolean; - pagination?: HostsRiskScoreRequestOptions['pagination']; + pagination?: RiskScoreRequestOptions['pagination']; + featureEnabled: boolean; + defaultIndex: string | undefined; } +type UseHostRiskScore = Omit; + +type UseUserRiskScore = Omit; + const isRecord = (item: unknown): item is Record => typeof item === 'object' && !!item; -export const isHostsRiskScoreHit = (item: Partial): item is HostsRiskScore => +export const isRiskScoreHit = (item: unknown): item is HostsRiskScore | UsersRiskScore => isRecord(item) && - isRecord(item.host) && + (isRecord(item.host) || isRecord(item.user)) && + isRecord(item.risk_stats) && typeof item.risk_stats?.risk_score === 'number' && typeof item.risk === 'string'; export const useHostRiskScore = ({ timerange, - hostName, + onlyLatest, + filterQuery, + sort, + skip = false, + pagination, +}: UseHostRiskScore): [boolean, RiskScoreState] => { + const spaceId = useSpaceId(); + const defaultIndex = spaceId ? getHostRiskIndex(spaceId, onlyLatest) : undefined; + + const riskyHostsFeatureEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled'); + return useRiskScore({ + timerange, + onlyLatest, + filterQuery, + sort, + skip, + pagination, + featureEnabled: riskyHostsFeatureEnabled, + defaultIndex, + }); +}; + +export const useUserRiskScore = ({ + timerange, + onlyLatest, + filterQuery, + sort, + skip = false, + pagination, +}: UseUserRiskScore): [boolean, RiskScoreState] => { + const spaceId = useSpaceId(); + const defaultIndex = spaceId ? getUserRiskIndex(spaceId, onlyLatest) : undefined; + + const usersFeatureEnabled = useIsExperimentalFeatureEnabled('usersEnabled'); + return useRiskScore({ + timerange, + onlyLatest, + filterQuery, + sort, + skip, + pagination, + featureEnabled: usersFeatureEnabled, + defaultIndex, + }); +}; + +export const useRiskScore = ({ + timerange, onlyLatest = true, filterQuery, sort, skip = false, pagination, -}: UseHostRiskScore): [boolean, HostRiskScoreState] => { + featureEnabled, + defaultIndex, +}: UseRiskScore): [boolean, RiskScoreState] => { const { querySize, cursorStart } = pagination || {}; - const { data, spaces } = useKibana().services; + const { data } = useKibana().services; const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const searchSubscription = useRef(new Subscription()); - const riskyHostsFeatureEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled'); - const [loading, setLoading] = useState(riskyHostsFeatureEnabled); - const [riskScoreRequest, setHostRiskScoreRequest] = useState( - null - ); + + const [loading, setLoading] = useState(featureEnabled); + const [riskScoreRequest, setRiskScoreRequest] = useState(null); const { getTransformChangesIfTheyExist } = useTransforms(); const { addError, addWarning } = useAppToasts(); - const [riskScoreResponse, setHostRiskScoreResponse] = useState({ + const [riskScoreResponse, setRiskScoreResponse] = useState>({ data: undefined, inspect: { dsl: [], @@ -95,7 +151,7 @@ export const useHostRiskScore = ({ }); const riskScoreSearch = useCallback( - (request: HostsRiskScoreRequestOptions | null) => { + (request: RiskScoreRequestOptions | null) => { if (request == null || skip) { return; } @@ -105,7 +161,7 @@ export const useHostRiskScore = ({ setLoading(true); searchSubscription.current = data.search - .search(request, { + .search(request, { strategy: 'securitySolutionSearchStrategy', abortSignal: abortCtrl.current.signal, }) @@ -114,11 +170,11 @@ export const useHostRiskScore = ({ if (isCompleteResponse(response)) { const hits = response?.rawResponse?.hits?.hits; - setHostRiskScoreResponse((prevResponse) => ({ + setRiskScoreResponse((prevResponse) => ({ ...prevResponse, - data: isHostsRiskScoreHit(hits?.[0]?._source) - ? (hits?.map((hit) => hit._source) as HostsRiskScore[]) - : [], + data: isRiskScoreHit(hits?.[0]?._source) + ? (hits?.map((hit) => hit._source) as RiskScoreType) + : ([] as unknown as RiskScoreType), inspect: getInspectResponse(response, prevResponse.inspect), refetch: refetch.current, totalCount: response.totalCount, @@ -135,7 +191,7 @@ export const useHostRiskScore = ({ error: (error) => { setLoading(false); if (isIndexNotFoundError(error)) { - setHostRiskScoreResponse((prevResponse) => + setRiskScoreResponse((prevResponse) => !prevResponse ? prevResponse : { @@ -155,38 +211,30 @@ export const useHostRiskScore = ({ }; searchSubscription.current.unsubscribe(); abortCtrl.current.abort(); - if (riskyHostsFeatureEnabled) { + if (featureEnabled) { asyncSearch(); } refetch.current = asyncSearch; }, - [data.search, addError, addWarning, skip, riskyHostsFeatureEnabled] + [data.search, addError, addWarning, skip, featureEnabled] ); - const [spaceId, setSpaceId] = useState(); - - useEffect(() => { - if (spaces) { - spaces.getActiveSpace().then((space) => setSpaceId(space.id)); - } - }, [spaces]); useEffect(() => { - if (spaceId) { - setHostRiskScoreRequest((prevRequest) => { + if (defaultIndex) { + setRiskScoreRequest((prevRequest) => { const myRequest = { ...(prevRequest ?? {}), - defaultIndex: [getHostRiskIndex(spaceId, onlyLatest)], - factoryQueryType: HostsQueries.hostsRiskScore, + defaultIndex: [defaultIndex], + factoryQueryType: RiskQueries.riskScore, filterQuery: createFilter(filterQuery), pagination: - cursorStart && querySize + cursorStart !== undefined && querySize !== undefined ? { cursorStart, querySize, } : undefined, - hostNames: hostName ? [hostName] : undefined, timerange: timerange ? { to: timerange.to, from: timerange.from, interval: '' } : undefined, @@ -201,14 +249,13 @@ export const useHostRiskScore = ({ } }, [ filterQuery, - spaceId, onlyLatest, timerange, cursorStart, querySize, sort, - hostName, getTransformChangesIfTheyExist, + defaultIndex, ]); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/hosts/containers/host_risk_score/translations.ts b/x-pack/plugins/security_solution/public/risk_score/containers/all/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/hosts/containers/host_risk_score/translations.ts rename to x-pack/plugins/security_solution/public/risk_score/containers/all/translations.ts diff --git a/x-pack/plugins/security_solution/public/risk_score/containers/common/index.ts b/x-pack/plugins/security_solution/public/risk_score/containers/common/index.ts new file mode 100644 index 000000000000..1277c08aee5a --- /dev/null +++ b/x-pack/plugins/security_solution/public/risk_score/containers/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. + */ + +import { useState, useEffect } from 'react'; +import { useKibana } from '../../../common/lib/kibana'; + +export const useSpaceId = () => { + const { spaces } = useKibana().services; + + const [spaceId, setSpaceId] = useState(); + + useEffect(() => { + if (spaces) { + spaces.getActiveSpace().then((space) => setSpaceId(space.id)); + } + }, [spaces]); + return spaceId; +}; diff --git a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/types.ts b/x-pack/plugins/security_solution/public/risk_score/containers/index.ts similarity index 74% rename from x-pack/plugins/security_solution/public/common/containers/hosts_risk/types.ts rename to x-pack/plugins/security_solution/public/risk_score/containers/index.ts index 4227dd68abc8..089c88aa9be3 100644 --- a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/types.ts +++ b/x-pack/plugins/security_solution/public/risk_score/containers/index.ts @@ -5,7 +5,14 @@ * 2.0. */ -import { HostsRiskScore } from '../../../../common/search_strategy'; +import { HostsRiskScore } from '../../../common/search_strategy/security_solution/risk_score'; + +export * from './all'; +export * from './kpi'; + +export const enum UserRiskScoreQueryId { + USERS_BY_RISK = 'UsersByRisk', +} export const enum HostRiskScoreQueryId { DEFAULT = 'HostRiskScore', diff --git a/x-pack/plugins/security_solution/public/risk_score/containers/kpi/index.tsx b/x-pack/plugins/security_solution/public/risk_score/containers/kpi/index.tsx new file mode 100644 index 000000000000..bad3799b42ed --- /dev/null +++ b/x-pack/plugins/security_solution/public/risk_score/containers/kpi/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Observable } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { useEffect, useMemo } from 'react'; +import { useObservable, withOptionalSignal } from '@kbn/securitysolution-hook-utils'; +import { createFilter } from '../../../common/containers/helpers'; + +import { + getHostRiskIndex, + getUserRiskIndex, + KpiRiskScoreRequestOptions, + KpiRiskScoreStrategyResponse, + RiskQueries, + RiskScoreAggByFields, + RiskSeverity, +} from '../../../../common/search_strategy'; + +import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/common'; +import type { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; + +import { useKibana } from '../../../common/lib/kibana'; +import { isIndexNotFoundError } from '../../../common/utils/exceptions'; +import { ESTermQuery } from '../../../../common/typed_json'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { SeverityCount } from '../../../common/components/severity/types'; +import { useSpaceId } from '../common'; + +type GetHostsRiskScoreProps = KpiRiskScoreRequestOptions & { + data: DataPublicPluginStart; + signal: AbortSignal; +}; + +const getRiskyHosts = ({ + data, + defaultIndex, + signal, + filterQuery, + aggBy, +}: GetHostsRiskScoreProps): Observable => + data.search.search( + { + defaultIndex, + factoryQueryType: RiskQueries.kpiRiskScore, + filterQuery: createFilter(filterQuery), + aggBy, + }, + { + strategy: 'securitySolutionSearchStrategy', + abortSignal: signal, + } + ); + +const getRiskyHostsComplete = ( + props: GetHostsRiskScoreProps +): Observable => { + return getRiskyHosts(props).pipe( + filter((response) => { + return isErrorResponse(response) || isCompleteResponse(response); + }) + ); +}; + +const getRiskyHostsWithOptionalSignal = withOptionalSignal(getRiskyHostsComplete); + +const useRiskyHostsComplete = () => useObservable(getRiskyHostsWithOptionalSignal); + +interface RiskScoreKpi { + error: unknown; + isModuleDisabled: boolean; + severityCount: SeverityCount; + loading: boolean; +} + +type UseHostRiskScoreKpiProps = Omit< + UseRiskScoreKpiProps, + 'defaultIndex' | 'aggBy' | 'featureEnabled' +>; +type UseUserRiskScoreKpiProps = Omit< + UseRiskScoreKpiProps, + 'defaultIndex' | 'aggBy' | 'featureEnabled' +>; + +export const useUserRiskScoreKpi = ({ + filterQuery, + skip, +}: UseUserRiskScoreKpiProps): RiskScoreKpi => { + const spaceId = useSpaceId(); + const defaultIndex = spaceId ? getUserRiskIndex(spaceId) : undefined; + const usersFeatureEnabled = useIsExperimentalFeatureEnabled('usersEnabled'); + + return useRiskScoreKpi({ + filterQuery, + skip, + defaultIndex, + aggBy: 'user.name', + featureEnabled: usersFeatureEnabled, + }); +}; + +export const useHostRiskScoreKpi = ({ + filterQuery, + skip, +}: UseHostRiskScoreKpiProps): RiskScoreKpi => { + const spaceId = useSpaceId(); + const defaultIndex = spaceId ? getHostRiskIndex(spaceId) : undefined; + const riskyHostsFeatureEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled'); + + return useRiskScoreKpi({ + filterQuery, + skip, + defaultIndex, + aggBy: 'host.name', + featureEnabled: riskyHostsFeatureEnabled, + }); +}; + +interface UseRiskScoreKpiProps { + filterQuery?: string | ESTermQuery; + skip?: boolean; + defaultIndex: string | undefined; + aggBy: RiskScoreAggByFields; + featureEnabled: boolean; +} + +const useRiskScoreKpi = ({ + filterQuery, + skip, + defaultIndex, + aggBy, + featureEnabled, +}: UseRiskScoreKpiProps): RiskScoreKpi => { + const { error, result, start, loading } = useRiskyHostsComplete(); + const { data } = useKibana().services; + const isModuleDisabled = !!error && isIndexNotFoundError(error); + + useEffect(() => { + if (!skip && defaultIndex && featureEnabled) { + start({ + data, + filterQuery, + defaultIndex: [defaultIndex], + aggBy, + }); + } + }, [data, defaultIndex, start, filterQuery, skip, aggBy, featureEnabled]); + + const severityCount = useMemo( + () => ({ + [RiskSeverity.unknown]: 0, + [RiskSeverity.low]: 0, + [RiskSeverity.moderate]: 0, + [RiskSeverity.high]: 0, + [RiskSeverity.critical]: 0, + ...(result?.kpiRiskScore ?? {}), + }), + [result] + ); + return { error, severityCount, loading, isModuleDisabled }; +}; diff --git a/x-pack/plugins/security_solution/public/rules/routes.tsx b/x-pack/plugins/security_solution/public/rules/routes.tsx index fcb434ae760e..4172e75a3cd9 100644 --- a/x-pack/plugins/security_solution/public/rules/routes.tsx +++ b/x-pack/plugins/security_solution/public/rules/routes.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { Route, Switch } from 'react-router-dom'; +import * as i18n from './translations'; import { TrackApplicationView } from '../../../../../src/plugins/usage_collection/public'; import { RULES_PATH, SecurityPageName } from '../../common/constants'; import { NotFoundPage } from '../app/404'; @@ -14,6 +15,7 @@ import { RulesPage } from '../detections/pages/detection_engine/rules'; import { CreateRulePage } from '../detections/pages/detection_engine/rules/create'; import { RuleDetailsPage } from '../detections/pages/detection_engine/rules/details'; import { EditRulePage } from '../detections/pages/detection_engine/rules/edit'; +import { useReadonlyHeader } from '../use_readonly_header'; const RulesSubRoutes = [ { @@ -38,18 +40,26 @@ const RulesSubRoutes = [ }, ]; -const renderRulesRoutes = () => ( - - - {RulesSubRoutes.map((route, index) => ( - - - - ))} - - - -); +const RulesContainerComponent: React.FC = () => { + useReadonlyHeader(i18n.READ_ONLY_BADGE_TOOLTIP); + + return ( + + + {RulesSubRoutes.map((route, index) => ( + + + + ))} + + + + ); +}; + +const Rules = React.memo(RulesContainerComponent); + +const renderRulesRoutes = () => ; export const routes = [ { diff --git a/x-pack/plugins/cases/public/methods/index.ts b/x-pack/plugins/security_solution/public/rules/translations.ts similarity index 53% rename from x-pack/plugins/cases/public/methods/index.ts rename to x-pack/plugins/security_solution/public/rules/translations.ts index 375bd42ee858..2d2c5de70dba 100644 --- a/x-pack/plugins/cases/public/methods/index.ts +++ b/x-pack/plugins/security_solution/public/rules/translations.ts @@ -5,8 +5,11 @@ * 2.0. */ -export * from './can_use_cases'; -export * from './get_cases'; -export * from './get_recent_cases'; -export * from './get_all_cases_selector_modal'; -export * from './get_create_case_flyout'; +import { i18n } from '@kbn/i18n'; + +export const READ_ONLY_BADGE_TOOLTIP = i18n.translate( + 'xpack.securitySolution.rules.badge.readOnly.tooltip', + { + defaultMessage: 'Unable to create, edit or delete rules', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.test.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.test.tsx rename to x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.test.tsx index 0afb2bf64135..1bddd96c0572 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.test.tsx @@ -11,15 +11,15 @@ import { CreateFieldButton, CreateFieldEditorActions } from './index'; import { indexPatternFieldEditorPluginMock, Start, -} from '../../../../../../../src/plugins/data_view_field_editor/public/mocks'; +} from '../../../../../../../../src/plugins/data_view_field_editor/public/mocks'; -import { TestProviders } from '../../../common/mock'; -import { useKibana } from '../../../common/lib/kibana'; -import type { DataView } from '../../../../../../../src/plugins/data/common'; -import { TimelineId } from '../../../../common/types'; +import { TestProviders } from '../../../../common/mock'; +import { useKibana } from '../../../../common/lib/kibana'; +import type { DataView } from '../../../../../../../../src/plugins/data/common'; +import { TimelineId } from '../../../../../common/types'; let mockIndexPatternFieldEditor: Start; -jest.mock('../../../common/lib/kibana'); +jest.mock('../../../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; const runAllPromises = () => new Promise(setImmediate); diff --git a/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.tsx similarity index 80% rename from x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx rename to x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.tsx index 8979a78d7aa4..645e1f0b29ae 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.tsx @@ -10,23 +10,26 @@ import { EuiButton } from '@elastic/eui'; import styled from 'styled-components'; import { useDispatch } from 'react-redux'; -import type { DataViewField, DataView } from '../../../../../../../src/plugins/data_views/common'; -import { useKibana } from '../../../common/lib/kibana'; +import type { + DataViewField, + DataView, +} from '../../../../../../../../src/plugins/data_views/common'; +import { useKibana } from '../../../../common/lib/kibana'; import * as i18n from './translations'; -import { CreateFieldComponentType, TimelineId } from '../../../../../timelines/common'; -import { upsertColumn } from '../../../../../timelines/public'; -import { useDataView } from '../../../common/containers/source/use_data_view'; -import { SourcererScopeName } from '../../../common/store/sourcerer/model'; -import { sourcererSelectors } from '../../../common/store'; -import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; -import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/constants'; -import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers'; +import { FieldBrowserOptions, TimelineId } from '../../../../../../timelines/common'; +import { upsertColumn } from '../../../../../../timelines/public'; +import { useDataView } from '../../../../common/containers/source/use_data_view'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { sourcererSelectors } from '../../../../common/store'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../../timeline/body/constants'; +import { defaultColumnHeaderType } from '../../timeline/body/column_headers/default_headers'; export type CreateFieldEditorActions = { closeEditor: () => void } | null; -type CreateFieldEditorActionsRef = MutableRefObject; +export type CreateFieldEditorActionsRef = MutableRefObject; -interface CreateFieldButtonProps { +export interface CreateFieldButtonProps { selectedDataViewId: string; onClick: () => void; timelineId: TimelineId; @@ -142,7 +145,7 @@ export const useCreateFieldButton = ( return; } // It receives onClick props from field browser in order to close the modal. - const CreateFieldButtonComponent: CreateFieldComponentType = ({ onClick }) => ( + const CreateFieldButtonComponent: FieldBrowserOptions['createFieldButton'] = ({ onClick }) => ( void; -}) => { - const keyboardHandlerRef = useRef(null); - const [closePopOverTrigger, setClosePopOverTrigger] = useState(false); - const [hoverActionsOwnFocus, setHoverActionsOwnFocus] = useState(false); - const { timelines } = useKibana().services; - - const handleClosePopOverTrigger = useCallback(() => { - setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger); - - setHoverActionsOwnFocus((prevHoverActionsOwnFocus) => { - if (prevHoverActionsOwnFocus) { - // on the next tick, re-focus the keyboard handler if the hover actions owned focus - setTimeout(() => { - keyboardHandlerRef.current?.focus(); - }, 0); - } - return false; // always give up ownership - }); - - setTimeout(() => { - setHoverActionsOwnFocus(false); - }, 0); // invoked on the next tick, because we want to restore focus first - }, []); - - const openPopover = useCallback(() => { - setHoverActionsOwnFocus(true); - }, [setHoverActionsOwnFocus]); - - const { onBlur, onKeyDown } = timelines.getUseDraggableKeyboardWrapper()({ - closePopover: handleClosePopOverTrigger, - draggableId: getDraggableFieldId({ - contextId: `field-browser-field-items-field-draggable-${timelineId}-${categoryId}-${fieldName}`, - fieldId: fieldName, - }), - fieldName, - keyboardHandlerRef, - openPopover, - }); - - const onFocus = useCallback(() => { - keyboardHandlerRef.current?.focus(); - }, []); - - const onCloseRequested = useCallback(() => { - setHoverActionsOwnFocus((prevHoverActionOwnFocus) => - prevHoverActionOwnFocus ? false : prevHoverActionOwnFocus - ); - - setTimeout(() => { - onFocus(); // return focus to this draggable on the next tick, because we owned focus - }, 0); - }, [onFocus]); - - return ( -
    - - {(provided) => ( -
    - -
    - )} -
    -
    - ); -}; - -export const DraggableFieldsBrowserField = React.memo(DraggableFieldsBrowserFieldComponent); -DraggableFieldsBrowserField.displayName = 'DraggableFieldsBrowserFieldComponent'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx deleted file mode 100644 index 5acc0ef9aa46..000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx +++ /dev/null @@ -1,81 +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 { mount } from 'enzyme'; -import React from 'react'; -import { waitFor } from '@testing-library/react'; -import { mockBrowserFields } from '../../../common/containers/source/mock'; -import { TestProviders } from '../../../common/mock'; -import '../../../common/mock/match_media'; -import { getColumnsWithTimestamp } from '../../../common/components/event_details/helpers'; - -import { FieldName } from './field_name'; - -jest.mock('../../../common/lib/kibana'); - -const categoryId = 'base'; -const timestampFieldId = '@timestamp'; - -const defaultProps = { - categoryId, - categoryColumns: getColumnsWithTimestamp({ - browserFields: mockBrowserFields, - category: categoryId, - }), - closePopOverTrigger: false, - fieldId: timestampFieldId, - handleClosePopOverTrigger: jest.fn(), - hoverActionsOwnFocus: false, - onCloseRequested: jest.fn(), - onUpdateColumns: jest.fn(), - setClosePopOverTrigger: jest.fn(), -}; - -describe('FieldName', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - test('it renders the field name', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find(`[data-test-subj="field-name-${timestampFieldId}"]`).first().text() - ).toEqual(timestampFieldId); - }); - - test('it renders a copy to clipboard action menu item a user hovers over the name', async () => { - const wrapper = mount( - - - - ); - await waitFor(() => { - wrapper.find('[data-test-subj="withHoverActionsButton"]').simulate('mouseenter'); - wrapper.update(); - jest.runAllTimers(); - wrapper.update(); - expect(wrapper.find('[data-test-subj="hover-actions-copy-button"]').exists()).toBe(true); - }); - }); - - test('it highlights the text specified by the `highlight` prop', () => { - const highlight = 'stamp'; - - const wrapper = mount( - - - - ); - - expect(wrapper.find('mark').first().text()).toEqual(highlight); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx deleted file mode 100644 index 6e9672d08b36..000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx +++ /dev/null @@ -1,165 +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 { EuiHighlight, EuiText } from '@elastic/eui'; -import React, { useCallback, useState, useMemo, useRef, useContext } from 'react'; -import styled from 'styled-components'; - -import { OnUpdateColumns } from '../timeline/events'; -import { WithHoverActions } from '../../../common/components/with_hover_actions'; -import { ColumnHeaderOptions } from '../../../../common/types'; -import { HoverActions } from '../../../common/components/hover_actions'; -import { TimelineContext } from '../../../../../timelines/public'; - -/** - * The name of a (draggable) field - */ -export const FieldNameContainer = styled.span` - border-radius: 4px; - display: flex; - padding: 0 4px 0 8px; - position: relative; - - &::before { - background-image: linear-gradient( - 135deg, - ${({ theme }) => theme.eui.euiColorMediumShade} 25%, - transparent 25% - ), - linear-gradient(-135deg, ${({ theme }) => theme.eui.euiColorMediumShade} 25%, transparent 25%), - linear-gradient(135deg, transparent 75%, ${({ theme }) => theme.eui.euiColorMediumShade} 75%), - linear-gradient(-135deg, transparent 75%, ${({ theme }) => theme.eui.euiColorMediumShade} 75%); - background-position: 0 0, 1px 0, 1px -1px, 0px 1px; - background-size: 2px 2px; - bottom: 2px; - content: ''; - display: block; - left: 2px; - position: absolute; - top: 2px; - width: 4px; - } - - &:hover, - &:focus { - transition: background-color 0.7s ease; - background-color: #000; - color: #fff; - - &::before { - background-image: linear-gradient(135deg, #fff 25%, transparent 25%), - linear-gradient( - -135deg, - ${({ theme }) => theme.eui.euiColorLightestShade} 25%, - transparent 25% - ), - linear-gradient( - 135deg, - transparent 75%, - ${({ theme }) => theme.eui.euiColorLightestShade} 75% - ), - linear-gradient( - -135deg, - transparent 75%, - ${({ theme }) => theme.eui.euiColorLightestShade} 75% - ); - } - } -`; - -FieldNameContainer.displayName = 'FieldNameContainer'; - -/** Renders a field name in it's non-dragging state */ -export const FieldName = React.memo<{ - categoryId: string; - categoryColumns: ColumnHeaderOptions[]; - closePopOverTrigger: boolean; - fieldId: string; - highlight?: string; - handleClosePopOverTrigger: () => void; - hoverActionsOwnFocus: boolean; - onCloseRequested: () => void; - onUpdateColumns: OnUpdateColumns; -}>( - ({ - closePopOverTrigger, - fieldId, - highlight = '', - handleClosePopOverTrigger, - hoverActionsOwnFocus, - onCloseRequested, - }) => { - const containerRef = useRef(null); - const [showTopN, setShowTopN] = useState(false); - const { timelineId: timelineIdFind } = useContext(TimelineContext); - - const toggleTopN = useCallback(() => { - setShowTopN((prevShowTopN) => { - const newShowTopN = !prevShowTopN; - if (newShowTopN === false) { - handleClosePopOverTrigger(); - } - return newShowTopN; - }); - }, [handleClosePopOverTrigger]); - - const closeTopN = useCallback(() => { - setShowTopN(false); - }, []); - - const hoverContent = useMemo( - () => ( - - ), - [ - closeTopN, - fieldId, - handleClosePopOverTrigger, - hoverActionsOwnFocus, - showTopN, - timelineIdFind, - toggleTopN, - ] - ); - - const render = useCallback( - () => ( - - - - {fieldId} - - - - ), - [fieldId, highlight] - ); - - return ( -
    - -
    - ); - } -); - -FieldName.displayName = 'FieldName'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_table_columns/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_table_columns/index.tsx new file mode 100644 index 000000000000..b060575fdc5c --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_table_columns/index.tsx @@ -0,0 +1,117 @@ +/* + * Copyright 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 styled from 'styled-components'; +import { + EuiToolTip, + EuiFlexGroup, + EuiFlexItem, + EuiScreenReaderOnly, + EuiHealth, + EuiBadge, + EuiIcon, + EuiText, + EuiHighlight, +} from '@elastic/eui'; +import type { FieldTableColumns } from '../../../../../../timelines/common/types'; +import * as i18n from './translations'; +import { + getExampleText, + getIconFromType, +} from '../../../../common/components/event_details/helpers'; +import { getEmptyValue } from '../../../../common/components/empty_value'; +import { EllipsisText } from '../../../../common/components/truncatable_text'; + +const TypeIcon = styled(EuiIcon)` + margin: 0 4px; + position: relative; + top: -1px; +`; +TypeIcon.displayName = 'TypeIcon'; + +export const Description = styled.span` + user-select: text; + width: 400px; +`; +Description.displayName = 'Description'; + +export const FieldName = React.memo<{ + fieldId: string; + highlight?: string; +}>(({ fieldId, highlight = '' }) => ( + + + {fieldId} + + +)); +FieldName.displayName = 'FieldName'; + +export const getFieldTableColumns = (highlight: string): FieldTableColumns => [ + { + field: 'name', + name: i18n.NAME, + render: (name: string, { type }) => { + return ( + + + + + + + + + + + + ); + }, + sortable: true, + width: '200px', + }, + { + field: 'description', + name: i18n.DESCRIPTION, + render: (description, { name, example }) => ( + + <> + +

    {i18n.DESCRIPTION_FOR_FIELD(name)}

    +
    + + + {`${description ?? getEmptyValue()} ${getExampleText(example)}`} + + + +
    + ), + sortable: true, + width: '400px', + }, + { + field: 'isRuntime', + name: i18n.RUNTIME, + render: (isRuntime: boolean) => + isRuntime ? : null, + sortable: true, + width: '80px', + }, + { + field: 'category', + name: i18n.CATEGORY, + render: (category: string, { name }) => ( + {category} + ), + sortable: true, + width: '100px', + }, +]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_table_columns/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_table_columns/translations.ts new file mode 100644 index 000000000000..c16307250c2c --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_table_columns/translations.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 { i18n } from '@kbn/i18n'; + +export const NAME = i18n.translate('xpack.securitySolution.fieldBrowser.fieldName', { + defaultMessage: 'Name', +}); + +export const DESCRIPTION = i18n.translate('xpack.securitySolution.fieldBrowser.descriptionLabel', { + defaultMessage: 'Description', +}); + +export const DESCRIPTION_FOR_FIELD = (field: string) => + i18n.translate('xpack.securitySolution.fieldBrowser.descriptionForScreenReaderOnly', { + values: { + field, + }, + defaultMessage: 'Description for field {field}:', + }); + +export const CATEGORY = i18n.translate('xpack.securitySolution.fieldBrowser.categoryLabel', { + defaultMessage: 'Category', +}); + +export const RUNTIME = i18n.translate('xpack.securitySolution.fieldBrowser.runtimeLabel', { + defaultMessage: 'Runtime', +}); + +export const RUNTIME_FIELD = i18n.translate('xpack.securitySolution.fieldBrowser.runtimeTitle', { + defaultMessage: 'Runtime Field', +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx new file mode 100644 index 000000000000..46f2caa147a4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.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 { TimelineId } from '../../../../common/types'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { useCreateFieldButton, CreateFieldEditorActionsRef } from './create_field_button'; +import { getFieldTableColumns } from './field_table_columns'; + +export type { CreateFieldEditorActions } from './create_field_button'; + +export interface UseFieldBrowserOptions { + sourcererScope: SourcererScopeName; + timelineId: TimelineId; + editorActionsRef?: CreateFieldEditorActionsRef; +} + +export const useFieldBrowserOptions = ({ + sourcererScope, + timelineId, + editorActionsRef, +}: UseFieldBrowserOptions) => { + const createFieldButton = useCreateFieldButton(sourcererScope, timelineId, editorActionsRef); + return { + createFieldButton, + getFieldTableColumns, + }; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx index 66007bbbd5d0..7fd00667f57b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx @@ -47,7 +47,7 @@ describe('AddToCaseButton', () => { it('navigates to the correct path without id', async () => { const here = jest.fn(); - useKibanaMock().services.cases.getAllCasesSelectorModal = here.mockImplementation( + useKibanaMock().services.cases.ui.getAllCasesSelectorModal = here.mockImplementation( ({ onRowClick }) => { onRowClick(); return <>; @@ -69,7 +69,7 @@ describe('AddToCaseButton', () => { }); it('navigates to the correct path with id', async () => { - useKibanaMock().services.cases.getAllCasesSelectorModal = jest + useKibanaMock().services.cases.ui.getAllCasesSelectorModal = jest .fn() .mockImplementation(({ onRowClick }) => { onRowClick({ id: 'case-id' }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx index c7ad1795cab5..c83507570410 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx @@ -161,7 +161,7 @@ const AddToCaseButtonComponent: React.FC = ({ timelineId }) => { {isCaseModalOpen && - cases.getAllCasesSelectorModal({ + cases.ui.getAllCasesSelectorModal({ onRowClick, userCanCrud: userPermissions?.crud ?? false, owner: [APP_ID], diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index 7324213975a7..5108fa86b0f5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -58,187 +58,264 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should tabType="query" timelineId="test" > - - - -
    - -
    - - -
    +
    + + - - - -
    - -
    - - - - -
    - - +
    +
    +
    + + + + +
    + + - - - - + className="euiLoadingContent__singleLine" + key="0" + > + + - - + className="euiLoadingContent__singleLine" + key="1" + > + + - - + className="euiLoadingContent__singleLine" + key="2" + > + + - - + className="euiLoadingContent__singleLine" + key="3" + > + + - - + className="euiLoadingContent__singleLine" + key="4" + > + + - - + className="euiLoadingContent__singleLine" + key="5" + > + + - - + className="euiLoadingContent__singleLine" + key="6" + > + + - - + className="euiLoadingContent__singleLine" + key="7" + > + + - - + className="euiLoadingContent__singleLine" + key="8" + > + + + className="euiLoadingContent__singleLine" + key="9" + > + + - - - + + + + + +
    + +
    + +
    + +
    + +
    + + + + `; @@ -508,9 +585,12 @@ Array [ } } > - +
    - +
    void; interface Props { diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.test.tsx index 71d6f6253010..4a0b7d0fe050 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.test.tsx @@ -15,6 +15,7 @@ import { mockAlertDetailsData } from '../../../../common/components/event_detail import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy'; import { KibanaServices, useKibana } from '../../../../common/lib/kibana'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { mockCasesContract } from '../../../../../../cases/public/mocks'; const ecsData: Ecs = { _id: '1', @@ -114,6 +115,7 @@ describe('event details footer component', () => { }, query: jest.fn(), }, + cases: mockCasesContract(), }, }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx index bbb3206bf823..e97568a4ae52 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx @@ -109,7 +109,7 @@ export const EventDetailsFooterComponent = React.memo( return ( <> - + {detailsEcsData && ( @@ -145,7 +145,11 @@ export const EventDetailsFooterComponent = React.memo( /> )} {isAddEventFilterModalOpen && detailsEcsData != null && ( - + )} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx new file mode 100644 index 000000000000..bdb896e31cfa --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx @@ -0,0 +1,170 @@ +/* + * Copyright 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 { render } from '@testing-library/react'; +import { EventDetailsPanel } from './'; +import '../../../../common/mock/match_media'; +import { TestProviders } from '../../../../common/mock'; +import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; +import { Ecs } from '../../../../../common/ecs'; +import { mockAlertDetailsData } from '../../../../common/components/event_details/__mocks__'; +import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy'; +import { + KibanaServices, + useKibana, + useGetUserCasesPermissions, +} from '../../../../common/lib/kibana'; +import { + mockBrowserFields, + mockDocValueFields, + mockRuntimeMappings, +} from '../../../../common/containers/source/mock'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { mockCasesContext } from '../../../../../../cases/public/mocks/mock_cases_context'; + +const ecsData: Ecs = { + _id: '1', + agent: { type: ['blah'] }, + kibana: { + alert: { + workflow_status: ['open'], + rule: { + parameters: {}, + uuid: ['testId'], + }, + }, + }, +}; + +const mockAlertDetailsDataWithIsObject = mockAlertDetailsData.map((detail) => { + return { + ...detail, + isObjectArray: false, + }; +}) as TimelineEventsDetailsItem[]; + +jest.mock('../../../../../common/endpoint/service/host_isolation/utils', () => { + return { + isIsolationSupported: jest.fn().mockReturnValue(true), + }; +}); + +jest.mock( + '../../../../detections/containers/detection_engine/alerts/use_host_isolation_status', + () => { + return { + useHostIsolationStatus: jest.fn().mockReturnValue({ + loading: false, + isIsolated: false, + agentStatus: 'healthy', + }), + }; + } +); + +jest.mock('../../../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), +})); + +jest.mock('../../../../detections/components/user_info', () => ({ + useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]), +})); +jest.mock('../../../../common/lib/kibana'); +jest.mock( + '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges', + () => ({ + useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true, hasKibanaCRUD: true }), + }) +); +jest.mock('../../../../cases/components/use_insert_timeline'); + +jest.mock('../../../../common/utils/endpoint_alert_check', () => { + return { + isAlertFromEndpointAlert: jest.fn().mockReturnValue(true), + isAlertFromEndpointEvent: jest.fn().mockReturnValue(true), + }; +}); +jest.mock( + '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline', + () => { + return { + useInvestigateInTimeline: jest.fn().mockReturnValue({ + investigateInTimelineActionItems: [
    ], + investigateInTimelineAlertClick: () => {}, + }), + }; + } +); +jest.mock('../../../../detections/components/alerts_table/actions'); +const mockSearchStrategy = jest.fn(); + +const defaultProps = { + timelineId: TimelineId.test, + loadingEventDetails: false, + detailsEcsData: ecsData, + isHostIsolationPanelOpen: false, + handleOnEventClosed: jest.fn(), + onAddIsolationStatusClick: jest.fn(), + expandedEvent: { eventId: ecsData._id, indexName: '' }, + detailsData: mockAlertDetailsDataWithIsObject, + tabType: TimelineTabs.query, + browserFields: mockBrowserFields, + docValueFields: mockDocValueFields, + runtimeMappings: mockRuntimeMappings, +}; + +describe('event details footer component', () => { + beforeEach(() => { + const coreStartMock = coreMock.createStart(); + (KibanaServices.get as jest.Mock).mockReturnValue(coreStartMock); + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + search: { + searchStrategyClient: jest.fn(), + search: mockSearchStrategy.mockReturnValue({ + unsubscribe: jest.fn(), + subscribe: jest.fn(), + }), + }, + query: jest.fn(), + }, + uiSettings: { + get: jest.fn().mockReturnValue([]), + }, + cases: { + ui: { + getCasesContext: () => mockCasesContext, + }, + }, + }, + }); + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: true, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + test('it renders the take action dropdown in the timeline version', () => { + const wrapper = render( + + + + ); + expect(wrapper.getByTestId('side-panel-flyout-footer')).toBeTruthy(); + }); + test('it renders the take action dropdown in the flyout version', () => { + const wrapper = render( + + + + ); + expect(wrapper.getByTestId('side-panel-flyout-footer')).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index 057f18899e8f..7577789408a8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -19,6 +19,8 @@ import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; +import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana'; +import { APP_ID } from '../../../../../common/constants'; import { ExpandableEvent, ExpandableEventTitle } from './expandable_event'; import { useTimelineEventsDetails } from '../../../containers/details'; import { TimelineTabs } from '../../../../../common/types/timeline'; @@ -33,8 +35,8 @@ import { ALERT_DETAILS } from './translations'; import { useWithCaseDetailsRefresh } from '../../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context'; import { EventDetailsFooter } from './footer'; import { EntityType } from '../../../../../../timelines/common'; -import { useHostRiskScore } from '../../../../hosts/containers/host_risk_score'; -import { HostRisk } from '../../../../common/containers/hosts_risk/types'; +import { buildHostNamesFilter } from '../../../../../common/search_strategy'; +import { useHostRiskScore, HostRisk } from '../../../../risk_score/containers'; const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` .euiFlyoutBody__overflow { @@ -94,6 +96,13 @@ const EventDetailsPanelComponent: React.FC = ({ 'isolateHost' ); + const { + services: { cases }, + } = useKibana(); + + const CasesContext = cases.ui.getCasesContext(); + const casesPermissions = useGetUserCasesPermissions(); + const [isIsolateActionSuccessBannerVisible, setIsIsolateActionSuccessBannerVisible] = useState(false); @@ -127,7 +136,7 @@ const EventDetailsPanelComponent: React.FC = ({ ); const [hostRiskLoading, { data, isModuleEnabled }] = useHostRiskScore({ - hostName, + filterQuery: hostName ? buildHostNamesFilter([hostName]) : undefined, pagination: { cursorStart: 0, querySize: 1, @@ -239,7 +248,7 @@ const EventDetailsPanelComponent: React.FC = ({ /> ) : ( - <> + = ({ hostRisk={hostRisk} handleOnEventClosed={handleOnEventClosed} /> - + + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx index 48aa0853be49..9809a15eee23 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx @@ -25,6 +25,8 @@ import { } from '../../../../common/types/timeline'; import { FlowTarget } from '../../../../common/search_strategy/security_solution/network'; import { EventDetailsPanel } from './event_details'; +import { useKibana } from '../../../common/lib/kibana'; +import { mockCasesContext } from '../../../../../cases/public/mocks/mock_cases_context'; jest.mock('../../../common/lib/kibana'); @@ -99,9 +101,34 @@ describe('Details Panel Component', () => { timelineId: 'test', }; + const mockSearchStrategy = jest.fn(); + describe('DetailsPanel: rendering', () => { beforeEach(() => { store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + search: { + searchStrategyClient: jest.fn(), + search: mockSearchStrategy.mockReturnValue({ + unsubscribe: jest.fn(), + subscribe: jest.fn(), + }), + }, + query: jest.fn(), + }, + uiSettings: { + get: jest.fn().mockReturnValue([]), + }, + application: { + navigateToApp: jest.fn(), + }, + cases: { + ui: { getCasesContext: () => mockCasesContext }, + }, + }, + }); }); test('it should not render the DetailsPanel if no expanded detail has been set in the reducer', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/user_details/expandable_user.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/user_details/expandable_user.tsx index e21666c6d513..2f46cc41a364 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/user_details/expandable_user.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/user_details/expandable_user.tsx @@ -10,7 +10,17 @@ import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; import React from 'react'; import { UserDetailsLink } from '../../../../common/components/links'; +import { UserOverview } from '../../../../overview/components/user_overview'; +import { useUserDetails } from '../../../../users/containers/users/details'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { useSourcererDataView } from '../../../../common/containers/sourcerer'; +import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions'; +import { getCriteriaFromUsersType } from '../../../../common/components/ml/criteria/get_criteria_from_users_type'; +import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime'; +import { AnomalyTableProvider } from '../../../../common/components/ml/anomaly/anomaly_table_provider'; +import { UsersType } from '../../../../users/store/model'; +export const QUERY_ID = 'usersDetailsQuery'; export interface ExpandableUserProps { userName: string; } @@ -45,5 +55,47 @@ export const ExpandableUserDetails = ({ userName, isDraggable, }: ExpandableUserProps & { contextID: string; isDraggable?: boolean }) => { - return <>{'TODO I am empty'}; + const { to, from, isInitializing } = useGlobalTime(); + const { selectedPatterns } = useSourcererDataView(); + + const [loading, { userDetails }] = useUserDetails({ + endDate: to, + startDate: from, + userName, + indexNames: selectedPatterns, + skip: isInitializing, + }); + + return ( + + {({ isLoadingAnomaliesData, anomaliesData }) => ( + { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }} + /> + )} + + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/header_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/header_actions.tsx index 9636aadbc08e..0e26edc6ae1c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/header_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/header_actions.tsx @@ -86,7 +86,7 @@ const HeaderActionsComponent: React.FC = ({ sort, tabType, timelineId, - createFieldComponent, + fieldBrowserOptions, }) => { const { timelines: timelinesUi } = useKibana().services; const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); @@ -184,7 +184,7 @@ const HeaderActionsComponent: React.FC = ({ browserFields, columnHeaders, timelineId, - createFieldComponent, + options: fieldBrowserOptions, })} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx index b6e6aa40876c..7a94dcef31cf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx @@ -12,6 +12,7 @@ import { TestProviders, mockTimelineModel, mockTimelineData } from '../../../../ import { Actions } from '.'; import { mockTimelines } from '../../../../../common/mock/mock_timelines_plugin'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; +import { mockCasesContract } from '../../../../../../../cases/public/mocks'; jest.mock('../../../../../detections/components/user_info', () => ({ useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]), @@ -43,6 +44,7 @@ jest.mock('../../../../../common/lib/kibana', () => ({ siem: { crud_alerts: true, read_alerts: true }, }, }, + cases: mockCasesContract(), uiSettings: { get: jest.fn(), }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx index aec28732f38a..7e3de3514f5a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx @@ -28,7 +28,7 @@ import { HeaderActions } from '../actions/header_actions'; jest.mock('../../../../../common/lib/kibana'); const mockUseCreateFieldButton = jest.fn().mockReturnValue(<>); -jest.mock('../../../create_field_button', () => ({ +jest.mock('../../../fields_browser/create_field_button', () => ({ useCreateFieldButton: (...params: unknown[]) => mockUseCreateFieldButton(...params), })); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx index ca1cdef903de..e58dd520181c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx @@ -34,7 +34,7 @@ import { Sort } from '../sort'; import { ColumnHeader } from './column_header'; import { SourcererScopeName } from '../../../../../common/store/sourcerer/model'; -import { CreateFieldEditorActions, useCreateFieldButton } from '../../../create_field_button'; +import { useFieldBrowserOptions, CreateFieldEditorActions } from '../../../fields_browser'; export interface ColumnHeadersComponentProps { actionsColumnWidth: number; @@ -190,11 +190,11 @@ export const ColumnHeadersComponent = ({ [trailingControlColumns] ); - const createFieldComponent = useCreateFieldButton( - SourcererScopeName.timeline, - timelineId as TimelineId, - fieldEditorActionsRef - ); + const fieldBrowserOptions = useFieldBrowserOptions({ + sourcererScope: SourcererScopeName.timeline, + timelineId: timelineId as TimelineId, + editorActionsRef: fieldEditorActionsRef, + }); const LeadingHeaderActions = useMemo(() => { return leadingHeaderCells.map( @@ -221,7 +221,7 @@ export const ColumnHeadersComponent = ({ sort={sort} tabType={tabType} timelineId={timelineId} - createFieldComponent={createFieldComponent} + fieldBrowserOptions={fieldBrowserOptions} /> )} @@ -234,7 +234,7 @@ export const ColumnHeadersComponent = ({ actionsColumnWidth, browserFields, columnHeaders, - createFieldComponent, + fieldBrowserOptions, isEventViewer, isSelectAllChecked, onSelectAll, @@ -270,7 +270,7 @@ export const ColumnHeadersComponent = ({ sort={sort} tabType={tabType} timelineId={timelineId} - createFieldComponent={createFieldComponent} + fieldBrowserOptions={fieldBrowserOptions} /> )} @@ -283,7 +283,7 @@ export const ColumnHeadersComponent = ({ actionsColumnWidth, browserFields, columnHeaders, - createFieldComponent, + fieldBrowserOptions, isEventViewer, isSelectAllChecked, onSelectAll, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx index b9e04060881d..ac4a09c01cc0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx @@ -20,6 +20,7 @@ import { getDefaultControlColumn } from '../control_columns'; import { testLeadingControlColumn } from '../../../../../common/mock/mock_timeline_control_columns'; import { mockTimelines } from '../../../../../common/mock/mock_timelines_plugin'; import { getActionsColumnWidth } from '../../../../../../../timelines/public'; +import { mockCasesContract } from '../../../../../../../cases/public/mocks'; jest.mock('../../../../../common/hooks/use_experimental_features'); const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; @@ -40,6 +41,7 @@ jest.mock('../../../../../common/lib/kibana', () => ({ siem: { crud_alerts: true, read_alerts: true }, }, }, + cases: mockCasesContract(), }, }), useToasts: jest.fn().mockReturnValue({ @@ -50,17 +52,6 @@ jest.mock('../../../../../common/lib/kibana', () => ({ useGetUserCasesPermissions: jest.fn(), })); -jest.mock( - '../../../../../../../timelines/public/components/actions/timeline/cases/add_to_case_action', - () => { - return { - AddToCasePopover: () => { - return
    {'Add to case'}
    ; - }, - }; - } -); - describe('EventColumnView', () => { useIsExperimentalFeatureEnabledMock.mockReturnValue(false); (useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineType.default); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 66a140987475..bcb4c01fa409 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -24,12 +24,12 @@ import { useMountAppended } from '../../../../common/utils/use_mount_appended'; import { timelineActions } from '../../../store/timeline'; import { ColumnHeaderOptions, TimelineTabs } from '../../../../../common/types/timeline'; import { defaultRowRenderers } from './renderers'; -import { mockCasesContext } from '../../../../common/mock/mock_cases_context'; jest.mock('../../../../common/lib/kibana/hooks'); jest.mock('../../../../common/hooks/use_app_toasts'); jest.mock('../../../../common/lib/kibana', () => { const originalModule = jest.requireActual('../../../../common/lib/kibana'); + const mockCasesContract = jest.requireActual('../../../../../../cases/public/mocks'); return { ...originalModule, useKibana: jest.fn().mockReturnValue({ @@ -41,9 +41,7 @@ jest.mock('../../../../common/lib/kibana', () => { siem: { crud_alerts: true, read_alerts: true }, }, }, - cases: { - getCasesContext: () => mockCasesContext, - }, + cases: mockCasesContract.mockCasesContract(), data: { search: jest.fn(), query: jest.fn(), @@ -63,10 +61,6 @@ jest.mock('../../../../common/lib/kibana', () => { onBlur: jest.fn(), onKeyDown: jest.fn(), }), - getAddToCasePopover: jest - .fn() - .mockReturnValue(
    {'Add to case'}
    ), - getAddToCaseAction: jest.fn(), }, }, }), @@ -116,7 +110,7 @@ jest.mock('../../../../common/lib/helpers/scheduler', () => ({ maxDelay: () => 3000, })); -jest.mock('../../create_field_button', () => ({ +jest.mock('../../fields_browser/create_field_button', () => ({ useCreateFieldButton: () => <>, })); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index a9d0028f6d9d..c41544c1c4b4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -229,7 +229,7 @@ export const BodyComponent = React.memo( ); const kibana = useKibana(); const casesPermissions = useGetUserCasesPermissions(); - const CasesContext = kibana.services.cases.getCasesContext(); + const CasesContext = kibana.services.cases.ui.getCasesContext(); return ( <> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx index d82e4c7d6719..d81104ff5be0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx @@ -8,12 +8,12 @@ import React from 'react'; import { mount } from 'enzyme'; import { waitFor } from '@testing-library/react'; -import { HostName } from './host_name'; import { TestProviders } from '../../../../../common/mock'; import { TimelineId, TimelineTabs } from '../../../../../../common/types'; import { StatefulEventContext } from '../../../../../../../timelines/public'; import { timelineActions } from '../../../../store/timeline'; import { activeTimeline } from '../../../../containers/active_timeline_context'; +import { UserName } from './user_name'; jest.mock('react-redux', () => { const origin = jest.requireActual('react-redux'); @@ -51,14 +51,13 @@ jest.mock('../../../../store/timeline', () => { }; }); -// TODO USER NAME -describe('HostName', () => { +describe('UserName', () => { const props = { - fieldName: 'host.name', + fieldName: 'user.name', contextId: 'test-context-id', eventId: 'test-event-id', isDraggable: false, - value: 'Mock Host', + value: 'Mock User', }; let toggleExpandedDetail: jest.SpyInstance; @@ -70,16 +69,14 @@ describe('HostName', () => { afterEach(() => { toggleExpandedDetail.mockClear(); }); - test('should render host name', () => { + test('should render user name', () => { const wrapper = mount( - + ); - expect(wrapper.find('[data-test-subj="host-details-button"]').last().text()).toEqual( - props.value - ); + expect(wrapper.find('[data-test-subj="users-link-anchor"]').last().text()).toEqual(props.value); }); test('should render DefaultDraggable if isDraggable is true', () => { @@ -89,56 +86,14 @@ describe('HostName', () => { }; const wrapper = mount( - + ); expect(wrapper.find('[data-test-subj="DefaultDraggable"]').exists()).toEqual(true); }); - test('if not enableHostDetailsFlyout, should go to hostdetails page', async () => { - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="host-details-button"]').first().simulate('click'); - await waitFor(() => { - expect(timelineActions.toggleDetailPanel).not.toHaveBeenCalled(); - expect(toggleExpandedDetail).not.toHaveBeenCalled(); - }); - }); - - test('if enableHostDetailsFlyout, should open HostDetailsSidePanel', async () => { - const context = { - enableHostDetailsFlyout: true, - enableIpDetailsFlyout: true, - timelineID: TimelineId.active, - tabType: TimelineTabs.query, - }; - const wrapper = mount( - - - - - - ); - - wrapper.find('[data-test-subj="host-details-button"]').first().simulate('click'); - await waitFor(() => { - expect(timelineActions.toggleDetailPanel).toHaveBeenCalledWith({ - panelView: 'hostDetail', - params: { - hostName: props.value, - }, - tabType: context.tabType, - timelineId: context.timelineID, - }); - }); - }); - - test('if enableHostDetailsFlyout and timelineId equals to `timeline-1`, should call toggleExpandedDetail', async () => { + test('if timelineId equals to `timeline-1`, should call toggleExpandedDetail', async () => { const context = { enableHostDetailsFlyout: true, enableIpDetailsFlyout: true, @@ -148,23 +103,23 @@ describe('HostName', () => { const wrapper = mount( - + ); - wrapper.find('[data-test-subj="host-details-button"]').first().simulate('click'); + wrapper.find('[data-test-subj="users-link-anchor"]').first().simulate('click'); await waitFor(() => { expect(toggleExpandedDetail).toHaveBeenCalledWith({ - panelView: 'hostDetail', + panelView: 'userDetail', params: { - hostName: props.value, + userName: props.value, }, }); }); }); - test('if enableHostDetailsFlyout but timelineId not equals to `TimelineId.active`, should not call toggleExpandedDetail', async () => { + test('if timelineId not equals to `TimelineId.active`, should not call toggleExpandedDetail', async () => { const context = { enableHostDetailsFlyout: true, enableIpDetailsFlyout: true, @@ -174,17 +129,17 @@ describe('HostName', () => { const wrapper = mount( - + ); - wrapper.find('[data-test-subj="host-details-button"]').first().simulate('click'); + wrapper.find('[data-test-subj="users-link-anchor"]').first().simulate('click'); await waitFor(() => { expect(timelineActions.toggleDetailPanel).toHaveBeenCalledWith({ - panelView: 'hostDetail', + panelView: 'userDetail', params: { - hostName: props.value, + userName: props.value, }, tabType: context.tabType, timelineId: context.timelineID, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.tsx index fae2652284a6..61f62ee3c0e2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.tsx @@ -83,7 +83,7 @@ const UserNameComponent: React.FC = ({ ); // The below is explicitly defined this way as the onClick takes precedence when it and the href are both defined - // When this component is used outside of timeline/alerts table (i.e. in the flyout) we would still like it to link to the Host Details page + // When this component is used outside of timeline/alerts table (i.e. in the flyout) we would still like it to link to the User Details page const content = useMemo( () => ( ({ useTimelineEvents: jest.fn(), @@ -58,7 +58,9 @@ jest.mock('../../../../common/lib/kibana', () => { getUrlForApp: jest.fn(), }, cases: { - getCasesContext: () => mockCasesContext, + ui: { + getCasesContext: () => mockCasesContext, + }, }, docLinks: { links: { query: { eql: 'url-eql_doc' } } }, uiSettings: { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx index ffe50f935b9f..ad06c6e0059a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx @@ -24,7 +24,7 @@ import { mockSourcererScope } from '../../../../common/containers/sourcerer/mock import { PinnedTabContentComponent, Props as PinnedTabContentComponentProps } from '.'; import { Direction } from '../../../../../common/search_strategy'; import { useDraggableKeyboardWrapper as mockUseDraggableKeyboardWrapper } from '../../../../../../timelines/public/components'; -import { mockCasesContext } from '../../../../common/mock/mock_cases_context'; +import { mockCasesContext } from '../../../../../../cases/public/mocks/mock_cases_context'; jest.mock('../../../containers/index', () => ({ useTimelineEvents: jest.fn(), @@ -53,7 +53,9 @@ jest.mock('../../../../common/lib/kibana', () => { getUrlForApp: jest.fn(), }, cases: { - getCasesContext: () => mockCasesContext, + ui: { + getCasesContext: () => mockCasesContext, + }, }, uiSettings: { get: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx index 019bedacbffe..227d158cb477 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx @@ -26,7 +26,7 @@ import { useSourcererDataView } from '../../../../common/containers/sourcerer'; import { mockSourcererScope } from '../../../../common/containers/sourcerer/mocks'; import { Direction } from '../../../../../common/search_strategy'; import * as helpers from '../helpers'; -import { mockCasesContext } from '../../../../common/mock/mock_cases_context'; +import { mockCasesContext } from '../../../../../../cases/public/mocks/mock_cases_context'; jest.mock('../../../containers/index', () => ({ useTimelineEvents: jest.fn(), @@ -61,7 +61,9 @@ jest.mock('../../../../common/lib/kibana', () => { getUrlForApp: jest.fn(), }, cases: { - getCasesContext: () => mockCasesContext, + ui: { + getCasesContext: () => mockCasesContext, + }, }, uiSettings: { get: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/super_select.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/super_select.tsx index 2e6b486cf3e9..6b5e60bbf16e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/super_select.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/super_select.tsx @@ -187,7 +187,6 @@ export class EuiSuperSelect extends Component extends Component { + if (hasKibanaREAD && !hasKibanaCRUD) { + chrome.setBadge({ + text: i18n.READ_ONLY_BADGE_TEXT, + tooltip, + iconType: 'glasses', + }); + } + + // remove the icon after the component unmounts + return () => { + chrome.setBadge(); + }; + }, [chrome, hasKibanaREAD, hasKibanaCRUD, tooltip]); +} diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.test.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.test.tsx new file mode 100644 index 000000000000..9864dd6b096e --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { UserRiskScoreColumns } from '.'; +import { getUserRiskScoreColumns } from './columns'; +import { TestProviders } from '../../../common/mock'; + +describe('getUserRiskScoreColumns', () => { + const defaultProps = { + dispatchSeverityUpdate: jest.fn(), + }; + + test('should have expected fields', () => { + const columns = getUserRiskScoreColumns(defaultProps); + + expect(columns[0].field).toBe('user.name'); + expect(columns[1].field).toBe('risk_stats.risk_score'); + expect(columns[2].field).toBe('risk'); + + columns.forEach((column) => { + expect(column).toHaveProperty('name'); + expect(column).toHaveProperty('render'); + expect(column).toHaveProperty('sortable'); + }); + }); + + test('should render user detail link', () => { + const username = 'test_user_name'; + const columns: UserRiskScoreColumns = getUserRiskScoreColumns(defaultProps); + const usernameColumn = columns[0]; + const renderedColumn = usernameColumn.render!(username, null); + + const { queryByTestId } = render({renderedColumn}); + + expect(queryByTestId('users-link-anchor')).toHaveTextContent(username); + }); + + test('should render user score truncated', () => { + const columns: UserRiskScoreColumns = getUserRiskScoreColumns(defaultProps); + + const riskScore = 10.11111111; + const riskScoreColumn = columns[1]; + const renderedColumn = riskScoreColumn.render!(riskScore, null); + + const { queryByTestId } = render({renderedColumn}); + + expect(queryByTestId('risk-score-truncate')).toHaveTextContent('10.11'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.tsx new file mode 100644 index 000000000000..c3b26aa1e44d --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIcon, EuiLink, EuiText, EuiToolTip } from '@elastic/eui'; +import { + DragEffects, + DraggableWrapper, +} from '../../../common/components/drag_and_drop/draggable_wrapper'; +import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers'; +import { getEmptyTagValue } from '../../../common/components/empty_value'; + +import { IS_OPERATOR } from '../../../timelines/components/timeline/data_providers/data_provider'; +import { Provider } from '../../../timelines/components/timeline/data_providers/provider'; +import { UserRiskScoreColumns } from '.'; + +import * as i18n from './translations'; +import { RiskScore } from '../../../common/components/severity/common'; +import { RiskSeverity } from '../../../../common/search_strategy'; +import { UserDetailsLink } from '../../../common/components/links'; + +export const getUserRiskScoreColumns = ({ + dispatchSeverityUpdate, +}: { + dispatchSeverityUpdate: (s: RiskSeverity) => void; +}): UserRiskScoreColumns => [ + { + field: 'user.name', + name: i18n.USER_NAME, + truncateText: false, + mobileOptions: { show: true }, + sortable: true, + render: (userName) => { + if (userName != null && userName.length > 0) { + const id = escapeDataProviderId(`user-risk-score-table-userName-${userName}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: 'risk_stats.risk_score', + name: i18n.USER_RISK_SCORE, + truncateText: true, + mobileOptions: { show: true }, + sortable: true, + render: (riskScore) => { + if (riskScore != null) { + return ( + + {riskScore.toFixed(2)} + + ); + } + return getEmptyTagValue(); + }, + }, + { + field: 'risk', + name: ( + + <> + {i18n.USER_RISK} + + + ), + truncateText: false, + mobileOptions: { show: true }, + sortable: true, + render: (risk) => { + if (risk != null) { + return ( + dispatchSeverityUpdate(risk)}> + {i18n.VIEW_USERS_BY_SEVERITY(risk.toLowerCase())} + + } + severity={risk} + /> + ); + } + return getEmptyTagValue(); + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx new file mode 100644 index 000000000000..3faa96b436de --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 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 { render } from '@testing-library/react'; +import { noop } from 'lodash'; +import React from 'react'; +import { UserRiskScoreTable } from '.'; +import { TestProviders } from '../../../common/mock'; +import { UsersType } from '../../store/model'; + +describe('UserRiskScoreTable', () => { + const username = 'test_user_name'; + const defautProps = { + data: [ + { + '@timestamp': '1641902481', + risk: 'High', + risk_stats: { + rule_risks: [], + risk_score: 71, + }, + user: { + name: username, + }, + }, + ], + id: 'test_id', + isInspect: false, + loading: false, + loadPage: noop, + severityCount: { + Unknown: 0, + Low: 0, + Moderate: 0, + High: 0, + Critical: 0, + }, + totalCount: 0, + type: UsersType.page, + }; + + it('renders', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('users-link-anchor')).toHaveTextContent(username); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx new file mode 100644 index 000000000000..9f782b7f2866 --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx @@ -0,0 +1,228 @@ +/* + * Copyright 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, { useMemo, useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; +import { + Columns, + Criteria, + ItemsPerRow, + PaginatedTable, +} from '../../../common/components/paginated_table'; + +import { getUserRiskScoreColumns } from './columns'; + +import * as i18nUsers from '../../pages/translations'; +import * as i18n from './translations'; +import { usersModel, usersSelectors } from '../../store'; +import { + UserRiskScoreFields, + UserRiskScoreItem, +} from '../../../../common/search_strategy/security_solution/users/common'; +import { SeverityCount } from '../../../common/components/severity/types'; +import { SeverityBadges } from '../../../common/components/severity/severity_badges'; +import { SeverityBar } from '../../../common/components/severity/severity_bar'; +import { SeverityFilterGroup } from '../../../common/components/severity/severity_filter_group'; +import { usersActions } from '../../../users/store'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { State } from '../../../common/store'; +import { + RiskScoreSortField, + RiskSeverity, + UsersRiskScore, +} from '../../../../common/search_strategy'; + +export const rowItems: ItemsPerRow[] = [ + { + text: i18n.ROWS_5, + numberOfRow: 5, + }, + { + text: i18n.ROWS_10, + numberOfRow: 10, + }, +]; + +const tableType = usersModel.UsersTableType.risk; + +interface UserRiskScoreTableProps { + data: UsersRiskScore[]; + id: string; + isInspect: boolean; + loading: boolean; + loadPage: (newActivePage: number) => void; + severityCount: SeverityCount; + totalCount: number; + type: usersModel.UsersType; +} + +export type UserRiskScoreColumns = [ + Columns, + Columns, + Columns +]; + +const UserRiskScoreTableComponent: React.FC = ({ + data, + id, + isInspect, + loading, + loadPage, + severityCount, + totalCount, + type, +}) => { + const dispatch = useDispatch(); + + const getUserRiskScoreSelector = useMemo(() => usersSelectors.userRiskScoreSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state: State) => + getUserRiskScoreSelector(state) + ); + const updateLimitPagination = useCallback( + (newLimit) => { + dispatch( + usersActions.updateTableLimit({ + usersType: type, + limit: newLimit, + tableType, + }) + ); + }, + [type, dispatch] + ); + + const updateActivePage = useCallback( + (newPage) => { + dispatch( + usersActions.updateTableActivePage({ + activePage: newPage, + usersType: type, + tableType, + }) + ); + }, + [type, dispatch] + ); + + const onSort = useCallback( + (criteria: Criteria) => { + if (criteria.sort != null) { + const newSort = criteria.sort; + if (newSort.direction !== sort.direction || newSort.field !== sort.field) { + dispatch( + usersActions.updateTableSorting({ + sort: newSort as RiskScoreSortField, + }) + ); + } + } + }, + [dispatch, sort] + ); + const dispatchSeverityUpdate = useCallback( + (s: RiskSeverity) => { + dispatch( + usersActions.updateUserRiskScoreSeverityFilter({ + severitySelection: [s], + }) + ); + }, + [dispatch] + ); + const columns = useMemo( + () => getUserRiskScoreColumns({ dispatchSeverityUpdate }), + [dispatchSeverityUpdate] + ); + + const risk = ( + + + + + + + + + ); + + const headerTitle = ( + + {i18nUsers.NAVIGATION_RISK_TITLE} + + + + + ); + + const getUserRiskScoreFilterQuerySelector = useMemo( + () => usersSelectors.usersRiskScoreSeverityFilterSelector(), + [] + ); + const severitySelectionRedux = useDeepEqualSelector((state: State) => + getUserRiskScoreFilterQuerySelector(state) + ); + + const onSelect = useCallback( + (newSelection: RiskSeverity[]) => { + dispatch( + usersActions.updateUserRiskScoreSeverityFilter({ + severitySelection: newSelection, + }) + ); + }, + [dispatch] + ); + + return ( + + } + headerSupplement={risk} + headerTitle={headerTitle} + headerUnit={i18n.UNIT(totalCount)} + id={id} + isInspect={isInspect} + itemsPerRow={rowItems} + limit={limit} + loading={loading} + loadPage={loadPage} + onChange={onSort} + pageOfItems={data} + showMorePagesIndicator={false} + sorting={sort} + split={true} + stackHeader={true} + totalCount={totalCount} + updateLimitPagination={updateLimitPagination} + updateActivePage={updateActivePage} + /> + ); +}; + +UserRiskScoreTableComponent.displayName = 'UserRiskScoreTableComponent'; + +export const UserRiskScoreTable = React.memo(UserRiskScoreTableComponent); + +UserRiskScoreTable.displayName = 'UserRiskScoreTable'; diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/translations.ts b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/translations.ts new file mode 100644 index 000000000000..c33d45c37c28 --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/translations.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +export const USER_NAME = i18n.translate('xpack.securitySolution.usersRiskTable.userNameTitle', { + defaultMessage: 'User Name', +}); + +export const USER_RISK_SCORE = i18n.translate( + 'xpack.securitySolution.usersRiskTable.userRiskScoreTitle', + { + defaultMessage: 'User risk score', + } +); + +export const USER_RISK_TOOLTIP = i18n.translate( + 'xpack.securitySolution.usersRiskTable.userRiskToolTip', + { + defaultMessage: + 'User risk classification is determined by user risk score. Users classified as Critical or High are indicated as risky.', + } +); + +export const USER_RISK = i18n.translate('xpack.securitySolution.usersRiskTable.riskTitle', { + defaultMessage: 'User risk classification', +}); + +export const VIEW_USERS_BY_SEVERITY = (severity: string) => + i18n.translate('xpack.securitySolution.usersRiskTable.filteredUsersTitle', { + values: { severity }, + defaultMessage: 'View {severity} risk users', + }); + +export const UNIT = (totalCount: number) => + i18n.translate('xpack.securitySolution.usersTable.unit', { + values: { totalCount }, + defaultMessage: `{totalCount, plural, =1 {user} other {users}}`, + }); + +export const ROWS_5 = i18n.translate('xpack.securitySolution.usersTable.rows', { + values: { numRows: 5 }, + defaultMessage: '{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}', +}); + +export const ROWS_10 = i18n.translate('xpack.securitySolution.usersTable.rows', { + values: { numRows: 10 }, + defaultMessage: '{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}', +}); + +export const USER_RISK_TABLE_TOOLTIP = i18n.translate( + 'xpack.securitySolution.hostsRiskTable.usersTableTitle', + { + defaultMessage: + 'The user risk table is not affected by the KQL time range. This table shows the latest recorded risk score for each user.', + } +); diff --git a/x-pack/plugins/security_solution/public/users/containers/users/details/index.tsx b/x-pack/plugins/security_solution/public/users/containers/users/details/index.tsx new file mode 100644 index 000000000000..d8687524215d --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/containers/users/details/index.tsx @@ -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. + */ + +import deepEqual from 'fast-deep-equal'; +import { noop } from 'lodash/fp'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Subscription } from 'rxjs'; + +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { inputsModel } from '../../../../common/store'; +import { useKibana } from '../../../../common/lib/kibana'; + +import * as i18n from './translations'; +import { + isCompleteResponse, + isErrorResponse, +} from '../../../../../../../../src/plugins/data/common'; +import { getInspectResponse } from '../../../../helpers'; +import { InspectResponse } from '../../../../types'; +import { + UserDetailsRequestOptions, + UserDetailsStrategyResponse, +} from '../../../../../common/search_strategy/security_solution/users/details'; +import { UsersQueries } from '../../../../../common/search_strategy/security_solution/users'; +import { UserItem } from '../../../../../common/search_strategy/security_solution/users/common'; + +export const ID = 'usersDetailsQuery'; + +export interface UserDetailsArgs { + id: string; + inspect: InspectResponse; + userDetails: UserItem; + refetch: inputsModel.Refetch; + startDate: string; + endDate: string; +} + +interface UseUserDetails { + endDate: string; + userName: string; + id?: string; + indexNames: string[]; + skip?: boolean; + startDate: string; +} + +export const useUserDetails = ({ + endDate, + userName, + indexNames, + id = ID, + skip = false, + startDate, +}: UseUserDetails): [boolean, UserDetailsArgs] => { + const { data } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const searchSubscription$ = useRef(new Subscription()); + const [loading, setLoading] = useState(false); + const [userDetailsRequest, setUserDetailsRequest] = useState( + null + ); + const { addError, addWarning } = useAppToasts(); + + const [userDetailsResponse, setUserDetailsResponse] = useState({ + endDate, + userDetails: {}, + id, + inspect: { + dsl: [], + response: [], + }, + refetch: refetch.current, + startDate, + }); + + const userDetailsSearch = useCallback( + (request: UserDetailsRequestOptions | null) => { + if (request == null || skip) { + return; + } + + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + searchSubscription$.current = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + setLoading(false); + setUserDetailsResponse((prevResponse) => ({ + ...prevResponse, + userDetails: response.userDetails, + inspect: getInspectResponse(response, prevResponse.inspect), + refetch: refetch.current, + })); + searchSubscription$.current.unsubscribe(); + } else if (isErrorResponse(response)) { + setLoading(false); + addWarning(i18n.ERROR_USER_DETAILS); + searchSubscription$.current.unsubscribe(); + } + }, + error: (msg) => { + setLoading(false); + addError(msg, { + title: i18n.FAIL_USER_DETAILS, + }); + searchSubscription$.current.unsubscribe(); + }, + }); + }; + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + }, + [data.search, addError, addWarning, skip] + ); + + useEffect(() => { + setUserDetailsRequest((prevRequest) => { + const myRequest = { + ...(prevRequest ?? {}), + defaultIndex: indexNames, + factoryQueryType: UsersQueries.details, + userName, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [endDate, userName, indexNames, startDate]); + + useEffect(() => { + userDetailsSearch(userDetailsRequest); + return () => { + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + }; + }, [userDetailsRequest, userDetailsSearch]); + + return [loading, userDetailsResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/users/containers/users/details/translations.ts b/x-pack/plugins/security_solution/public/users/containers/users/details/translations.ts new file mode 100644 index 000000000000..abf96e1b52c7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/containers/users/details/translations.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 { i18n } from '@kbn/i18n'; + +export const ERROR_USER_DETAILS = i18n.translate( + 'xpack.securitySolution.userDetails.errorSearchDescription', + { + defaultMessage: `An error has occurred on user details search`, + } +); + +export const FAIL_USER_DETAILS = i18n.translate( + 'xpack.securitySolution.userDetails.failSearchDescription', + { + defaultMessage: `Failed to run search on user details`, + } +); diff --git a/x-pack/plugins/security_solution/public/users/pages/constants.ts b/x-pack/plugins/security_solution/public/users/pages/constants.ts index 48c293765772..95c0e361e82d 100644 --- a/x-pack/plugins/security_solution/public/users/pages/constants.ts +++ b/x-pack/plugins/security_solution/public/users/pages/constants.ts @@ -10,6 +10,6 @@ import { UsersTableType } from '../store/model'; export const usersDetailsPagePath = `${USERS_PATH}/:detailName`; -export const usersTabPath = `${USERS_PATH}/:tabName(${UsersTableType.allUsers}|${UsersTableType.anomalies})`; +export const usersTabPath = `${USERS_PATH}/:tabName(${UsersTableType.allUsers}|${UsersTableType.anomalies}|${UsersTableType.risk})`; -export const usersDetailsTabPath = `${usersDetailsPagePath}/:tabName(${UsersTableType.allUsers})`; +export const usersDetailsTabPath = `${usersDetailsPagePath}/:tabName(${UsersTableType.anomalies})`; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx index dcab2e6dcdac..966fe067fde8 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx @@ -5,35 +5,76 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { Route, Switch } from 'react-router-dom'; import { UsersTableType } from '../../store/model'; -import { useGlobalTime } from '../../../common/containers/use_global_time'; - +import { AnomaliesUserTable } from '../../../common/components/ml/tables/anomalies_user_table'; import { UsersDetailsTabsProps } from './types'; -import { type } from './utils'; - -import { AllUsersQueryTabBody } from '../navigation'; +import { AnomaliesQueryTabBody } from '../../../common/containers/anomalies/anomalies_query_tab_body'; +import { scoreIntervalToDateTime } from '../../../common/components/ml/score/score_interval_to_datetime'; +import { UpdateDateRange } from '../../../common/components/charts/common'; +import { Anomaly } from '../../../common/components/ml/types'; +import { usersDetailsPagePath } from '../constants'; export const UsersDetailsTabs = React.memo( - ({ docValueFields, filterQuery, indexNames, usersDetailsPagePath }) => { - const { from, to, isInitializing, deleteQuery, setQuery } = useGlobalTime(); + ({ + deleteQuery, + filterQuery, + from, + indexNames, + isInitializing, + setQuery, + to, + type, + setAbsoluteRangeDatePicker, + detailName, + }) => { + const narrowDateRange = useCallback( + (score: Anomaly, interval: string) => { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }, + [setAbsoluteRangeDatePicker] + ); + + const updateDateRange = useCallback( + ({ x }) => { + if (!x) { + return; + } + const [min, max] = x; + setAbsoluteRangeDatePicker({ + id: 'global', + from: new Date(min).toISOString(), + to: new Date(max).toISOString(), + }); + }, + [setAbsoluteRangeDatePicker] + ); + + const tabProps = { + deleteQuery, + endDate: to, + filterQuery, + indexNames, + skip: isInitializing || filterQuery === undefined, + setQuery, + startDate: from, + type, + narrowDateRange, + updateDateRange, + userName: detailName, + }; return ( - - + + ); diff --git a/x-pack/plugins/security_solution/public/users/pages/details/index.tsx b/x-pack/plugins/security_solution/public/users/pages/details/index.tsx index 9616f1913468..e68c37d6b404 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/details/index.tsx @@ -42,7 +42,15 @@ import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/h import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; import { LastEventTime } from '../../../common/components/last_event_time'; import { LastEventIndexKey } from '../../../../common/search_strategy'; -const ID = 'UsersDetailsQueryId'; + +import { AnomalyTableProvider } from '../../../common/components/ml/anomaly/anomaly_table_provider'; +import { UserOverview } from '../../../overview/components/user_overview'; +import { useUserDetails } from '../../containers/users/details'; +import { useQueryInspector } from '../../../common/components/page/manage_query'; +import { scoreIntervalToDateTime } from '../../../common/components/ml/score/score_interval_to_datetime'; +import { getCriteriaFromUsersType } from '../../../common/components/ml/criteria/get_criteria_from_users_type'; +import { UsersType } from '../../store/model'; +const QUERY_ID = 'UsersDetailsQueryId'; const UsersDetailsComponent: React.FC = ({ detailName, @@ -80,12 +88,30 @@ const UsersDetailsComponent: React.FC = ({ filters: getFilters(), }); - useInvalidFilterQuery({ id: ID, filterQuery, kqlError, query, startDate: from, endDate: to }); + useInvalidFilterQuery({ + id: QUERY_ID, + filterQuery, + kqlError, + query, + startDate: from, + endDate: to, + }); useEffect(() => { dispatch(setUsersDetailsTablesActivePageToZero()); }, [dispatch, detailName]); + const [loading, { inspect, userDetails, refetch }] = useUserDetails({ + id: QUERY_ID, + endDate: to, + startDate: from, + userName: detailName, + indexNames: selectedPatterns, + skip: selectedPatterns.length === 0, + }); + + useQueryInspector({ setQuery, deleteQuery, refetch, inspect, loading, queryId: QUERY_ID }); + return ( <> {indicesExist ? ( @@ -108,11 +134,41 @@ const UsersDetailsComponent: React.FC = ({ } title={detailName} /> + + {({ isLoadingAnomaliesData, anomaliesData }) => ( + { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }} + /> + )} + + + - `${USERS_PATH}/${hostName}/${tabName}`; +const getTabsOnUsersDetailsUrl = (userName: string, tabName: UsersTableType) => + `${USERS_PATH}/${userName}/${tabName}`; -export const navTabsUsersDetails = (hostName: string): UsersDetailsNavTab => { +export const navTabsUsersDetails = (userName: string): UsersDetailsNavTab => { return { - [UsersTableType.allUsers]: { - id: UsersTableType.allUsers, - name: i18n.NAVIGATION_ALL_USERS_TITLE, - href: getTabsOnUsersDetailsUrl(hostName, UsersTableType.allUsers), + [UsersTableType.anomalies]: { + id: UsersTableType.anomalies, + name: i18n.NAVIGATION_ANOMALIES_TITLE, + href: getTabsOnUsersDetailsUrl(userName, UsersTableType.anomalies), disabled: false, }, }; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/types.ts b/x-pack/plugins/security_solution/public/users/pages/details/types.ts index 7075c627351d..69974678bf4d 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/types.ts +++ b/x-pack/plugins/security_solution/public/users/pages/details/types.ts @@ -44,7 +44,7 @@ export type UsersDetailsComponentProps = UsersDetailsComponentReduxProps & UsersDetailsComponentDispatchProps & UsersQueryProps; -type KeyUsersDetailsNavTab = UsersTableType.allUsers; +type KeyUsersDetailsNavTab = UsersTableType.anomalies; export type UsersDetailsNavTab = Record; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/utils.ts b/x-pack/plugins/security_solution/public/users/pages/details/utils.ts index f490e3e46914..eb2820c6d486 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/utils.ts +++ b/x-pack/plugins/security_solution/public/users/pages/details/utils.ts @@ -23,6 +23,7 @@ export const type = usersModel.UsersType.details; const TabNameMappedToI18nKey: Record = { [UsersTableType.allUsers]: i18n.NAVIGATION_ALL_USERS_TITLE, [UsersTableType.anomalies]: i18n.NAVIGATION_ANOMALIES_TITLE, + [UsersTableType.risk]: i18n.NAVIGATION_RISK_TITLE, }; export const getBreadcrumbs = ( diff --git a/x-pack/plugins/security_solution/public/users/pages/index.tsx b/x-pack/plugins/security_solution/public/users/pages/index.tsx index 7f85791e99fb..0b6b103b7817 100644 --- a/x-pack/plugins/security_solution/public/users/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/index.tsx @@ -37,7 +37,7 @@ export const UsersContainer = React.memo(() => ( }) => ( diff --git a/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx index beffcb879cea..f991316983f4 100644 --- a/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx @@ -25,4 +25,10 @@ export const navTabsUsers: UsersNavTab = { href: getTabsOnUsersUrl(UsersTableType.anomalies), disabled: false, }, + [UsersTableType.risk]: { + id: UsersTableType.risk, + name: i18n.NAVIGATION_RISK_TITLE, + href: getTabsOnUsersUrl(UsersTableType.risk), + disabled: false, + }, }; diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx index dfc245a48f7f..6c494c9752c4 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx @@ -8,7 +8,7 @@ import { getOr } from 'lodash/fp'; import React from 'react'; import { useAuthentications } from '../../../hosts/containers/authentications'; -import { AllUsersQueryProps } from './types'; +import { UsersComponentsQueryProps } from './types'; import { AuthenticationTable } from '../../../hosts/components/authentications_table'; import { manageQuery } from '../../../common/components/page/manage_query'; @@ -25,7 +25,7 @@ export const AllUsersQueryTabBody = ({ type, docValueFields, deleteQuery, -}: AllUsersQueryProps) => { +}: UsersComponentsQueryProps) => { const [ loading, { authentications, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts b/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts index 53f7c7487108..1e4c28f38450 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts @@ -10,7 +10,7 @@ import { ESTermQuery } from '../../../../common/typed_json'; import { DocValueFields } from '../../../../../timelines/common'; import { NavTab } from '../../../common/components/navigation/types'; -type KeyUsersNavTab = UsersTableType.allUsers | UsersTableType.anomalies; +type KeyUsersNavTab = UsersTableType.allUsers | UsersTableType.anomalies | UsersTableType.risk; export type UsersNavTab = Record; export interface QueryTabBodyProps { @@ -20,7 +20,7 @@ export interface QueryTabBodyProps { filterQuery?: string | ESTermQuery; } -export type AllUsersQueryProps = QueryTabBodyProps & { +export type UsersComponentsQueryProps = QueryTabBodyProps & { deleteQuery?: GlobalTimeArgs['deleteQuery']; docValueFields?: DocValueFields[]; indexNames: string[]; diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx new file mode 100644 index 000000000000..a19e7803cb90 --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { noop } from 'lodash/fp'; + +import { UsersComponentsQueryProps } from './types'; +import { manageQuery } from '../../../common/components/page/manage_query'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { State } from '../../../common/store'; + +import { UserRiskScoreTable } from '../../components/user_risk_score_table'; +import { usersSelectors } from '../../store'; +import { + UserRiskScoreQueryId, + useUserRiskScore, + useUserRiskScoreKpi, +} from '../../../risk_score/containers'; + +const UserRiskScoreTableManage = manageQuery(UserRiskScoreTable); + +export const UserRiskScoreQueryTabBody = ({ + filterQuery, + skip, + setQuery, + type, + deleteQuery, +}: UsersComponentsQueryProps) => { + const getUserRiskScoreSelector = useMemo(() => usersSelectors.userRiskScoreSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state: State) => + getUserRiskScoreSelector(state) + ); + + const pagination = useMemo( + () => ({ + cursorStart: activePage * limit, + querySize: limit, + }), + [activePage, limit] + ); + + const [loading, { data, totalCount, inspect, isInspected, refetch }] = useUserRiskScore({ + filterQuery, + skip, + pagination, + sort, + }); + + const { severityCount, loading: isKpiLoading } = useUserRiskScoreKpi({ + filterQuery, + }); + + return ( + + ); +}; + +UserRiskScoreQueryTabBody.displayName = 'UserRiskScoreQueryTabBody'; diff --git a/x-pack/plugins/security_solution/public/users/pages/translations.ts b/x-pack/plugins/security_solution/public/users/pages/translations.ts index 4bcfc01e4170..7744ef125ffa 100644 --- a/x-pack/plugins/security_solution/public/users/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/users/pages/translations.ts @@ -24,3 +24,10 @@ export const NAVIGATION_ANOMALIES_TITLE = i18n.translate( defaultMessage: 'Anomalies', } ); + +export const NAVIGATION_RISK_TITLE = i18n.translate( + 'xpack.securitySolution.users.navigation.riskTitle', + { + defaultMessage: 'Users by risk', + } +); diff --git a/x-pack/plugins/security_solution/public/users/pages/users.tsx b/x-pack/plugins/security_solution/public/users/pages/users.tsx index 2f09f59f76f9..91cdb5cc1e43 100644 --- a/x-pack/plugins/security_solution/public/users/pages/users.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/users.tsx @@ -10,8 +10,8 @@ import styled from 'styled-components'; import { noop } from 'lodash/fp'; import React, { useCallback, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; +import { useParams } from 'react-router-dom'; import { isTab } from '../../../../timelines/public'; - import { SecurityPageName } from '../../app/types'; import { FiltersGlobal } from '../../common/components/filters_global'; import { HeaderPage } from '../../common/components/header_page'; @@ -24,7 +24,7 @@ import { useGlobalFullScreen } from '../../common/containers/use_full_screen'; import { useGlobalTime } from '../../common/containers/use_global_time'; import { useKibana } from '../../common/lib/kibana'; import { convertToBuildEsQuery } from '../../common/lib/keury'; -import { inputsSelectors } from '../../common/store'; +import { inputsSelectors, State } from '../../common/store'; import { setAbsoluteRangeDatePicker } from '../../common/store/inputs/actions'; import { SpyRoute } from '../../common/utils/route/spy_routes'; @@ -33,7 +33,7 @@ import { OverviewEmpty } from '../../overview/components/overview_empty'; import { UsersTabs } from './users_tabs'; import { navTabsUsers } from './nav_tabs'; import * as i18n from './translations'; -import { usersModel } from '../store'; +import { usersModel, usersSelectors } from '../store'; import { onTimelineTabKeyPressed, resetKeyboardFocus, @@ -44,6 +44,8 @@ import { useInvalidFilterQuery } from '../../common/hooks/use_invalid_filter_que import { UsersKpiComponent } from '../components/kpi_users'; import { UpdateDateRange } from '../../common/components/charts/common'; import { LastEventIndexKey } from '../../../common/search_strategy'; +import { generateSeverityFilter } from '../../hosts/store/helpers'; +import { UsersTableType } from '../store/model'; const ID = 'UsersQueryId'; @@ -68,10 +70,27 @@ const UsersComponent = () => { const query = useDeepEqualSelector(getGlobalQuerySelector); const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + const getUsersRiskScoreFilterQuerySelector = useMemo( + () => usersSelectors.usersRiskScoreSeverityFilterSelector(), + [] + ); + const severitySelection = useDeepEqualSelector((state: State) => + getUsersRiskScoreFilterQuerySelector(state) + ); + const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); const { globalFullScreen } = useGlobalFullScreen(); const { uiSettings } = useKibana().services; - const tabsFilters = filters; + + const { tabName } = useParams<{ tabName: string }>(); + const tabsFilters = React.useMemo(() => { + if (tabName === UsersTableType.risk) { + const severityFilter = generateSeverityFilter(severitySelection); + + return [...severityFilter, ...filters]; + } + return filters; + }, [severitySelection, tabName, filters]); const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererDataView(); const [filterQuery, kqlError] = useMemo( diff --git a/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx index 2db83c5d75ae..50de49d1e4af 100644 --- a/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx @@ -18,6 +18,8 @@ import { Anomaly } from '../../common/components/ml/types'; import { scoreIntervalToDateTime } from '../../common/components/ml/score/score_interval_to_datetime'; import { UpdateDateRange } from '../../common/components/charts/common'; +import { UserRiskScoreQueryTabBody } from './navigation/user_risk_score_tab_body'; + export const UsersTabs = memo( ({ deleteQuery, @@ -78,6 +80,9 @@ export const UsersTabs = memo( + + + ); } diff --git a/x-pack/plugins/security_solution/public/users/store/actions.ts b/x-pack/plugins/security_solution/public/users/store/actions.ts index ef182b37d0b1..262604f68bdf 100644 --- a/x-pack/plugins/security_solution/public/users/store/actions.ts +++ b/x-pack/plugins/security_solution/public/users/store/actions.ts @@ -7,15 +7,10 @@ import actionCreatorFactory from 'typescript-fsa'; import { usersModel } from '.'; +import { RiskScoreSortField, RiskSeverity } from '../../../common/search_strategy'; const actionCreator = actionCreatorFactory('x-pack/security_solution/local/users'); -export const updateUsersTable = actionCreator<{ - usersType: usersModel.UsersType; - tableType: usersModel.UsersTableType | usersModel.UsersTableType; - updates: usersModel.TableUpdates; -}>('UPDATE_NETWORK_TABLE'); - export const setUsersTablesActivePageToZero = actionCreator('SET_USERS_TABLES_ACTIVE_PAGE_TO_ZERO'); export const setUsersDetailsTablesActivePageToZero = actionCreator( @@ -33,3 +28,11 @@ export const updateTableActivePage = actionCreator<{ activePage: number; tableType: usersModel.UsersTableType; }>('UPDATE_USERS_ACTIVE_PAGE'); + +export const updateTableSorting = actionCreator<{ + sort: RiskScoreSortField; +}>('UPDATE_USERS_SORTING'); + +export const updateUserRiskScoreSeverityFilter = actionCreator<{ + severitySelection: RiskSeverity[]; +}>('UPDATE_USERS_RISK_SEVERITY_FILTER'); diff --git a/x-pack/plugins/security_solution/public/users/store/model.ts b/x-pack/plugins/security_solution/public/users/store/model.ts index 57d9c4b6c62f..22630d34d48a 100644 --- a/x-pack/plugins/security_solution/public/users/store/model.ts +++ b/x-pack/plugins/security_solution/public/users/store/model.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { RiskScoreSortField, RiskSeverity } from '../../../common/search_strategy'; + export enum UsersType { page = 'page', details = 'details', @@ -13,6 +15,7 @@ export enum UsersType { export enum UsersTableType { allUsers = 'allUsers', anomalies = 'anomalies', + risk = 'userRisk', } export type AllUsersTables = UsersTableType; @@ -24,22 +27,29 @@ export interface BasicQueryPaginated { export type AllUsersQuery = BasicQueryPaginated; -export interface TableUpdates { - activePage?: number; - limit?: number; - isPtrIncluded?: boolean; - // sort?: SortField; +export interface UsersRiskScoreQuery extends BasicQueryPaginated { + sort: RiskScoreSortField; // TODO fix it when be is implemented + severitySelection: RiskSeverity[]; } export interface UsersQueries { [UsersTableType.allUsers]: AllUsersQuery; [UsersTableType.anomalies]: null | undefined; + [UsersTableType.risk]: UsersRiskScoreQuery; +} + +export interface UserDetailsQueries { + [UsersTableType.anomalies]: null | undefined; } export interface UsersPageModel { queries: UsersQueries; } +export interface UserDetailsPageModel { + queries: UserDetailsQueries; +} + export interface UsersDetailsQueries { [UsersTableType.allUsers]: AllUsersQuery; } @@ -50,5 +60,5 @@ export interface UsersDetailsModel { export interface UsersModel { [UsersType.page]: UsersPageModel; - [UsersType.details]: UsersPageModel; + [UsersType.details]: UserDetailsPageModel; } diff --git a/x-pack/plugins/security_solution/public/users/store/reducer.ts b/x-pack/plugins/security_solution/public/users/store/reducer.ts index 3f4cd69d7f9e..26b2e8a225d5 100644 --- a/x-pack/plugins/security_solution/public/users/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/users/store/reducer.ts @@ -6,18 +6,19 @@ */ import { reducerWithInitialState } from 'typescript-fsa-reducers'; -import { get } from 'lodash/fp'; import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from '../../common/store/constants'; import { setUsersTablesActivePageToZero, - updateUsersTable, updateTableActivePage, updateTableLimit, + updateTableSorting, + updateUserRiskScoreSeverityFilter, } from './actions'; import { setUsersPageQueriesActivePageToZero } from './helpers'; import { UsersTableType, UsersModel } from './model'; -import { HostsTableType } from '../../hosts/store/model'; +import { Direction } from '../../../common/search_strategy/common'; +import { RiskScoreFields } from '../../../common/search_strategy'; export const initialUsersState: UsersModel = { page: { @@ -26,63 +27,82 @@ export const initialUsersState: UsersModel = { activePage: DEFAULT_TABLE_ACTIVE_PAGE, limit: DEFAULT_TABLE_LIMIT, }, - [HostsTableType.anomalies]: null, + [UsersTableType.risk]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + sort: { + field: RiskScoreFields.riskScore, + direction: Direction.desc, + }, + severitySelection: [], + }, + [UsersTableType.anomalies]: null, }, }, details: { queries: { - [UsersTableType.allUsers]: { - activePage: DEFAULT_TABLE_ACTIVE_PAGE, - limit: DEFAULT_TABLE_LIMIT, - }, - [HostsTableType.anomalies]: null, + [UsersTableType.anomalies]: null, }, }, }; export const usersReducer = reducerWithInitialState(initialUsersState) - .case(updateUsersTable, (state, { usersType, tableType, updates }) => ({ + .case(setUsersTablesActivePageToZero, (state) => ({ ...state, - [usersType]: { - ...state[usersType], + page: { + ...state.page, + queries: setUsersPageQueriesActivePageToZero(state), + }, + })) + .case(updateTableActivePage, (state, { activePage, tableType }) => ({ + ...state, + page: { + ...state.page, queries: { - ...state[usersType].queries, + ...state.page.queries, [tableType]: { - ...get([usersType, 'queries', tableType], state), - ...updates, + ...state.page.queries[tableType], + activePage, }, }, }, })) - .case(setUsersTablesActivePageToZero, (state) => ({ + .case(updateTableLimit, (state, { limit, tableType }) => ({ ...state, page: { ...state.page, - queries: setUsersPageQueriesActivePageToZero(state), + queries: { + ...state.page.queries, + [tableType]: { + ...state.page.queries[tableType], + limit, + }, + }, }, })) - .case(updateTableActivePage, (state, { activePage, usersType, tableType }) => ({ + .case(updateTableSorting, (state, { sort }) => ({ ...state, - [usersType]: { - ...state[usersType], + page: { + ...state.page, queries: { - ...state[usersType].queries, - [tableType]: { - ...state[usersType].queries[tableType], - activePage, + ...state.page.queries, + [UsersTableType.risk]: { + ...state.page.queries[UsersTableType.risk], + sort, }, }, }, })) - .case(updateTableLimit, (state, { limit, usersType, tableType }) => ({ + .case(updateUserRiskScoreSeverityFilter, (state, { severitySelection }) => ({ ...state, - [usersType]: { - ...state[usersType], + page: { + ...state.page, queries: { - ...state[usersType].queries, - [tableType]: { - ...state[usersType].queries[tableType], - limit, + ...state.page.queries, + [UsersTableType.risk]: { + ...state.page.queries[UsersTableType.risk], + severitySelection, + activePage: DEFAULT_TABLE_ACTIVE_PAGE, }, }, }, diff --git a/x-pack/plugins/security_solution/public/users/store/selectors.ts b/x-pack/plugins/security_solution/public/users/store/selectors.ts index 8a706ac06156..bdeacef2bf77 100644 --- a/x-pack/plugins/security_solution/public/users/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/users/store/selectors.ts @@ -15,3 +15,9 @@ const selectUserPage = (state: State): UsersPageModel => state.users.page; export const allUsersSelector = () => createSelector(selectUserPage, (users) => users.queries[UsersTableType.allUsers]); + +export const userRiskScoreSelector = () => + createSelector(selectUserPage, (users) => users.queries[UsersTableType.risk]); + +export const usersRiskScoreSeverityFilterSelector = () => + createSelector(selectUserPage, (users) => users.queries[UsersTableType.risk].severitySelection); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/blocklists/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/blocklists/index.ts index 81a18bb89c35..3ed2ea6e7f1b 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/blocklists/index.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/blocklists/index.ts @@ -80,7 +80,7 @@ const createBlocklists: RunFn = async ({ flags, log }) => { const body = eventGenerator.generateBlocklistForCreate(); if (isArtifactByPolicy(body)) { - const nmExceptions = Math.floor(Math.random() * 3) || 1; + const nmExceptions = eventGenerator.randomN(3) || 1; body.tags = Array.from({ length: nmExceptions }, () => { return `policy:${randomPolicyId()}`; }); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts index 7f18c0b40fed..05baa6d4ade0 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts @@ -9,7 +9,11 @@ import { run, RunFn, createFailError } from '@kbn/dev-utils'; import { KbnClient } from '@kbn/test'; import { AxiosError } from 'axios'; import pMap from 'p-map'; -import type { CreateExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { + CreateExceptionListItemSchema, + CreateExceptionListSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION, ENDPOINT_EVENT_FILTERS_LIST_ID, @@ -41,8 +45,8 @@ export const cli = () => { kibana: 'http://elastic:changeme@localhost:5601', }, help: ` - --count Number of event filters to create. Default: 10 - --kibana The URL to kibana including credentials. Default: http://elastic:changeme@localhost:5601 + --count Number of event filters to create. Default: 10 + --kibana The URL to kibana including credentials. Default: http://elastic:changeme@localhost:5601 `, }, } @@ -77,7 +81,25 @@ const createEventFilters: RunFn = async ({ flags, log }) => { await pMap( Array.from({ length: flags.count as unknown as number }), () => { - const body = eventGenerator.generateEventFilterForCreate(); + let options: Partial = {}; + const listSize = (flags.count ?? 10) as number; + const randomN = eventGenerator.randomN(listSize); + if (randomN > Math.floor(listSize / 2)) { + const os = eventGenerator.randomOSFamily() as ExceptionListItemSchema['os_types'][number]; + options = { + os_types: [os], + entries: [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: os === 'windows' ? 'C:\\Fol*\\file.*' : '/usr/*/*.dmg', + }, + ], + }; + } + + const body = eventGenerator.generateEventFilterForCreate(options); if (isArtifactByPolicy(body)) { const nmExceptions = Math.floor(Math.random() * 3) || 1; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts index 7181b97b4ff6..1d0b07f1b64e 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts @@ -232,14 +232,6 @@ async function main() { type: 'boolean', default: false, }, - logsEndpoint: { - alias: 'le', - describe: - 'By default .logs-endpoint.action and .logs-endpoint.action.responses are not indexed. \ - Add endpoint actions and responses using this option. Starting with v7.16.0.', - type: 'boolean', - default: false, - }, ssl: { alias: 'ssl', describe: 'Use https for elasticsearch and kbn clients', @@ -354,7 +346,6 @@ async function main() { argv.alertIndex, argv.alertsPerHost, argv.fleet, - argv.logsEndpoint, { ancestors: argv.ancestors, generations: argv.generations, diff --git a/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js b/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js index 89835e27d3fa..b0b963872585 100644 --- a/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js +++ b/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js @@ -167,6 +167,10 @@ async function main() { * 2.0. */ + // THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY + // Please modify the 'extract_tactics_techniques_mitre.js' script directly and + // run 'yarn extract-mitre-attacks' from the root 'security_solution' plugin directory + import { i18n } from '@kbn/i18n'; import { MitreTacticsOptions, MitreTechniquesOptions, MitreSubtechniquesOptions } from './types'; @@ -197,13 +201,13 @@ async function main() { * * Is built alongside and sampled from the data in the file so to always be valid with the most up to date MITRE ATT&CK data */ - export const mockThreatData = ${JSON.stringify( + export const getMockThreatData = () => (${JSON.stringify( buildMockThreatData(tactics, techniques, subtechniques), null, 2 ) .replace(/}"/g, '}') - .replace(/"{/g, '{')}; + .replace(/"{/g, '{')}); `; fs.writeFileSync(`${OUTPUT_DIRECTORY}/mitre_tactics_techniques.ts`, body, 'utf-8'); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts index 60f91330d455..20da1e212f40 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts @@ -28,6 +28,9 @@ export const ArtifactConstants = { SUPPORTED_HOST_ISOLATION_EXCEPTIONS_OPERATING_SYSTEMS: ['macos', 'windows', 'linux'], GLOBAL_HOST_ISOLATION_EXCEPTIONS_NAME: 'endpoint-hostisolationexceptionlist', + + SUPPORTED_BLOCKLISTS_OPERATING_SYSTEMS: ['macos', 'windows', 'linux'], + GLOBAL_BLOCKLISTS_NAME: 'endpoint-blocklist', }; export const ManifestConstants = { diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts index 16cbe618c507..83dbcf1ca6f6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts @@ -10,15 +10,13 @@ import { listMock } from '../../../../../lists/server/mocks'; import { getFoundExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import type { EntriesArray, EntryList } from '@kbn/securitysolution-io-ts-list-types'; -import { - buildArtifact, - getEndpointExceptionList, - getEndpointTrustedAppsList, - getFilteredEndpointExceptionList, -} from './lists'; +import { buildArtifact, getEndpointExceptionList, getFilteredEndpointExceptionList } from './lists'; import { TranslatedEntry, TranslatedExceptionListItem } from '../../schemas/artifacts'; import { ArtifactConstants } from './common'; import { + ENDPOINT_BLOCKLISTS_LIST_ID, + ENDPOINT_EVENT_FILTERS_LIST_ID, + ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID, } from '@kbn/securitysolution-list-constants'; @@ -61,12 +59,12 @@ describe('artifacts lists', () => { const first = getFoundExceptionListItemSchemaMock(); mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -107,12 +105,12 @@ describe('artifacts lists', () => { first.data[0].entries = testEntries; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -158,12 +156,12 @@ describe('artifacts lists', () => { first.data[0].entries = testEntries; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -211,12 +209,12 @@ describe('artifacts lists', () => { first.data[0].entries = testEntries; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -263,12 +261,12 @@ describe('artifacts lists', () => { first.data[0].entries = testEntries; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -306,12 +304,12 @@ describe('artifacts lists', () => { first.data[1].entries = testEntries; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -349,12 +347,12 @@ describe('artifacts lists', () => { first.data[0].entries = testEntries; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -378,12 +376,12 @@ describe('artifacts lists', () => { .mockReturnValueOnce(first) .mockReturnValueOnce(second); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); // Expect 2 exceptions, the first two calls returned the same exception list items expect(resp.entries.length).toEqual(2); @@ -394,12 +392,12 @@ describe('artifacts lists', () => { exceptionsResponse.data = []; exceptionsResponse.total = 0; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(exceptionsResponse); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp.entries.length).toEqual(0); }); @@ -543,13 +541,17 @@ describe('artifacts lists', () => { ], }; - describe('getEndpointExceptionList', () => { - test('it should build proper kuery', async () => { + describe('Builds proper kuery without policy', () => { + test('for Endpoint List', async () => { mockExceptionClient.findExceptionListItem = jest .fn() .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); - const resp = await getEndpointExceptionList(mockExceptionClient, 'v1', 'windows'); + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'windows', + }); expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); @@ -563,15 +565,18 @@ describe('artifacts lists', () => { sortOrder: 'desc', }); }); - }); - describe('getEndpointTrustedAppsList', () => { - test('it should build proper kuery without policy', async () => { + test('for Trusted Apps', async () => { mockExceptionClient.findExceptionListItem = jest .fn() .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); - const resp = await getEndpointTrustedAppsList(mockExceptionClient, 'v1', 'macos'); + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); @@ -587,17 +592,98 @@ describe('artifacts lists', () => { }); }); - test('it should build proper kuery with policy', async () => { + test('for Event Filters', async () => { mockExceptionClient.findExceptionListItem = jest .fn() .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); - const resp = await getEndpointTrustedAppsList( - mockExceptionClient, - 'v1', - 'macos', - 'c6d16e42-c32d-4dce-8a88-113cfe276ad1' - ); + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + }); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and (exception-list-agnostic.attributes.tags:"policy:all")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); + + test('for Host Isolation Exceptions', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + }); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and (exception-list-agnostic.attributes.tags:"policy:all")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); + + test('for Blocklists', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + listId: ENDPOINT_BLOCKLISTS_LIST_ID, + }); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_BLOCKLISTS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and (exception-list-agnostic.attributes.tags:"policy:all")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); + }); + + describe('Build proper kuery with policy', () => { + test('for Trusted Apps', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + policyId: 'c6d16e42-c32d-4dce-8a88-113cfe276ad1', + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); @@ -614,5 +700,91 @@ describe('artifacts lists', () => { sortOrder: 'desc', }); }); + + test('for Event Filters', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + policyId: 'c6d16e42-c32d-4dce-8a88-113cfe276ad1', + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + }); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and ' + + '(exception-list-agnostic.attributes.tags:"policy:all" or ' + + 'exception-list-agnostic.attributes.tags:"policy:c6d16e42-c32d-4dce-8a88-113cfe276ad1")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); + + test('for Host Isolation Exceptions', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + policyId: 'c6d16e42-c32d-4dce-8a88-113cfe276ad1', + listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + }); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and ' + + '(exception-list-agnostic.attributes.tags:"policy:all" or ' + + 'exception-list-agnostic.attributes.tags:"policy:c6d16e42-c32d-4dce-8a88-113cfe276ad1")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); + test('for Blocklists', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + policyId: 'c6d16e42-c32d-4dce-8a88-113cfe276ad1', + listId: ENDPOINT_BLOCKLISTS_LIST_ID, + }); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_BLOCKLISTS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and ' + + '(exception-list-agnostic.attributes.tags:"policy:all" or ' + + 'exception-list-agnostic.attributes.tags:"policy:c6d16e42-c32d-4dce-8a88-113cfe276ad1")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index d4a486539855..7a36e2ef940e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -13,14 +13,15 @@ import type { ExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { validate } from '@kbn/securitysolution-io-ts-utils'; +import { hasSimpleExecutableName, OperatingSystem } from '@kbn/securitysolution-utils'; import { + ENDPOINT_BLOCKLISTS_LIST_ID, ENDPOINT_EVENT_FILTERS_LIST_ID, ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID, } from '@kbn/securitysolution-list-constants'; -import { OperatingSystem } from '../../../../common/endpoint/types'; import { ExceptionListClient } from '../../../../../lists/server'; import { InternalArtifactCompleteSchema, @@ -40,7 +41,6 @@ import { WrappedTranslatedExceptionList, wrappedTranslatedExceptionList, } from '../../schemas'; -import { hasSimpleExecutableName } from '../../../../common/endpoint/service/trusted_apps/validations'; export async function buildArtifact( exceptions: WrappedTranslatedExceptionList, @@ -64,22 +64,30 @@ export async function buildArtifact( }; } -export async function getFilteredEndpointExceptionList( - eClient: ExceptionListClient, - schemaVersion: string, - filter: string, - listId: - | typeof ENDPOINT_LIST_ID - | typeof ENDPOINT_TRUSTED_APPS_LIST_ID - | typeof ENDPOINT_EVENT_FILTERS_LIST_ID - | typeof ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID -): Promise { +export type ArtifactListId = + | typeof ENDPOINT_LIST_ID + | typeof ENDPOINT_TRUSTED_APPS_LIST_ID + | typeof ENDPOINT_EVENT_FILTERS_LIST_ID + | typeof ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID + | typeof ENDPOINT_BLOCKLISTS_LIST_ID; + +export async function getFilteredEndpointExceptionList({ + elClient, + filter, + listId, + schemaVersion, +}: { + elClient: ExceptionListClient; + filter: string; + listId: ArtifactListId; + schemaVersion: string; +}): Promise { const exceptions: WrappedTranslatedExceptionList = { entries: [] }; let page = 1; let paging = true; while (paging) { - const response = await eClient.findExceptionListItem({ + const response = await elClient.findExceptionListItem({ listId, namespaceType: 'agnostic', filter, @@ -108,72 +116,42 @@ export async function getFilteredEndpointExceptionList( return validated as WrappedTranslatedExceptionList; } -export async function getEndpointExceptionList( - eClient: ExceptionListClient, - schemaVersion: string, - os: string -): Promise { - const filter = `exception-list-agnostic.attributes.os_types:\"${os}\"`; - - return getFilteredEndpointExceptionList(eClient, schemaVersion, filter, ENDPOINT_LIST_ID); -} - -export async function getEndpointTrustedAppsList( - eClient: ExceptionListClient, - schemaVersion: string, - os: string, - policyId?: string -): Promise { - const osFilter = `exception-list-agnostic.attributes.os_types:\"${os}\"`; - const policyFilter = `(exception-list-agnostic.attributes.tags:\"policy:all\"${ - policyId ? ` or exception-list-agnostic.attributes.tags:\"policy:${policyId}\"` : '' - })`; - - return getFilteredEndpointExceptionList( - eClient, - schemaVersion, - `${osFilter} and ${policyFilter}`, - ENDPOINT_TRUSTED_APPS_LIST_ID - ); -} - -export async function getEndpointEventFiltersList( - eClient: ExceptionListClient, - schemaVersion: string, - os: string, - policyId?: string -): Promise { +export async function getEndpointExceptionList({ + elClient, + listId, + os, + policyId, + schemaVersion, +}: { + elClient: ExceptionListClient; + listId?: ArtifactListId; + os: string; + policyId?: string; + schemaVersion: string; +}): Promise { const osFilter = `exception-list-agnostic.attributes.os_types:\"${os}\"`; const policyFilter = `(exception-list-agnostic.attributes.tags:\"policy:all\"${ policyId ? ` or exception-list-agnostic.attributes.tags:\"policy:${policyId}\"` : '' })`; - return getFilteredEndpointExceptionList( - eClient, + // for endpoint list + if (!listId || listId === ENDPOINT_LIST_ID) { + return getFilteredEndpointExceptionList({ + elClient, + schemaVersion, + filter: `${osFilter}`, + listId: ENDPOINT_LIST_ID, + }); + } + // for TAs, EFs, Host IEs and Blocklists + return getFilteredEndpointExceptionList({ + elClient, schemaVersion, - `${osFilter} and ${policyFilter}`, - ENDPOINT_EVENT_FILTERS_LIST_ID - ); + filter: `${osFilter} and ${policyFilter}`, + listId, + }); } -export async function getHostIsolationExceptionsList( - eClient: ExceptionListClient, - schemaVersion: string, - os: string, - policyId?: string -): Promise { - const osFilter = `exception-list-agnostic.attributes.os_types:\"${os}\"`; - const policyFilter = `(exception-list-agnostic.attributes.tags:\"policy:all\"${ - policyId ? ` or exception-list-agnostic.attributes.tags:\"policy:${policyId}\"` : '' - })`; - - return getFilteredEndpointExceptionList( - eClient, - schemaVersion, - `${osFilter} and ${policyFilter}`, - ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID - ); -} /** * Translates Exception list items to Exceptions the endpoint can understand * @param exceptions @@ -227,7 +205,7 @@ function getMatcherWildcardFunction({ field: string; os: ExceptionListItemSchema['os_types'][number]; }): TranslatedEntryMatchWildcardMatcher { - return field.endsWith('.caseless') + return field.endsWith('.caseless') || field.endsWith('.text') ? os === 'linux' ? 'wildcard_cased' : 'wildcard_caseless' diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.test.ts index c3182014cbb0..8be5ff542719 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.test.ts @@ -22,7 +22,9 @@ describe('Pagination', () => { }; describe('cursor', () => { const root = generator.generateEvent(); - const events = Array.from(generator.relatedEventsGenerator({ node: root, relatedEvents: 5 })); + const events = Array.from( + generator.relatedEventsGenerator({ node: root, relatedEvents: 5, sessionEntryLeader: 'test' }) + ); it('does build a cursor when received the same number of events as was requested', () => { expect(PaginationBuilder.buildCursorRequestLimit(4, events)).not.toBeNull(); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts index 95d0c8b607cb..717eadc13633 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts @@ -10,6 +10,8 @@ import { ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID, + ENDPOINT_EVENT_FILTERS_LIST_ID, + ENDPOINT_BLOCKLISTS_LIST_ID, } from '@kbn/securitysolution-list-constants'; import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { PackagePolicy } from '../../../../../../fleet/common/types/models'; @@ -73,6 +75,9 @@ describe('ManifestManager', () => { 'endpoint-hostisolationexceptionlist-windows-v1'; const ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_LINUX = 'endpoint-hostisolationexceptionlist-linux-v1'; + const ARTIFACT_NAME_BLOCKLISTS_MACOS = 'endpoint-blocklist-macos-v1'; + const ARTIFACT_NAME_BLOCKLISTS_WINDOWS = 'endpoint-blocklist-windows-v1'; + const ARTIFACT_NAME_BLOCKLISTS_LINUX = 'endpoint-blocklist-linux-v1'; let ARTIFACTS: InternalArtifactCompleteSchema[] = []; let ARTIFACTS_BY_ID: { [K: string]: InternalArtifactCompleteSchema } = {}; @@ -284,6 +289,9 @@ describe('ManifestManager', () => { ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_MACOS, ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_WINDOWS, ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_LINUX, + ARTIFACT_NAME_BLOCKLISTS_MACOS, + ARTIFACT_NAME_BLOCKLISTS_WINDOWS, + ARTIFACT_NAME_BLOCKLISTS_LINUX, ]; const getArtifactIds = (artifacts: InternalArtifactSchema[]) => [ @@ -327,7 +335,7 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(12); + expect(artifacts.length).toBe(15); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); for (const artifact of artifacts) { @@ -342,14 +350,18 @@ describe('ManifestManager', () => { test('Builds fully new manifest if no baseline parameter passed and present exception list items', async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); + const eventFiltersListItem = getExceptionListItemSchemaMock({ os_types: ['windows'] }); const hostIsolationExceptionsItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); + const blocklistsListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, [ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem] }, + [ENDPOINT_EVENT_FILTERS_LIST_ID]: { linux: [eventFiltersListItem] }, [ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID]: { linux: [hostIsolationExceptionsItem] }, + [ENDPOINT_BLOCKLISTS_LIST_ID]: { linux: [blocklistsListItem] }, }); context.savedObjectsClient.create = jest .fn() @@ -366,7 +378,7 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(12); + expect(artifacts.length).toBe(15); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(getArtifactObject(artifacts[0])).toStrictEqual({ @@ -381,12 +393,19 @@ describe('ManifestManager', () => { }); expect(getArtifactObject(artifacts[6])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[7])).toStrictEqual({ entries: [] }); - expect(getArtifactObject(artifacts[8])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[8])).toStrictEqual({ + entries: translateToEndpointExceptions([eventFiltersListItem], 'v1'), + }); expect(getArtifactObject(artifacts[9])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[10])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[11])).toStrictEqual({ entries: translateToEndpointExceptions([hostIsolationExceptionsItem], 'v1'), }); + expect(getArtifactObject(artifacts[12])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[13])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[14])).toStrictEqual({ + entries: translateToEndpointExceptions([blocklistsListItem], 'v1'), + }); for (const artifact of artifacts) { expect(manifest.isDefaultArtifact(artifact)).toBe(true); @@ -399,6 +418,9 @@ describe('ManifestManager', () => { test('Reuses artifacts when baseline parameter passed and present exception list items', async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); + const eventFiltersListItem = getExceptionListItemSchemaMock({ os_types: ['windows'] }); + const hostIsolationExceptionsItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); + const blocklistsListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); @@ -416,6 +438,9 @@ describe('ManifestManager', () => { context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, [ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem] }, + [ENDPOINT_EVENT_FILTERS_LIST_ID]: { linux: [eventFiltersListItem] }, + [ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID]: { linux: [hostIsolationExceptionsItem] }, + [ENDPOINT_BLOCKLISTS_LIST_ID]: { linux: [blocklistsListItem] }, }); const manifest = await manifestManager.buildNewManifest(oldManifest); @@ -426,7 +451,7 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(12); + expect(artifacts.length).toBe(15); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(artifacts[0]).toStrictEqual(oldManifest.getAllArtifacts()[0]); @@ -439,7 +464,19 @@ describe('ManifestManager', () => { }); expect(getArtifactObject(artifacts[6])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[7])).toStrictEqual({ entries: [] }); - expect(getArtifactObject(artifacts[8])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[8])).toStrictEqual({ + entries: translateToEndpointExceptions([eventFiltersListItem], 'v1'), + }); + expect(getArtifactObject(artifacts[9])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[10])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[11])).toStrictEqual({ + entries: translateToEndpointExceptions([hostIsolationExceptionsItem], 'v1'), + }); + expect(getArtifactObject(artifacts[12])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[13])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[14])).toStrictEqual({ + entries: translateToEndpointExceptions([blocklistsListItem], 'v1'), + }); for (const artifact of artifacts) { expect(manifest.isDefaultArtifact(artifact)).toBe(true); @@ -449,6 +486,7 @@ describe('ManifestManager', () => { } }); + // test('Builds manifest with policy specific exception list items for trusted apps', async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); @@ -487,7 +525,7 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(13); + expect(artifacts.length).toBe(16); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(getArtifactObject(artifacts[0])).toStrictEqual({ diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index af985bf23017..7be2a36396a7 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -10,6 +10,12 @@ import semver from 'semver'; import LRU from 'lru-cache'; import { isEqual, isEmpty } from 'lodash'; import { Logger, SavedObjectsClientContract } from 'src/core/server'; +import { + ENDPOINT_EVENT_FILTERS_LIST_ID, + ENDPOINT_TRUSTED_APPS_LIST_ID, + ENDPOINT_BLOCKLISTS_LIST_ID, + ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, +} from '@kbn/securitysolution-list-constants'; import { ListResult } from '../../../../../../fleet/common'; import { PackagePolicyServiceInterface } from '../../../../../../fleet/server'; import { ExceptionListClient } from '../../../../../../lists/server'; @@ -23,10 +29,7 @@ import { ArtifactConstants, buildArtifact, getArtifactId, - getEndpointEventFiltersList, getEndpointExceptionList, - getEndpointTrustedAppsList, - getHostIsolationExceptionsList, Manifest, } from '../../../lib/artifacts'; import { @@ -133,7 +136,11 @@ export class ManifestManager { */ protected async buildExceptionListArtifact(os: string): Promise { return buildArtifact( - await getEndpointExceptionList(this.exceptionListClient, this.schemaVersion, os), + await getEndpointExceptionList({ + elClient: this.exceptionListClient, + schemaVersion: this.schemaVersion, + os, + }), this.schemaVersion, os, ArtifactConstants.GLOBAL_ALLOWLIST_NAME @@ -171,7 +178,13 @@ export class ManifestManager { */ protected async buildTrustedAppsArtifact(os: string, policyId?: string) { return buildArtifact( - await getEndpointTrustedAppsList(this.exceptionListClient, this.schemaVersion, os, policyId), + await getEndpointExceptionList({ + elClient: this.exceptionListClient, + schemaVersion: this.schemaVersion, + os, + policyId, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }), this.schemaVersion, os, ArtifactConstants.GLOBAL_TRUSTED_APPS_NAME @@ -231,13 +244,66 @@ export class ManifestManager { protected async buildEventFiltersForOs(os: string, policyId?: string) { return buildArtifact( - await getEndpointEventFiltersList(this.exceptionListClient, this.schemaVersion, os, policyId), + await getEndpointExceptionList({ + elClient: this.exceptionListClient, + schemaVersion: this.schemaVersion, + os, + policyId, + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + }), this.schemaVersion, os, ArtifactConstants.GLOBAL_EVENT_FILTERS_NAME ); } + /** + * Builds an array of Blocklist entries (one per supported OS) based on the current state of the + * Blocklist list + * @protected + */ + protected async buildBlocklistArtifacts(): Promise { + const defaultArtifacts: InternalArtifactCompleteSchema[] = []; + const policySpecificArtifacts: Record = {}; + + for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { + defaultArtifacts.push(await this.buildBlocklistForOs(os)); + } + + await iterateAllListItems( + (page) => this.listEndpointPolicyIds(page), + async (policyId) => { + for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { + policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; + policySpecificArtifacts[policyId].push(await this.buildBlocklistForOs(os, policyId)); + } + } + ); + + return { defaultArtifacts, policySpecificArtifacts }; + } + + protected async buildBlocklistForOs(os: string, policyId?: string) { + return buildArtifact( + await getEndpointExceptionList({ + elClient: this.exceptionListClient, + schemaVersion: this.schemaVersion, + os, + policyId, + listId: ENDPOINT_BLOCKLISTS_LIST_ID, + }), + this.schemaVersion, + os, + ArtifactConstants.GLOBAL_BLOCKLISTS_NAME + ); + } + + /** + * Builds an array of endpoint host isolation exception (one per supported OS) based on the current state of the + * Host Isolation Exception List + * @returns + */ + protected async buildHostIsolationExceptionsArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; @@ -266,12 +332,13 @@ export class ManifestManager { policyId?: string ): Promise { return buildArtifact( - await getHostIsolationExceptionsList( - this.exceptionListClient, - this.schemaVersion, + await getEndpointExceptionList({ + elClient: this.exceptionListClient, + schemaVersion: this.schemaVersion, os, - policyId - ), + policyId, + listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + }), this.schemaVersion, os, ArtifactConstants.GLOBAL_HOST_ISOLATION_EXCEPTIONS_NAME @@ -413,7 +480,7 @@ export class ManifestManager { * Builds a new manifest based on the current user exception list. * * @param baselineManifest A baseline manifest to use for initializing pre-existing artifacts. - * @returns {Promise} A new Manifest object reprenting the current exception list. + * @returns {Promise} A new Manifest object representing the current exception list. */ public async buildNewManifest( baselineManifest: Manifest = ManifestManager.createDefaultManifest(this.schemaVersion) @@ -423,6 +490,7 @@ export class ManifestManager { this.buildTrustedAppsArtifacts(), this.buildEventFiltersArtifacts(), this.buildHostIsolationExceptionsArtifacts(), + this.buildBlocklistArtifacts(), ]); const manifest = new Manifest({ diff --git a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts index db39d4c5108e..b5e787bd90c9 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts @@ -41,8 +41,8 @@ import { Manifest } from '../endpoint/lib/artifacts'; import { NewPackagePolicy } from '../../../fleet/common/types/models'; import { ManifestSchema } from '../../common/endpoint/schema/manifest'; import { DeletePackagePoliciesResponse } from '../../../fleet/common'; -import { ARTIFACT_LISTS_IDS_TO_REMOVE } from './handlers/remove_policy_from_artifacts'; import { createMockPolicyData } from '../endpoint/services/feature_usage'; +import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../common/endpoint/service/artifacts/constants'; describe('ingest_integration tests ', () => { let endpointAppContextMock: EndpointAppContextServiceStartContract; @@ -334,11 +334,11 @@ describe('ingest_integration tests ', () => { await invokeDeleteCallback(); expect(exceptionListClient.findExceptionListsItem).toHaveBeenCalledWith({ - listId: ARTIFACT_LISTS_IDS_TO_REMOVE, - filter: ARTIFACT_LISTS_IDS_TO_REMOVE.map( + listId: ALL_ENDPOINT_ARTIFACT_LIST_IDS, + filter: ALL_ENDPOINT_ARTIFACT_LIST_IDS.map( () => `exception-list-agnostic.attributes.tags:"policy:${policyId}"` ), - namespaceType: ARTIFACT_LISTS_IDS_TO_REMOVE.map(() => 'agnostic'), + namespaceType: ALL_ENDPOINT_ARTIFACT_LIST_IDS.map(() => 'agnostic'), page: 1, perPage: 50, sortField: undefined, diff --git a/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts b/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts index 57a23d677e01..28ee9d5ad81d 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts @@ -7,19 +7,9 @@ import pMap from 'p-map'; -import { - ENDPOINT_TRUSTED_APPS_LIST_ID, - ENDPOINT_EVENT_FILTERS_LIST_ID, - ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, -} from '@kbn/securitysolution-list-constants'; import { ExceptionListClient } from '../../../../lists/server'; import { PostPackagePolicyDeleteCallback } from '../../../../fleet/server'; - -export const ARTIFACT_LISTS_IDS_TO_REMOVE = [ - ENDPOINT_TRUSTED_APPS_LIST_ID, - ENDPOINT_EVENT_FILTERS_LIST_ID, - ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, -]; +import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../common/endpoint/service/artifacts/constants'; /** * Removes policy from artifacts @@ -32,11 +22,11 @@ export const removePolicyFromArtifacts = async ( const findArtifactsByPolicy = (currentPage: number) => { return exceptionsClient.findExceptionListsItem({ - listId: ARTIFACT_LISTS_IDS_TO_REMOVE, - filter: ARTIFACT_LISTS_IDS_TO_REMOVE.map( + listId: ALL_ENDPOINT_ARTIFACT_LIST_IDS as string[], + filter: ALL_ENDPOINT_ARTIFACT_LIST_IDS.map( () => `exception-list-agnostic.attributes.tags:"policy:${policy.id}"` ), - namespaceType: ARTIFACT_LISTS_IDS_TO_REMOVE.map(() => 'agnostic'), + namespaceType: ALL_ENDPOINT_ARTIFACT_LIST_IDS.map(() => 'agnostic'), page: currentPage, perPage: 50, sortField: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts index 11396864d802..3deb87e864f2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts @@ -20,6 +20,7 @@ import { SetupPlugins } from '../../../../plugin'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { createRuleValidateTypeDependents } from '../../../../../common/detection_engine/schemas/request/create_rules_type_dependents'; import { DETECTION_ENGINE_RULES_PREVIEW } from '../../../../../common/constants'; +import { wrapScopedClusterClient } from './utils/wrap_scoped_cluster_client'; import { previewRulesSchema, RulePreviewLogs, @@ -34,7 +35,7 @@ import { } from '../../../../../../alerting/common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ExecutorType } from '../../../../../../alerting/server/types'; -import { Alert, createAbortableEsClientFactory } from '../../../../../../alerting/server'; +import { Alert } from '../../../../../../alerting/server'; import { ConfigType } from '../../../../config'; import { alertInstanceFactoryStub } from '../../signals/preview/alert_instance_factory_stub'; import { CreateRuleOptions, CreateSecurityRuleTypeWrapperProps } from '../../rule_types/types'; @@ -47,6 +48,9 @@ import { } from '../../rule_types'; import { createSecurityRuleTypeWrapper } from '../../rule_types/create_security_rule_type_wrapper'; import { RULE_PREVIEW_INVOCATION_COUNT } from '../../../../../common/detection_engine/constants'; +import { RuleExecutionContext, StatusChangeArgs } from '../../rule_execution_log'; + +const PREVIEW_TIMEOUT_SECONDS = 60; export const previewRulesRoute = async ( router: SecuritySolutionPluginRouter, @@ -87,13 +91,7 @@ export const previewRulesRoute = async ( ].includes(invocationCount) ) { return response.ok({ - body: { logs: [{ errors: ['Invalid invocation count'], warnings: [] }] }, - }); - } - - if (request.body.type === 'threat_match') { - return response.ok({ - body: { logs: [{ errors: ['Preview for rule type not supported'], warnings: [] }] }, + body: { logs: [{ errors: ['Invalid invocation count'], warnings: [], duration: 0 }] }, }); } @@ -112,9 +110,11 @@ export const previewRulesRoute = async ( const spaceId = siemClient.getSpaceId(); const previewId = uuid.v4(); const username = security?.authc.getCurrentUser(request)?.username; - const previewRuleExecutionLogger = createPreviewRuleExecutionLogger(); + const loggedStatusChanges: Array = []; + const previewRuleExecutionLogger = createPreviewRuleExecutionLogger(loggedStatusChanges); const runState: Record = {}; const logs: RulePreviewLogs[] = []; + let isAborted = false; const previewRuleTypeWrapper = createSecurityRuleTypeWrapper({ ...securityRuleTypeOptions, @@ -158,6 +158,12 @@ export const previewRulesRoute = async ( ) => { let statePreview = runState as TState; + const abortController = new AbortController(); + setTimeout(() => { + abortController.abort(); + isAborted = true; + }, PREVIEW_TIMEOUT_SECONDS * 1000); + const startedAt = moment(); const parsedDuration = parseDuration(internalRule.schedule.interval) ?? 0; startedAt.subtract(moment.duration(parsedDuration * (invocationCount - 1))); @@ -175,7 +181,11 @@ export const previewRulesRoute = async ( updatedBy: username ?? 'preview-updated-by', }; - while (invocationCount > 0) { + let invocationStartTime; + + while (invocationCount > 0 && !isAborted) { + invocationStartTime = moment(); + statePreview = (await executor({ alertId: previewId, createdBy: rule.createdBy, @@ -188,13 +198,12 @@ export const previewRulesRoute = async ( shouldWriteAlerts, shouldStopExecution: () => false, alertFactory, - // Just use es client always for preview - search: createAbortableEsClientFactory({ + savedObjectsClient: context.core.savedObjects.client, + scopedClusterClient: wrapScopedClusterClient({ + abortController, scopedClusterClient: context.core.elasticsearch.client, - abortController: new AbortController(), }), - savedObjectsClient: context.core.savedObjects.client, - scopedClusterClient: context.core.elasticsearch.client, + uiSettingsClient: context.core.uiSettings.client, }, spaceId, startedAt: startedAt.toDate(), @@ -203,12 +212,11 @@ export const previewRulesRoute = async ( updatedBy: rule.updatedBy, })) as TState; - // Save and reset error and warning logs - const errors = previewRuleExecutionLogger.logged.statusChanges + const errors = loggedStatusChanges .filter((item) => item.newStatus === RuleExecutionStatus.failed) .map((item) => item.message ?? 'Unkown Error'); - const warnings = previewRuleExecutionLogger.logged.statusChanges + const warnings = loggedStatusChanges .filter((item) => item.newStatus === RuleExecutionStatus['partial failure']) .map((item) => item.message ?? 'Unknown Warning'); @@ -216,9 +224,14 @@ export const previewRulesRoute = async ( errors, warnings, startedAt: startedAt.toDate().toISOString(), + duration: moment().diff(invocationStartTime, 'milliseconds'), }); - previewRuleExecutionLogger.clearLogs(); + loggedStatusChanges.length = 0; + + if (errors.length) { + break; + } previousStartedAt = startedAt.toDate(); startedAt.add(parseInterval(internalRule.schedule.interval)); @@ -300,6 +313,7 @@ export const previewRulesRoute = async ( body: { previewId, logs, + isAborted, }, }); } catch (err) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_scoped_cluster_client.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_scoped_cluster_client.test.ts new file mode 100644 index 000000000000..ce037566e828 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_scoped_cluster_client.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright 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 { Client } from '@elastic/elasticsearch'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { wrapScopedClusterClient } from './wrap_scoped_cluster_client'; + +const esQuery = { + body: { query: { bool: { filter: { range: { '@timestamp': { gte: 0 } } } } } }, +}; + +describe('wrapScopedClusterClient', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('searches with asInternalUser when specified', async () => { + const abortController = new AbortController(); + const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + const childClient = elasticsearchServiceMock.createElasticsearchClient(); + + scopedClusterClient.asInternalUser.child.mockReturnValue(childClient as unknown as Client); + const asInternalUserWrappedSearchFn = childClient.search; + + const wrappedSearchClient = wrapScopedClusterClient({ + scopedClusterClient, + abortController, + }); + await wrappedSearchClient.asInternalUser.search(esQuery); + + expect(asInternalUserWrappedSearchFn).toHaveBeenCalledWith(esQuery, { + signal: abortController.signal, + }); + expect(scopedClusterClient.asInternalUser.search).not.toHaveBeenCalled(); + expect(scopedClusterClient.asCurrentUser.search).not.toHaveBeenCalled(); + }); + + test('searches with asCurrentUser when specified', async () => { + const abortController = new AbortController(); + const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + const childClient = elasticsearchServiceMock.createElasticsearchClient(); + + scopedClusterClient.asCurrentUser.child.mockReturnValue(childClient as unknown as Client); + const asCurrentUserWrappedSearchFn = childClient.search; + + const wrappedSearchClient = wrapScopedClusterClient({ + scopedClusterClient, + abortController, + }); + await wrappedSearchClient.asCurrentUser.search(esQuery); + + expect(asCurrentUserWrappedSearchFn).toHaveBeenCalledWith(esQuery, { + signal: abortController.signal, + }); + expect(scopedClusterClient.asInternalUser.search).not.toHaveBeenCalled(); + expect(scopedClusterClient.asCurrentUser.search).not.toHaveBeenCalled(); + }); + + test('uses search options when specified', async () => { + const abortController = new AbortController(); + const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + const childClient = elasticsearchServiceMock.createElasticsearchClient(); + + scopedClusterClient.asInternalUser.child.mockReturnValue(childClient as unknown as Client); + const asInternalUserWrappedSearchFn = childClient.search; + + const wrappedSearchClient = wrapScopedClusterClient({ + scopedClusterClient, + abortController, + }); + await wrappedSearchClient.asInternalUser.search(esQuery, { ignore: [404] }); + + expect(asInternalUserWrappedSearchFn).toHaveBeenCalledWith(esQuery, { + ignore: [404], + signal: abortController.signal, + }); + expect(scopedClusterClient.asInternalUser.search).not.toHaveBeenCalled(); + expect(scopedClusterClient.asCurrentUser.search).not.toHaveBeenCalled(); + }); + + test('re-throws error when search throws error', async () => { + const abortController = new AbortController(); + const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + const childClient = elasticsearchServiceMock.createElasticsearchClient(); + + scopedClusterClient.asInternalUser.child.mockReturnValue(childClient as unknown as Client); + const asInternalUserWrappedSearchFn = childClient.search; + + asInternalUserWrappedSearchFn.mockRejectedValueOnce(new Error('something went wrong!')); + const wrappedSearchClient = wrapScopedClusterClient({ + scopedClusterClient, + abortController, + }); + + await expect( + wrappedSearchClient.asInternalUser.search + ).rejects.toThrowErrorMatchingInlineSnapshot(`"something went wrong!"`); + }); + + test('handles empty search result object', async () => { + const abortController = new AbortController(); + const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + const childClient = elasticsearchServiceMock.createElasticsearchClient(); + + scopedClusterClient.asInternalUser.child.mockReturnValue(childClient as unknown as Client); + const asInternalUserWrappedSearchFn = childClient.search; + // @ts-ignore incomplete return type + asInternalUserWrappedSearchFn.mockResolvedValue({}); + + const wrappedSearchClient = wrapScopedClusterClient({ + scopedClusterClient, + abortController, + }); + + await wrappedSearchClient.asInternalUser.search(esQuery); + + expect(asInternalUserWrappedSearchFn).toHaveBeenCalledTimes(1); + expect(scopedClusterClient.asInternalUser.search).not.toHaveBeenCalled(); + expect(scopedClusterClient.asCurrentUser.search).not.toHaveBeenCalled(); + }); + + test('throws error when search throws abort error', async () => { + const abortController = new AbortController(); + abortController.abort(); + const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + const childClient = elasticsearchServiceMock.createElasticsearchClient(); + + scopedClusterClient.asInternalUser.child.mockReturnValue(childClient as unknown as Client); + childClient.search.mockRejectedValueOnce(new Error('Request has been aborted by the user')); + + const abortableSearchClient = wrapScopedClusterClient({ + scopedClusterClient, + abortController, + }); + + await expect( + abortableSearchClient.asInternalUser.search + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Search has been aborted due to cancelled execution"` + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_scoped_cluster_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_scoped_cluster_client.ts new file mode 100644 index 000000000000..f007a7dc745c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_scoped_cluster_client.ts @@ -0,0 +1,111 @@ +/* + * Copyright 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 { + TransportRequestOptions, + TransportResult, + TransportRequestOptionsWithMeta, + TransportRequestOptionsWithOutMeta, +} from '@elastic/elasticsearch'; +import type { + SearchRequest, + SearchResponse, + AggregateName, +} from '@elastic/elasticsearch/lib/api/types'; +import type { + SearchRequest as SearchRequestWithBody, + AggregationsAggregate, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { IScopedClusterClient, ElasticsearchClient } from 'src/core/server'; + +interface WrapScopedClusterClientOpts { + scopedClusterClient: IScopedClusterClient; + abortController: AbortController; +} + +type WrapEsClientOpts = Omit & { + esClient: ElasticsearchClient; +}; + +export function wrapScopedClusterClient(opts: WrapScopedClusterClientOpts): IScopedClusterClient { + const { scopedClusterClient, ...rest } = opts; + return { + asInternalUser: wrapEsClient({ + ...rest, + esClient: scopedClusterClient.asInternalUser, + }), + asCurrentUser: wrapEsClient({ + ...rest, + esClient: scopedClusterClient.asCurrentUser, + }), + }; +} + +function wrapEsClient(opts: WrapEsClientOpts): ElasticsearchClient { + const { esClient, ...rest } = opts; + + const wrappedClient = esClient.child({}); + + // Mutating the functions we want to wrap + wrappedClient.search = getWrappedSearchFn({ esClient: wrappedClient, ...rest }); + + return wrappedClient; +} + +function getWrappedSearchFn(opts: WrapEsClientOpts) { + const originalSearch = opts.esClient.search; + + // A bunch of overloads to make TypeScript happy + async function search< + TDocument = unknown, + TAggregations = Record + >( + params?: SearchRequest | SearchRequestWithBody, + options?: TransportRequestOptionsWithOutMeta + ): Promise>; + async function search< + TDocument = unknown, + TAggregations = Record + >( + params?: SearchRequest | SearchRequestWithBody, + options?: TransportRequestOptionsWithMeta + ): Promise, unknown>>; + async function search< + TDocument = unknown, + TAggregations = Record + >( + params?: SearchRequest | SearchRequestWithBody, + options?: TransportRequestOptions + ): Promise>; + async function search< + TDocument = unknown, + TAggregations = Record + >( + params?: SearchRequest | SearchRequestWithBody, + options?: TransportRequestOptions + ): Promise< + | TransportResult, unknown> + | SearchResponse + > { + try { + const searchOptions = options ?? {}; + return (await originalSearch.call(opts.esClient, params, { + ...searchOptions, + signal: opts.abortController.signal, + })) as + | TransportResult, unknown> + | SearchResponse; + } catch (e) { + if (opts.abortController.signal.aborted) { + throw new Error('Search has been aborted due to cancelled execution'); + } + throw e; + } + } + + return search; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/event_log_reader.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/event_log_reader.ts index a3c2421edc85..3a59c26c769e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/event_log_reader.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/event_log_reader.ts @@ -55,8 +55,7 @@ export const createEventLogReader = (eventLog: IEventLogClient): IEventLogReader return eventLog.findEventsBySavedObjectIds(soType, soIds, { page: 1, per_page: count, - sort_field: '@timestamp', - sort_order: 'desc', + sort: [{ sort_field: '@timestamp', sort_order: 'desc' }], filter: kqlFilter, }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts index 3d390cac6b91..2129596a1b20 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts @@ -73,7 +73,6 @@ export const createRuleTypeMocks = ( } as SavedObject); const services = { - search: elasticsearchServiceMock.createScopedClusterClient(), savedObjectsClient: mockSavedObjectsClient, scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), alertFactory: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts index 1096573f81cd..25b5471a3c2e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts @@ -72,7 +72,12 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = } = options; let runState = state; const { from, maxSignals, meta, ruleId, timestampOverride, to } = params; - const { alertWithPersistence, savedObjectsClient, scopedClusterClient } = services; + const { + alertWithPersistence, + savedObjectsClient, + scopedClusterClient, + uiSettingsClient, + } = services; const searchAfterSize = Math.min(maxSignals, DEFAULT_SEARCH_AFTER_PAGE_SIZE); const esClient = scopedClusterClient.asCurrentUser; @@ -155,6 +160,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = logger, buildRuleMessage, ruleExecutionLogger, + uiSettingsClient, }); if (!wroteWarningStatus) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts index 7d0cfa55922b..d9119b3cff8d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts @@ -60,7 +60,7 @@ describe('Custom Query Alerts', () => { alerting.registerType(queryAlertType); - services.search.asCurrentUser.search.mockReturnValue( + services.scopedClusterClient.asCurrentUser.search.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { hits: [], @@ -104,7 +104,7 @@ describe('Custom Query Alerts', () => { alerting.registerType(queryAlertType); - services.search.asCurrentUser.search.mockReturnValue( + services.scopedClusterClient.asCurrentUser.search.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { hits: [sampleDocNoSortId()], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/file_ex.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/file_ex.json new file mode 100644 index 000000000000..b20857bb0754 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/file_ex.json @@ -0,0 +1 @@ +{"savedObjectId":null,"version":null,"columns":[{"columnHeaderType":"not-filtered","id":"@timestamp","type":"number"},{"aggregatable":false,"description":"For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.","columnHeaderType":"not-filtered","id":"message","category":"base","type":"string","example":"Hello World"},{"aggregatable":true,"description":"Process name. Sometimes called program name or similar.","columnHeaderType":"not-filtered","id":"process.name","category":"process","type":"string","example":"ssh"},{"aggregatable":true,"description":"The action captured by the event. This describes the information in the event. It is more specific than `event.category`. Examples are `group-add`, `process-started`, `file-created`. The value is normally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","example":"user-password-change"},{"aggregatable":true,"description":"Name of the file including the extension, without the directory.","columnHeaderType":"not-filtered","id":"file.name","category":"file","type":"string","example":"example.png"},{"columnHeaderType":"not-filtered","id":"file.size"},{"columnHeaderType":"not-filtered","id":"file.path"},{"columnHeaderType":"not-filtered","id":"host.name"},{"columnHeaderType":"not-filtered","id":"user.name"}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"file","queryMatch":{"displayValue":null,"field":"event.category","displayField":null,"value":"file","operator":":"},"id":"timeline-1-fd6cfcf0-cfbd-4a42-b58e-9efccca7ecdd","type":"default","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.group.id}","queryMatch":{"displayValue":null,"field":"signal.group.id","displayField":null,"value":"{signal.group.id}","operator":":"},"id":"timeline-1-072c4726-d198-41c5-a3dc-561062c454a9","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.original_event.id}","queryMatch":{"field":"signal.original_event.id","value":"{signal.original_event.id}","operator":":"},"id":"timeline-1-dba415d2-9968-4961-8b0f-a381c3d28c87","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","timelineType":"template","kqlQuery":{"filterQuery":null},"title":"Comprehensive File Timeline","sort":[{"columnType":"number","sortDirection":"desc","columnId":"@timestamp"}],"templateTimelineId":"4d4c0b59-ea83-483f-b8c1-8c360ee53c5c","templateTimelineVersion":2,"created":1618433758898,"createdBy":"1674059739","updated":1618500709024,"updatedBy":"elastic","dateRange":{"start":"2021-04-13T20:55:34.156Z","end":"2021-04-14T20:55:34.157Z"},"indexNames":[],"eqlOptions":{"tiebreakerField":"","size":100,"query":"","eventCategoryField":"event.category","timestampField":"@timestamp"},"savedQueryId":null,"eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} \ No newline at end of file diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/index.ndjson b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/index.ndjson index 84972a837a3e..bf2e16ede0de 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/index.ndjson +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/index.ndjson @@ -9,6 +9,10 @@ // Do not hand edit. Run that script to regenerate package information instead {"savedObjectId":null,"version":null,"columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"kibana.alert.rule.description","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"event.action","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.name","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The working directory of the process.","columnHeaderType":"not-filtered","id":"process.working_directory","category":"process","type":"string","searchable":null,"example":"/home/alice"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","searchable":null,"example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.pid","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Absolute path to the process executable.","columnHeaderType":"not-filtered","id":"process.parent.executable","category":"process","type":"string","searchable":null,"example":"/usr/bin/ssh"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.parent.args","category":"process","type":"string","searchable":null,"example":"[\"ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"aggregatable":true,"name":null,"description":"Process id.","columnHeaderType":"not-filtered","id":"process.parent.pid","category":"process","type":"number","searchable":null,"example":"4242"},{"indexes":null,"aggregatable":true,"name":null,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","searchable":null,"example":"albert"},{"indexes":null,"aggregatable":true,"name":null,"description":"Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.","columnHeaderType":"not-filtered","id":"host.name","category":"host","type":"string","searchable":null}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"","queryMatch":{"displayValue":"endpoint","field":"agent.type","displayField":"agent.type","value":"endpoint","operator":":"},"id":"timeline-1-4685da24-35c1-43f3-892d-1f926dbf5568","type":"default","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Endpoint Timeline","timelineType":"template","templateTimelineVersion":2,"templateTimelineId":"db366523-f1c6-4c1f-8731-6ce5ed9e5717","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1594735857110,"createdBy":"Elastic","updated":1611609999115,"updatedBy":"Elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} +{"savedObjectId":null,"version":null,"columns":[{"columnHeaderType":"not-filtered","id":"@timestamp","type":"number"},{"aggregatable":false,"description":"For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.","columnHeaderType":"not-filtered","id":"message","category":"base","type":"string","example":"Hello World"},{"aggregatable":true,"description":"Process name. Sometimes called program name or similar.","columnHeaderType":"not-filtered","id":"process.name","category":"process","type":"string","example":"ssh"},{"aggregatable":true,"description":"The action captured by the event. This describes the information in the event. It is more specific than `event.category`. Examples are `group-add`, `process-started`, `file-created`. The value is normally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","example":"user-password-change"},{"aggregatable":true,"description":"Name of the file including the extension, without the directory.","columnHeaderType":"not-filtered","id":"file.name","category":"file","type":"string","example":"example.png"},{"columnHeaderType":"not-filtered","id":"file.size"},{"columnHeaderType":"not-filtered","id":"file.path"},{"columnHeaderType":"not-filtered","id":"host.name"},{"columnHeaderType":"not-filtered","id":"user.name"}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"file","queryMatch":{"displayValue":null,"field":"event.category","displayField":null,"value":"file","operator":":"},"id":"timeline-1-fd6cfcf0-cfbd-4a42-b58e-9efccca7ecdd","type":"default","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.group.id}","queryMatch":{"displayValue":null,"field":"signal.group.id","displayField":null,"value":"{signal.group.id}","operator":":"},"id":"timeline-1-072c4726-d198-41c5-a3dc-561062c454a9","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.original_event.id}","queryMatch":{"field":"signal.original_event.id","value":"{signal.original_event.id}","operator":":"},"id":"timeline-1-dba415d2-9968-4961-8b0f-a381c3d28c87","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","timelineType":"template","kqlQuery":{"filterQuery":null},"title":"Comprehensive File Timeline","sort":[{"columnType":"number","sortDirection":"desc","columnId":"@timestamp"}],"templateTimelineId":"4d4c0b59-ea83-483f-b8c1-8c360ee53c5c","templateTimelineVersion":2,"created":1618433758898,"createdBy":"1674059739","updated":1618500709024,"updatedBy":"elastic","dateRange":{"start":"2021-04-13T20:55:34.156Z","end":"2021-04-14T20:55:34.157Z"},"indexNames":[],"eqlOptions":{"tiebreakerField":"","size":100,"query":"","eventCategoryField":"event.category","timestampField":"@timestamp"},"savedQueryId":null,"eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} +{"savedObjectId":null,"version":null,"columns":[{"columnHeaderType":"not-filtered","id":"@timestamp","type":"number"},{"aggregatable":false,"description":"For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.","columnHeaderType":"not-filtered","id":"message","category":"base","type":"string","example":"Hello World"},{"aggregatable":true,"description":"The action captured by the event. This describes the information in the event. It is more specific than `event.category`. Examples are `group-add`, `process-started`, `file-created`. The value is normally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","example":"user-password-change"},{"aggregatable":true,"description":"In the OSI Model this would be the Network Layer. ipv4, ipv6, ipsec, pim, etc The field value must be normalized to lowercase for querying. See the documentation section \"Implementing ECS\".","columnHeaderType":"not-filtered","id":"network.type","category":"network","type":"string","example":"ipv4"},{"aggregatable":true,"description":"Same as network.iana_number, but instead using the Keyword name of the transport layer (udp, tcp, ipv6-icmp, etc.) The field value must be normalized to lowercase for querying. See the documentation section \"Implementing ECS\".","columnHeaderType":"not-filtered","id":"network.transport","category":"network","type":"string","example":"tcp"},{"aggregatable":true,"description":"Direction of the network traffic. Recommended values are: * inbound * outbound * internal * external * unknown When mapping events from a host-based monitoring context, populate this field from the host's point of view. When mapping events from a network or perimeter-based monitoring context, populate this field from the point of view of your network perimeter.","columnHeaderType":"not-filtered","id":"network.direction","category":"network","type":"string","example":"inbound"},{"aggregatable":true,"description":"IP address of the source. Can be one or multiple IPv4 or IPv6 addresses.","columnHeaderType":"not-filtered","id":"source.ip","category":"source","type":"ip"},{"columnHeaderType":"not-filtered","id":"source.port"},{"aggregatable":true,"description":"IP address of the destination. Can be one or multiple IPv4 or IPv6 addresses.","columnHeaderType":"not-filtered","id":"destination.ip","category":"destination","type":"ip"},{"columnHeaderType":"not-filtered","id":"destination.port"},{"aggregatable":true,"description":"Name of the host. It can contain what `hostname` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.","columnHeaderType":"not-filtered","id":"host.name","category":"host","type":"string"},{"columnHeaderType":"not-filtered","id":"user.name"}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"network","queryMatch":{"displayValue":null,"field":"event.category","displayField":null,"value":"network","operator":":"},"id":"timeline-1-dbab0164-2150-47a1-a66f-75ebafe24d5c","type":"default","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.group.id}","queryMatch":{"displayValue":null,"field":"signal.group.id","displayField":null,"value":"{signal.group.id}","operator":":"},"id":"timeline-1-15b52ead-4956-4ed0-bd12-e137eaf4467e","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.original_event.id}","queryMatch":{"field":"signal.original_event.id","value":"{signal.original_event.id}","operator":":"},"id":"timeline-1-2164774f-6409-4ac4-b73c-907914baf058","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","timelineType":"template","kqlQuery":{"filterQuery":null},"title":"Comprehensive Network Timeline","sort":[{"columnType":"number","sortDirection":"desc","columnId":"@timestamp"}],"templateTimelineId":"300afc76-072d-4261-864d-4149714bf3f1","templateTimelineVersion":2,"created":1618432938016,"createdBy":"1674059739","updated":1618500782465,"updatedBy":"elastic","dateRange":{"start":"2021-04-13T20:40:01.909Z","end":"2021-04-14T20:40:01.909Z"},"indexNames":[],"eqlOptions":{"tiebreakerField":"","size":100,"query":"","eventCategoryField":"event.category","timestampField":"@timestamp"},"savedQueryId":null,"eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} {"savedObjectId":null,"version":null,"columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"kibana.alert.rule.description","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","searchable":null,"example":"user-password-change"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","searchable":null,"example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.pid","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"IP address of the source (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"source.ip","category":"source","type":"ip","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Port of the source.","columnHeaderType":"not-filtered","id":"source.port","category":"source","type":"number","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"IP address of the destination (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"destination.ip","category":"destination","type":"ip","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"destination.port","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","searchable":null,"example":"albert"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"host.name","searchable":null}],"dataProviders":[{"and":[{"enabled":true,"excluded":false,"id":"timeline-1-e37e37c5-a6e7-4338-af30-47bfbc3c0e1e","kqlQuery":"","name":"{destination.ip}","queryMatch":{"displayField":"destination.ip","displayValue":"{destination.ip}","field":"destination.ip","operator":":","value":"{destination.ip}"},"type":"template"}],"enabled":true,"excluded":false,"id":"timeline-1-ec778f01-1802-40f0-9dfb-ed8de1f656cb","kqlQuery":"","name":"{source.ip}","queryMatch":{"displayField":"source.ip","displayValue":"{source.ip}","field":"source.ip","operator":":","value":"{source.ip}"},"type":"template"}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Network Timeline","timelineType":"template","templateTimelineVersion":2,"templateTimelineId":"91832785-286d-4ebe-b884-1a208d111a70","dateRange":{"start":1588255858373,"end":1588256218373},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1594735573866,"createdBy":"Elastic","updated":1611609960850,"updatedBy":"Elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} +{"savedObjectId":null,"version":null,"columns":[{"columnHeaderType":"not-filtered","id":"@timestamp","type":"number"},{"aggregatable":false,"description":"For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.","columnHeaderType":"not-filtered","id":"message","category":"base","type":"string","example":"Hello World"},{"columnHeaderType":"not-filtered","id":"process.code_signature.status"},{"columnHeaderType":"not-filtered","id":"process.code_signature.subject_name"},{"columnHeaderType":"not-filtered","id":"process.command_line"},{"columnHeaderType":"not-filtered","id":"process.executable"},{"columnHeaderType":"not-filtered","id":"process.name"},{"columnHeaderType":"not-filtered","id":"process.parent.name"},{"columnHeaderType":"not-filtered","id":"event.action"},{"columnHeaderType":"not-filtered","id":"host.name"},{"columnHeaderType":"not-filtered","id":"user.name"}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"process","queryMatch":{"displayValue":null,"field":"event.category","displayField":null,"value":"process","operator":":"},"id":"timeline-1-44c387b3-14e2-4493-9702-869311bb7fb1","type":"default","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.group.id}","queryMatch":{"displayValue":null,"field":"signal.group.id","displayField":null,"value":"{signal.group.id}","operator":":"},"id":"timeline-1-690a2939-b1d3-417b-8332-281147d8d0a0","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.original_event.id}","queryMatch":{"field":"signal.original_event.id","value":"{signal.original_event.id}","operator":":"},"id":"timeline-1-8a39602a-78f6-4de2-a3b1-60c1112701c4","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","timelineType":"template","kqlQuery":{"filterQuery":null},"title":"Comprehensive Process Timeline","sort":[{"columnType":"number","sortDirection":"desc","columnId":"@timestamp"}],"templateTimelineId":"e70679c2-6cde-4510-9764-4823df18f7db","templateTimelineVersion":2,"created":1618431743530,"createdBy":"1674059739","updated":1618500593280,"updatedBy":"elastic","dateRange":{"start":"2021-04-13T20:21:59.161Z","end":"2021-04-14T20:21:59.161Z"},"indexNames":[],"eqlOptions":{"tiebreakerField":"","size":100,"query":"","eventCategoryField":"event.category","timestampField":"@timestamp"},"savedQueryId":null,"eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} {"savedObjectId":null,"version":null,"columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"kibana.alert.rule.description","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"event.action","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.name","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The working directory of the process.","columnHeaderType":"not-filtered","id":"process.working_directory","category":"process","type":"string","searchable":null,"example":"/home/alice"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","searchable":null,"example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.pid","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Absolute path to the process executable.","columnHeaderType":"not-filtered","id":"process.parent.executable","category":"process","type":"string","searchable":null,"example":"/usr/bin/ssh"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.parent.args","category":"process","type":"string","searchable":null,"example":"[\"ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"aggregatable":true,"name":null,"description":"Process id.","columnHeaderType":"not-filtered","id":"process.parent.pid","category":"process","type":"number","searchable":null,"example":"4242"},{"indexes":null,"aggregatable":true,"name":null,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","searchable":null,"example":"albert"},{"indexes":null,"aggregatable":true,"name":null,"description":"Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.","columnHeaderType":"not-filtered","id":"host.name","category":"host","type":"string","searchable":null}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"{process.name}","queryMatch":{"displayValue":null,"field":"process.name","displayField":null,"value":"{process.name}","operator":":"},"id":"timeline-1-8622010a-61fb-490d-b162-beac9c36a853","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Process Timeline","timelineType":"template","templateTimelineVersion":2,"templateTimelineId":"76e52245-7519-4251-91ab-262fb1a1728c","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1594735629389,"createdBy":"Elastic","updated":1611609848602,"updatedBy":"Elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} +{"savedObjectId":null,"version":null,"columns":[{"columnHeaderType":"not-filtered","id":"@timestamp","type":"number"},{"aggregatable":false,"description":"For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.","columnHeaderType":"not-filtered","id":"message","category":"base","type":"string","example":"Hello World"},{"aggregatable":true,"description":"Process name. Sometimes called program name or similar.","columnHeaderType":"not-filtered","id":"process.name","category":"process","type":"string","example":"ssh"},{"aggregatable":true,"description":"The action captured by the event. This describes the information in the event. It is more specific than `event.category`. Examples are `group-add`, `process-started`, `file-created`. The value is normally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","example":"user-password-change"},{"aggregatable":true,"description":"Hive-relative path of keys.","columnHeaderType":"not-filtered","id":"registry.key","category":"registry","type":"string","example":"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Image File Execution Options\\winword.exe"},{"aggregatable":true,"description":"Name of the value written.","columnHeaderType":"not-filtered","id":"registry.value","category":"registry","type":"string","example":"Debugger"},{"aggregatable":true,"description":"Full path, including hive, key and value","columnHeaderType":"not-filtered","id":"registry.path","category":"registry","type":"string","example":"HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Image File Execution Options\\winword.exe\\Debugger"},{"columnHeaderType":"not-filtered","id":"host.name"},{"columnHeaderType":"not-filtered","id":"user.name"}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"registry","queryMatch":{"displayValue":null,"field":"event.category","displayField":null,"value":"registry","operator":":"},"id":"timeline-1-f9cfd451-4826-4042-9814-d42e17e4a982","type":"default","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.group.id}","queryMatch":{"displayValue":null,"field":"signal.group.id","displayField":null,"value":"{signal.group.id}","operator":":"},"id":"timeline-1-b940d03a-db9b-4f0f-9e1e-26076a74f482","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.original_event.id}","queryMatch":{"field":"signal.original_event.id","value":"{signal.original_event.id}","operator":":"},"id":"timeline-1-51ebb99b-7723-4451-834a-b5d922684d6e","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","timelineType":"template","kqlQuery":{"filterQuery":null},"title":"Comprehensive Registry Timeline","sort":[{"columnType":"number","sortDirection":"desc","columnId":"@timestamp"}],"templateTimelineId":"3e47ef71-ebfc-4520-975c-cb27fc090799","templateTimelineVersion":2,"created":1618433313346,"createdBy":"1674059739","updated":1618500745983,"updatedBy":"elastic","dateRange":{"start":"2021-04-13T20:48:06.119Z","end":"2021-04-14T20:48:06.120Z"},"indexNames":[],"eqlOptions":{"tiebreakerField":"","size":100,"query":"","eventCategoryField":"event.category","timestampField":"@timestamp"},"savedQueryId":null,"eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} {"savedObjectId":null,"version":null,"columns":[{"columnHeaderType":"not-filtered","id":"@timestamp"},{"columnHeaderType":"not-filtered","id":"kibana.alert.rule.description"},{"aggregatable":true,"description":"The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","example":"user-password-change"},{"aggregatable":true,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"columnHeaderType":"not-filtered","id":"process.pid"},{"aggregatable":true,"description":"IP address of the source (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"source.ip","category":"source","type":"ip"},{"aggregatable":true,"description":"Port of the source.","columnHeaderType":"not-filtered","id":"source.port","category":"source","type":"number"},{"aggregatable":true,"description":"IP address of the destination (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"destination.ip","category":"destination","type":"ip"},{"columnHeaderType":"not-filtered","id":"destination.port"},{"aggregatable":true,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","example":"albert"},{"columnHeaderType":"not-filtered","id":"host.name"}],"dataProviders":[{"excluded":false,"and":[{"excluded":false,"kqlQuery":"","name":"{threat.enrichments.matched.type}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.type","displayField":null,"value":"{threat.enrichments.matched.type}","operator":":"},"id":"timeline-1-ae18ef4b-f690-4122-a24d-e13b6818fba8","type":"template","enabled":true},{"excluded":false,"kqlQuery":"","name":"{threat.enrichments.matched.field}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.field","displayField":null,"value":"{threat.enrichments.matched.field}","operator":":"},"id":"timeline-1-7b4cf27e-6788-4d8e-9188-7687f0eba0f2","type":"template","enabled":true}],"kqlQuery":"","name":"{threat.enrichments.matched.atomic}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.atomic","displayField":null,"value":"{threat.enrichments.matched.atomic}","operator":":"},"id":"timeline-1-7db7d278-a80a-4853-971a-904319c50777","type":"template","enabled":true}],"description":"This Timeline template is for alerts generated by Indicator Match detection rules.","eqlOptions":{"eventCategoryField":"event.category","tiebreakerField":"","timestampField":"@timestamp","query":"","size":100},"eventType":"alert","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"dataViewId": "security-solution","indexNames":[],"title":"Generic Threat Match Timeline","timelineType":"template","templateTimelineVersion":3,"templateTimelineId":"495ad7a7-316e-4544-8a0f-9c098daee76e","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":[{"sortDirection":"desc","columnId":"@timestamp"}],"created":1616696609311,"createdBy":"elastic","updated":1616788372794,"updatedBy":"elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/network_ex.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/network_ex.json new file mode 100644 index 000000000000..b3ecd20cc25e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/network_ex.json @@ -0,0 +1 @@ +{"savedObjectId":null,"version":null,"columns":[{"columnHeaderType":"not-filtered","id":"@timestamp","type":"number"},{"aggregatable":false,"description":"For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.","columnHeaderType":"not-filtered","id":"message","category":"base","type":"string","example":"Hello World"},{"aggregatable":true,"description":"The action captured by the event. This describes the information in the event. It is more specific than `event.category`. Examples are `group-add`, `process-started`, `file-created`. The value is normally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","example":"user-password-change"},{"aggregatable":true,"description":"In the OSI Model this would be the Network Layer. ipv4, ipv6, ipsec, pim, etc The field value must be normalized to lowercase for querying. See the documentation section \"Implementing ECS\".","columnHeaderType":"not-filtered","id":"network.type","category":"network","type":"string","example":"ipv4"},{"aggregatable":true,"description":"Same as network.iana_number, but instead using the Keyword name of the transport layer (udp, tcp, ipv6-icmp, etc.) The field value must be normalized to lowercase for querying. See the documentation section \"Implementing ECS\".","columnHeaderType":"not-filtered","id":"network.transport","category":"network","type":"string","example":"tcp"},{"aggregatable":true,"description":"Direction of the network traffic. Recommended values are: * inbound * outbound * internal * external * unknown When mapping events from a host-based monitoring context, populate this field from the host's point of view. When mapping events from a network or perimeter-based monitoring context, populate this field from the point of view of your network perimeter.","columnHeaderType":"not-filtered","id":"network.direction","category":"network","type":"string","example":"inbound"},{"aggregatable":true,"description":"IP address of the source. Can be one or multiple IPv4 or IPv6 addresses.","columnHeaderType":"not-filtered","id":"source.ip","category":"source","type":"ip"},{"columnHeaderType":"not-filtered","id":"source.port"},{"aggregatable":true,"description":"IP address of the destination. Can be one or multiple IPv4 or IPv6 addresses.","columnHeaderType":"not-filtered","id":"destination.ip","category":"destination","type":"ip"},{"columnHeaderType":"not-filtered","id":"destination.port"},{"aggregatable":true,"description":"Name of the host. It can contain what `hostname` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.","columnHeaderType":"not-filtered","id":"host.name","category":"host","type":"string"},{"columnHeaderType":"not-filtered","id":"user.name"}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"network","queryMatch":{"displayValue":null,"field":"event.category","displayField":null,"value":"network","operator":":"},"id":"timeline-1-dbab0164-2150-47a1-a66f-75ebafe24d5c","type":"default","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.group.id}","queryMatch":{"displayValue":null,"field":"signal.group.id","displayField":null,"value":"{signal.group.id}","operator":":"},"id":"timeline-1-15b52ead-4956-4ed0-bd12-e137eaf4467e","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.original_event.id}","queryMatch":{"field":"signal.original_event.id","value":"{signal.original_event.id}","operator":":"},"id":"timeline-1-2164774f-6409-4ac4-b73c-907914baf058","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","timelineType":"template","kqlQuery":{"filterQuery":null},"title":"Comprehensive Network Timeline","sort":[{"columnType":"number","sortDirection":"desc","columnId":"@timestamp"}],"templateTimelineId":"300afc76-072d-4261-864d-4149714bf3f1","templateTimelineVersion":2,"created":1618432938016,"createdBy":"1674059739","updated":1618500782465,"updatedBy":"elastic","dateRange":{"start":"2021-04-13T20:40:01.909Z","end":"2021-04-14T20:40:01.909Z"},"indexNames":[],"eqlOptions":{"tiebreakerField":"","size":100,"query":"","eventCategoryField":"event.category","timestampField":"@timestamp"},"savedQueryId":null,"eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} \ No newline at end of file diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/process_ex.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/process_ex.json new file mode 100644 index 000000000000..6627d445ec9c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/process_ex.json @@ -0,0 +1 @@ +{"savedObjectId":null,"version":null,"columns":[{"columnHeaderType":"not-filtered","id":"@timestamp","type":"number"},{"aggregatable":false,"description":"For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.","columnHeaderType":"not-filtered","id":"message","category":"base","type":"string","example":"Hello World"},{"columnHeaderType":"not-filtered","id":"process.code_signature.status"},{"columnHeaderType":"not-filtered","id":"process.code_signature.subject_name"},{"columnHeaderType":"not-filtered","id":"process.command_line"},{"columnHeaderType":"not-filtered","id":"process.executable"},{"columnHeaderType":"not-filtered","id":"process.name"},{"columnHeaderType":"not-filtered","id":"process.parent.name"},{"columnHeaderType":"not-filtered","id":"event.action"},{"columnHeaderType":"not-filtered","id":"host.name"},{"columnHeaderType":"not-filtered","id":"user.name"}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"process","queryMatch":{"displayValue":null,"field":"event.category","displayField":null,"value":"process","operator":":"},"id":"timeline-1-44c387b3-14e2-4493-9702-869311bb7fb1","type":"default","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.group.id}","queryMatch":{"displayValue":null,"field":"signal.group.id","displayField":null,"value":"{signal.group.id}","operator":":"},"id":"timeline-1-690a2939-b1d3-417b-8332-281147d8d0a0","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.original_event.id}","queryMatch":{"field":"signal.original_event.id","value":"{signal.original_event.id}","operator":":"},"id":"timeline-1-8a39602a-78f6-4de2-a3b1-60c1112701c4","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","timelineType":"template","kqlQuery":{"filterQuery":null},"title":"Comprehensive Process Timeline","sort":[{"columnType":"number","sortDirection":"desc","columnId":"@timestamp"}],"templateTimelineId":"e70679c2-6cde-4510-9764-4823df18f7db","templateTimelineVersion":2,"created":1618431743530,"createdBy":"1674059739","updated":1618500593280,"updatedBy":"elastic","dateRange":{"start":"2021-04-13T20:21:59.161Z","end":"2021-04-14T20:21:59.161Z"},"indexNames":[],"eqlOptions":{"tiebreakerField":"","size":100,"query":"","eventCategoryField":"event.category","timestampField":"@timestamp"},"savedQueryId":null,"eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} \ No newline at end of file diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/registry_ex.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/registry_ex.json new file mode 100644 index 000000000000..42599a8c9eb5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/registry_ex.json @@ -0,0 +1 @@ +{"savedObjectId":null,"version":null,"columns":[{"columnHeaderType":"not-filtered","id":"@timestamp","type":"number"},{"aggregatable":false,"description":"For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.","columnHeaderType":"not-filtered","id":"message","category":"base","type":"string","example":"Hello World"},{"aggregatable":true,"description":"Process name. Sometimes called program name or similar.","columnHeaderType":"not-filtered","id":"process.name","category":"process","type":"string","example":"ssh"},{"aggregatable":true,"description":"The action captured by the event. This describes the information in the event. It is more specific than `event.category`. Examples are `group-add`, `process-started`, `file-created`. The value is normally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","example":"user-password-change"},{"aggregatable":true,"description":"Hive-relative path of keys.","columnHeaderType":"not-filtered","id":"registry.key","category":"registry","type":"string","example":"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Image File Execution Options\\winword.exe"},{"aggregatable":true,"description":"Name of the value written.","columnHeaderType":"not-filtered","id":"registry.value","category":"registry","type":"string","example":"Debugger"},{"aggregatable":true,"description":"Full path, including hive, key and value","columnHeaderType":"not-filtered","id":"registry.path","category":"registry","type":"string","example":"HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Image File Execution Options\\winword.exe\\Debugger"},{"columnHeaderType":"not-filtered","id":"host.name"},{"columnHeaderType":"not-filtered","id":"user.name"}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"registry","queryMatch":{"displayValue":null,"field":"event.category","displayField":null,"value":"registry","operator":":"},"id":"timeline-1-f9cfd451-4826-4042-9814-d42e17e4a982","type":"default","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.group.id}","queryMatch":{"displayValue":null,"field":"signal.group.id","displayField":null,"value":"{signal.group.id}","operator":":"},"id":"timeline-1-b940d03a-db9b-4f0f-9e1e-26076a74f482","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{signal.original_event.id}","queryMatch":{"field":"signal.original_event.id","value":"{signal.original_event.id}","operator":":"},"id":"timeline-1-51ebb99b-7723-4451-834a-b5d922684d6e","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","timelineType":"template","kqlQuery":{"filterQuery":null},"title":"Comprehensive Registry Timeline","sort":[{"columnType":"number","sortDirection":"desc","columnId":"@timestamp"}],"templateTimelineId":"3e47ef71-ebfc-4520-975c-cb27fc090799","templateTimelineVersion":2,"created":1618433313346,"createdBy":"1674059739","updated":1618500745983,"updatedBy":"elastic","dateRange":{"start":"2021-04-13T20:48:06.119Z","end":"2021-04-14T20:48:06.120Z"},"indexNames":[],"eqlOptions":{"tiebreakerField":"","size":100,"query":"","eventCategoryField":"event.category","timestampField":"@timestamp"},"savedQueryId":null,"eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} \ No newline at end of file diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts index 971be4653e15..e01e3498c2c7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts @@ -41,7 +41,7 @@ describe('threshold_executor', () => { beforeEach(() => { alertServices = alertsMock.createAlertServices(); - alertServices.search.asCurrentUser.search.mockResolvedValue( + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise(sampleEmptyDocSearchResults()) ); logger = loggingSystemMock.createLogger(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/preview/preview_rule_execution_logger.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/preview/preview_rule_execution_logger.ts index afa24e133b58..55ce5913e590 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/preview/preview_rule_execution_logger.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/preview/preview_rule_execution_logger.ts @@ -13,19 +13,11 @@ import { export interface IPreviewRuleExecutionLogger { factory: RuleExecutionLogForExecutorsFactory; - - logged: { - statusChanges: Array; - }; - - clearLogs(): void; } -export const createPreviewRuleExecutionLogger = () => { - let logged: IPreviewRuleExecutionLogger['logged'] = { - statusChanges: [], - }; - +export const createPreviewRuleExecutionLogger = ( + loggedStatusChanges: Array +) => { const factory: RuleExecutionLogForExecutorsFactory = ( savedObjectsClient, eventLogService, @@ -36,17 +28,11 @@ export const createPreviewRuleExecutionLogger = () => { context, logStatusChange(args: StatusChangeArgs): Promise { - logged.statusChanges.push({ ...context, ...args }); + loggedStatusChanges.push({ ...context, ...args }); return Promise.resolve(); }, }; }; - const clearLogs = (): void => { - logged = { - statusChanges: [], - }; - }; - - return { factory, logged, clearLogs }; + return { factory }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index b44c4cbe1661..7d22d58efdd6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -80,7 +80,7 @@ describe('searchAfterAndBulkCreate', () => { }); test('should return success with number of searches less than max signals', async () => { - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3)) ) @@ -99,7 +99,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6)) ) @@ -118,7 +118,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9)) ) @@ -137,7 +137,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(9, 12)) ) @@ -156,7 +156,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( sampleDocSearchResultsNoSortIdNoHits() ) @@ -194,13 +194,13 @@ describe('searchAfterAndBulkCreate', () => { wrapHits, }); expect(success).toEqual(true); - expect(mockService.search.asCurrentUser.search).toHaveBeenCalledTimes(5); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(5); expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); test('should return success with number of searches less than max signals with gap', async () => { - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3)) ) @@ -218,7 +218,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6)) ) @@ -237,7 +237,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9)) ) @@ -256,7 +256,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( sampleDocSearchResultsNoSortIdNoHits() ) @@ -293,13 +293,13 @@ describe('searchAfterAndBulkCreate', () => { wrapHits, }); expect(success).toEqual(true); - expect(mockService.search.asCurrentUser.search).toHaveBeenCalledTimes(4); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(4); expect(createdSignalsCount).toEqual(3); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); test('should return success when no search results are in the allowlist', async () => { - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3)) ) @@ -336,7 +336,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( sampleDocSearchResultsNoSortIdNoHits() ) @@ -373,7 +373,7 @@ describe('searchAfterAndBulkCreate', () => { wrapHits, }); expect(success).toEqual(true); - expect(mockService.search.asCurrentUser.search).toHaveBeenCalledTimes(2); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(2); expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -385,7 +385,7 @@ describe('searchAfterAndBulkCreate', () => { { ...getSearchListItemResponseMock(), value: ['3.3.3.3'] }, ]; listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); - mockService.search.asCurrentUser.search + mockService.scopedClusterClient.asCurrentUser.search .mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ @@ -433,7 +433,7 @@ describe('searchAfterAndBulkCreate', () => { wrapHits, }); expect(success).toEqual(true); - expect(mockService.search.asCurrentUser.search).toHaveBeenCalledTimes(2); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(2); expect(createdSignalsCount).toEqual(0); // should not create any signals because all events were in the allowlist expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -473,7 +473,7 @@ describe('searchAfterAndBulkCreate', () => { }, ], }); - mockService.search.asCurrentUser.search + mockService.scopedClusterClient.asCurrentUser.search .mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId( @@ -511,7 +511,7 @@ describe('searchAfterAndBulkCreate', () => { wrapHits, }); expect(success).toEqual(true); - expect(mockService.search.asCurrentUser.search).toHaveBeenCalledTimes(2); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(2); expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -525,7 +525,7 @@ describe('searchAfterAndBulkCreate', () => { ]; listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3), [ '1.1.1.1', @@ -567,13 +567,13 @@ describe('searchAfterAndBulkCreate', () => { wrapHits, }); expect(success).toEqual(true); - expect(mockService.search.asCurrentUser.search).toHaveBeenCalledTimes(1); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(createdSignalsCount).toEqual(0); // should not create any signals because all events were in the allowlist expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); test('should return success when no sortId present but search results are in the allowlist', async () => { - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3)) ) @@ -641,13 +641,13 @@ describe('searchAfterAndBulkCreate', () => { wrapHits, }); expect(success).toEqual(true); - expect(mockService.search.asCurrentUser.search).toHaveBeenCalledTimes(1); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); test('should return success when no exceptions list provided', async () => { - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3)) ) @@ -684,7 +684,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( sampleDocSearchResultsNoSortIdNoHits() ) @@ -717,7 +717,7 @@ describe('searchAfterAndBulkCreate', () => { wrapHits, }); expect(success).toEqual(true); - expect(mockService.search.asCurrentUser.search).toHaveBeenCalledTimes(2); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(2); expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -735,7 +735,7 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3)) ) @@ -787,7 +787,7 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(sampleEmptyDocSearchResults()) ); listClient.searchListItemByValues = jest.fn(({ value }) => @@ -822,23 +822,9 @@ describe('searchAfterAndBulkCreate', () => { }); test('if returns false when singleSearchAfter throws an exception', async () => { - mockService.search.asCurrentUser.search - .mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ - took: 100, - errors: false, - items: [ - { - create: { - status: 201, - }, - }, - ], - }) - ) - .mockImplementation(() => { - throw Error('Fake Error'); // throws the exception we are testing - }); + mockService.scopedClusterClient.asCurrentUser.search.mockImplementation(() => { + throw Error('Fake Error'); // throws the exception we are testing + }); listClient.searchListItemByValues = jest.fn(({ value }) => Promise.resolve( value.slice(0, 2).map((item) => ({ @@ -903,7 +889,7 @@ describe('searchAfterAndBulkCreate', () => { }, ], }; - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3)) ) @@ -911,7 +897,7 @@ describe('searchAfterAndBulkCreate', () => { mockService.scopedClusterClient.asCurrentUser.bulk.mockResponseOnce(bulkItem); // adds the response with errors we are testing - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6)) ) @@ -930,7 +916,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9)) ) @@ -949,7 +935,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(9, 12)) ) @@ -968,7 +954,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( sampleDocSearchResultsNoSortIdNoHits() ) @@ -994,13 +980,13 @@ describe('searchAfterAndBulkCreate', () => { }); expect(success).toEqual(false); expect(errors).toEqual(['error on creation']); - expect(mockService.search.asCurrentUser.search).toHaveBeenCalledTimes(5); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(5); expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); it('invokes the enrichment callback with signal search results', async () => { - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3)) ) @@ -1019,7 +1005,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6)) ) @@ -1038,7 +1024,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9)) ) @@ -1057,7 +1043,7 @@ describe('searchAfterAndBulkCreate', () => { ], }); - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( sampleDocSearchResultsNoSortIdNoHits() ) @@ -1097,7 +1083,7 @@ describe('searchAfterAndBulkCreate', () => { }) ); expect(success).toEqual(true); - expect(mockService.search.asCurrentUser.search).toHaveBeenCalledTimes(4); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(4); expect(createdSignalsCount).toEqual(3); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index f8270c53b07a..99230627cb6b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -176,7 +176,13 @@ export const searchAfterAndBulkCreate = async ({ buildRuleMessage(`enrichedEvents.hits.hits: ${enrichedEvents.hits.hits.length}`) ); - sendAlertTelemetryEvents(logger, eventsTelemetry, enrichedEvents, buildRuleMessage); + sendAlertTelemetryEvents( + logger, + eventsTelemetry, + enrichedEvents, + createdItems, + buildRuleMessage + ); } if (!hasSortId) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.test.ts index 991378983e1b..36bb90936620 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { selectEvents } from './send_telemetry_events'; +import { selectEvents, enrichEndpointAlertsSignalID } from './send_telemetry_events'; describe('sendAlertTelemetry', () => { it('selectEvents', () => { @@ -33,6 +33,9 @@ describe('sendAlertTelemetry', () => { data_stream: { dataset: 'endpoint.events', }, + event: { + id: 'foo', + }, }, }, { @@ -47,6 +50,9 @@ describe('sendAlertTelemetry', () => { dataset: 'endpoint.alerts', other: 'x', }, + event: { + id: 'bar', + }, }, }, { @@ -58,13 +64,52 @@ describe('sendAlertTelemetry', () => { '@timestamp': 'x', key3: 'hello', data_stream: {}, + event: { + id: 'baz', + }, + }, + }, + { + _index: 'y', + _type: 'y', + _id: 'y', + _score: 0, + _source: { + '@timestamp': 'y', + key3: 'hello', + data_stream: { + dataset: 'endpoint.alerts', + other: 'y', + }, + event: { + id: 'not-in-map', + }, + }, + }, + { + _index: 'z', + _type: 'z', + _id: 'z', + _score: 0, + _source: { + '@timestamp': 'z', + key3: 'no-event-id', + data_stream: { + dataset: 'endpoint.alerts', + other: 'z', + }, }, }, ], }, }; - - const sources = selectEvents(filteredEvents); + const joinMap = new Map([ + ['foo', '1234'], + ['bar', 'abcd'], + ['baz', '4567'], + ]); + const subsetEvents = selectEvents(filteredEvents); + const sources = enrichEndpointAlertsSignalID(subsetEvents, joinMap); expect(sources).toStrictEqual([ { '@timestamp': 'x', @@ -73,6 +118,31 @@ describe('sendAlertTelemetry', () => { dataset: 'endpoint.alerts', other: 'x', }, + event: { + id: 'bar', + }, + signal_id: 'abcd', + }, + { + '@timestamp': 'y', + key3: 'hello', + data_stream: { + dataset: 'endpoint.alerts', + other: 'y', + }, + event: { + id: 'not-in-map', + }, + signal_id: undefined, + }, + { + '@timestamp': 'z', + key3: 'no-event-id', + data_stream: { + dataset: 'endpoint.alerts', + other: 'z', + }, + signal_id: undefined, }, ]); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts index 5904f943183c..fc3aed36939c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts @@ -11,14 +11,17 @@ import { BuildRuleMessage } from './rule_messages'; import { SignalSearchResponse, SignalSource } from './types'; import { Logger } from '../../../../../../../src/core/server'; -export interface SearchResultWithSource { +interface SearchResultSource { _source: SignalSource; } +type CreatedSignalId = string; +type AlertId = string; + export function selectEvents(filteredEvents: SignalSearchResponse): TelemetryEvent[] { // @ts-expect-error @elastic/elasticsearch _source is optional const sources: TelemetryEvent[] = filteredEvents.hits.hits.map(function ( - obj: SearchResultWithSource + obj: SearchResultSource ): TelemetryEvent { return obj._source; }); @@ -27,20 +30,49 @@ export function selectEvents(filteredEvents: SignalSearchResponse): TelemetryEve return sources.filter((obj: TelemetryEvent) => obj.data_stream?.dataset === 'endpoint.alerts'); } +export function enrichEndpointAlertsSignalID( + events: TelemetryEvent[], + signalIdMap: Map +): TelemetryEvent[] { + return events.map(function (obj: TelemetryEvent): TelemetryEvent { + obj.signal_id = undefined; + if (obj?.event?.id !== undefined) { + obj.signal_id = signalIdMap.get(obj.event.id); + } + return obj; + }); +} + export function sendAlertTelemetryEvents( logger: Logger, eventsTelemetry: ITelemetryEventsSender | undefined, filteredEvents: SignalSearchResponse, + createdEvents: SignalSource[], buildRuleMessage: BuildRuleMessage ) { if (eventsTelemetry === undefined) { return; } - const sources = selectEvents(filteredEvents); + let selectedEvents = selectEvents(filteredEvents); + if (selectedEvents.length > 0) { + // Create map of ancenstor_id -> alert_id + let signalIdMap = new Map(); + /* eslint-disable no-param-reassign */ + signalIdMap = createdEvents.reduce((signalMap, obj) => { + const ancestorId = obj['kibana.alert.original_event.id']?.toString(); + const alertId = obj._id?.toString(); + if (ancestorId !== null && ancestorId !== undefined && alertId !== undefined) { + signalMap = signalIdMap.set(ancestorId, alertId); + } + + return signalMap; + }, new Map()); + selectedEvents = enrichEndpointAlertsSignalID(selectedEvents, signalIdMap); + } try { - eventsTelemetry.queueTelemetryEvents(sources); + eventsTelemetry.queueTelemetryEvents(selectedEvents); } catch (exc) { logger.error(buildRuleMessage(`[-] queing telemetry events failed ${exc}`)); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.test.ts index 7132ad06d0ca..d00925af7431 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.test.ts @@ -30,7 +30,7 @@ describe('singleSearchAfter', () => { }); test('if singleSearchAfter works without a given sort id', async () => { - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(sampleDocSearchResultsNoSortId()) ); const { searchResult } = await singleSearchAfter({ @@ -48,7 +48,7 @@ describe('singleSearchAfter', () => { expect(searchResult).toEqual(sampleDocSearchResultsNoSortId()); }); test('if singleSearchAfter returns an empty failure array', async () => { - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(sampleDocSearchResultsNoSortId()) ); const { searchErrors } = await singleSearchAfter({ @@ -83,7 +83,7 @@ describe('singleSearchAfter', () => { }, }, ]; - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 10, timed_out: false, @@ -119,7 +119,7 @@ describe('singleSearchAfter', () => { }); test('if singleSearchAfter works with a given sort id', async () => { const searchAfterSortIds = ['1234567891111']; - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( sampleDocSearchResultsWithSortId() ) @@ -140,7 +140,7 @@ describe('singleSearchAfter', () => { }); test('if singleSearchAfter throws error', async () => { const searchAfterSortIds = ['1234567891111']; - mockService.search.asCurrentUser.search.mockResolvedValueOnce( + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createErrorTransportRequestPromise(new Error('Fake Error')) ); await expect( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts index 3304e2507fe4..62b0097c17e7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts @@ -72,8 +72,9 @@ export const singleSearchAfter = async ({ const start = performance.now(); const { body: nextSearchAfterResult } = - await services.search.asCurrentUser.search( - searchAfterQuery as estypes.SearchRequest + await services.scopedClusterClient.asCurrentUser.search( + searchAfterQuery as estypes.SearchRequest, + { meta: true } ); const end = performance.now(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index b7a06d618162..f49eee858d13 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -27,6 +27,7 @@ import { } from '../../../../common/detection_engine/schemas/common'; import type { ElasticsearchClient, + IUiSettingsClient, Logger, SavedObjectsClientContract, } from '../../../../../../../src/core/server'; @@ -65,6 +66,7 @@ import type { RACAlert, WrappedRACAlert } from '../rule_types/types'; import type { SearchTypes } from '../../../../common/detection_engine/types'; import type { IRuleExecutionLogForExecutors } from '../rule_execution_log'; import { withSecuritySpan } from '../../../utils/with_security_span'; +import { ENABLE_CCS_READ_WARNING_SETTING } from '../../../../common/constants'; interface SortExceptionsReturn { exceptionsWithValueLists: ExceptionListItemSchema[]; @@ -93,12 +95,19 @@ export const hasReadIndexPrivileges = async (args: { logger: Logger; buildRuleMessage: BuildRuleMessage; ruleExecutionLogger: IRuleExecutionLogForExecutors; + uiSettingsClient: IUiSettingsClient; }): Promise => { - const { privileges, logger, buildRuleMessage, ruleExecutionLogger } = args; + const { privileges, logger, buildRuleMessage, ruleExecutionLogger, uiSettingsClient } = args; + + const isCcsPermissionWarningEnabled = await uiSettingsClient.get(ENABLE_CCS_READ_WARNING_SETTING); const indexNames = Object.keys(privileges.index); + const filteredIndexNames = isCcsPermissionWarningEnabled + ? indexNames + : indexNames.filter((indexName) => !indexName.includes(':')); // Cross cluster indices uniquely contain `:` in their name + const [, indexesWithNoReadPrivileges] = partition( - indexNames, + filteredIndexNames, (indexName) => privileges.index[indexName].read ); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts b/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts index 4d9b67af6c31..e18d104b0d73 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts @@ -44,6 +44,7 @@ export const createMockTelemetryReceiver = ( fetchTrustedApplications: jest.fn(), fetchEndpointList: jest.fn(), fetchDetectionRules: jest.fn().mockReturnValue({ body: null }), + fetchEndpointMetadata: jest.fn(), } as unknown as jest.Mocked; }; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts index 452717f1efb4..bd41bc454e87 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts @@ -108,6 +108,7 @@ const allowlistBaseEventFields: AllowlistFields = { export const allowlistEventFields: AllowlistFields = { _id: true, '@timestamp': true, + signal_id: true, agent: true, Endpoint: true, /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts index 6e24cea41b71..91054577656b 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts @@ -80,6 +80,13 @@ export interface ITelemetryReceiver { TransportResult>, unknown> >; + fetchEndpointMetadata( + executeFrom: string, + executeTo: string + ): Promise< + TransportResult>, unknown> + >; + fetchDiagnosticAlerts( executeFrom: string, executeTo: string @@ -270,6 +277,53 @@ export class TelemetryReceiver implements ITelemetryReceiver { return this.esClient.search(query, { meta: true }); } + public async fetchEndpointMetadata(executeFrom: string, executeTo: string) { + if (this.esClient === undefined || this.esClient === null) { + throw Error('elasticsearch client is unavailable: cannot retrieve elastic endpoint metrics'); + } + + const query: SearchRequest = { + expand_wildcards: ['open' as const, 'hidden' as const], + index: `.ds-metrics-endpoint.metadata-*`, + ignore_unavailable: false, + size: 0, // no query results required - only aggregation quantity + body: { + query: { + range: { + '@timestamp': { + gte: executeFrom, + lt: executeTo, + }, + }, + }, + aggs: { + endpoint_metadata: { + terms: { + field: 'agent.id', + size: this.max_records, + }, + aggs: { + latest_metadata: { + top_hits: { + size: 1, + sort: [ + { + '@timestamp': { + order: 'desc' as const, + }, + }, + ], + }, + }, + }, + }, + }, + }, + }; + + return this.esClient.search(query, { meta: true }); + } + public async fetchDiagnosticAlerts(executeFrom: string, executeTo: string) { if (this.esClient === undefined || this.esClient === null) { throw Error('elasticsearch client is unavailable: cannot retrieve diagnostic alerts'); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts index 70852aa3093c..d055f3843d47 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts @@ -35,6 +35,7 @@ describe('TelemetryEventsSender', () => { { event: { kind: 'alert', + id: 'test', }, dns: { question: { @@ -108,6 +109,7 @@ describe('TelemetryEventsSender', () => { { event: { kind: 'alert', + id: 'test', }, dns: { question: { diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts index e9cc36bbff90..c2c318debccd 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts @@ -11,6 +11,8 @@ import type { EndpointMetricsAggregation, EndpointPolicyResponseAggregation, EndpointPolicyResponseDocument, + EndpointMetadataAggregation, + EndpointMetadataDocument, ESClusterInfo, ESLicense, } from '../types'; @@ -188,7 +190,36 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { ) : new Map(); - /** STAGE 4 - Create the telemetry log records + /** STAGE 4 - Fetch Endpoint Agent Metadata + * + * Reads Endpoint Agent metadata out of the `.ds-metrics-endpoint.metadata` data stream + * and buckets them by Endpoint Agent id and sorts by the top hit. The EP agent will + * report its metadata once per day OR every time a policy change has occured. If + * a metadata document(s) exists for an EP agent we map to fleet agent and policy + */ + if (endpointData.endpointMetadata === undefined) { + logger.debug(`no endpoint metadata to report`); + } + + const { body: endpointMetadataResponse } = endpointData.endpointMetadata as unknown as { + body: EndpointMetadataAggregation; + }; + + if (endpointMetadataResponse.aggregations === undefined) { + logger.debug(`no endpoint metadata to report`); + } + + const endpointMetadata = + endpointMetadataResponse.aggregations.endpoint_metadata.buckets.reduce( + (cache, endpointAgentId) => { + const doc = endpointAgentId.latest_metadata.hits.hits[0]; + cache.set(endpointAgentId.key, doc); + return cache; + }, + new Map() + ); + + /** STAGE 5 - Create the telemetry log records * * Iterates through the endpoint metrics documents at STAGE 1 and joins them together * to form the telemetry log that is sent back to Elastic Security developers to @@ -199,6 +230,7 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { const telemetryPayloads = endpointMetrics.map((endpoint) => { let policyConfig = null; let failedPolicy = null; + let endpointMetadataById = null; const fleetAgentId = endpoint.endpoint_metrics.elastic.agent.id; const endpointAgentId = endpoint.endpoint_agent; @@ -212,6 +244,10 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { } } + if (endpointMetadata) { + endpointMetadataById = endpointMetadata.get(endpointAgentId); + } + const { cpu, memory, @@ -242,6 +278,10 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { }, endpoint_meta: { os: endpoint.endpoint_metrics.host.os, + capabilities: + endpointMetadataById !== null && endpointMetadataById !== undefined + ? endpointMetadataById._source.Endpoint.capabilities + : [], }, policy_config: endpointPolicyDetail !== null ? endpointPolicyDetail : {}, policy_response: @@ -265,7 +305,7 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { }); /** - * STAGE 5 - Send the documents + * STAGE 6 - Send the documents * * Send the documents in a batches of maxTelemetryBatch */ @@ -287,11 +327,13 @@ async function fetchEndpointData( executeFrom: string, executeTo: string ) { - const [fleetAgentsResponse, epMetricsResponse, policyResponse] = await Promise.allSettled([ - receiver.fetchFleetAgents(), - receiver.fetchEndpointMetrics(executeFrom, executeTo), - receiver.fetchEndpointPolicyResponses(executeFrom, executeTo), - ]); + const [fleetAgentsResponse, epMetricsResponse, policyResponse, endpointMetadata] = + await Promise.allSettled([ + receiver.fetchFleetAgents(), + receiver.fetchEndpointMetrics(executeFrom, executeTo), + receiver.fetchEndpointPolicyResponses(executeFrom, executeTo), + receiver.fetchEndpointMetadata(executeFrom, executeTo), + ]); return { fleetAgentsResponse: @@ -300,5 +342,6 @@ async function fetchEndpointData( : EmptyFleetAgentResponse, endpointMetrics: epMetricsResponse.status === 'fulfilled' ? epMetricsResponse.value : undefined, epPolicyResponse: policyResponse.status === 'fulfilled' ? policyResponse.value : undefined, + endpointMetadata: endpointMetadata.status === 'fulfilled' ? endpointMetadata.value : undefined, }; } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts index 35b701552b6b..c1c65a428f62 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts @@ -58,6 +58,10 @@ export interface TelemetryEvent { }; }; license?: ESLicense; + event?: { + id?: string; + kind?: string; + }; } // EP Policy Response @@ -239,6 +243,44 @@ interface EndpointMetricOS { full: string; } +// EP Metadata + +export interface EndpointMetadataAggregation { + hits: { + total: { value: number }; + }; + aggregations: { + endpoint_metadata: { + buckets: Array<{ key: string; doc_count: number; latest_metadata: EndpointMetadataHits }>; + }; + }; +} + +interface EndpointMetadataHits { + hits: { + total: { value: number }; + hits: EndpointMetadataDocument[]; + }; +} + +export interface EndpointMetadataDocument { + _source: { + '@timestamp': string; + agent: { + id: string; + version: string; + }; + Endpoint: { + capabilities: string[]; + }; + elastic: { + agent: { + id: string; + }; + }; + }; +} + // List HTTP Types export const GetTrustedAppsRequestSchema = { diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts index 3f53e59c348a..293066a3a182 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts @@ -9,6 +9,7 @@ import { KibanaRequest } from 'kibana/server'; import { schema } from '@kbn/config-schema'; import { isEqual } from 'lodash/fp'; import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; import { ExceptionItemLikeOptions } from '../types'; import { getEndpointAuthzInitialState } from '../../../../common/endpoint/service/authz'; @@ -16,7 +17,6 @@ import { getPolicyIdsFromArtifact, isArtifactByPolicy, } from '../../../../common/endpoint/service/artifacts'; -import { OperatingSystem } from '../../../../common/endpoint/types'; import { EndpointArtifactExceptionValidationError } from './errors'; import type { FeatureKeys } from '../../../endpoint/services/feature_usage/service'; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts index 28bc408165d4..645458478386 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts @@ -7,6 +7,7 @@ import { schema } from '@kbn/config-schema'; import { ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { BaseValidator, BasicEndpointExceptionDataSchema } from './base_validator'; import { EndpointArtifactExceptionValidationError } from './errors'; import { ExceptionItemLikeOptions } from '../types'; @@ -16,7 +17,6 @@ import { UpdateExceptionListItemOptions, } from '../../../../../lists/server'; import { isValidIPv4OrCIDR } from '../../../../common/endpoint/utils/is_valid_ip'; -import { OperatingSystem } from '../../../../common/endpoint/types'; function validateIp(value: string) { if (!isValidIPv4OrCIDR(value)) { diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts index d7ca2c0f0567..fc69153f0b21 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts @@ -8,17 +8,14 @@ import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '@kbn/securitysolution-list-constants'; import { schema, TypeOf } from '@kbn/config-schema'; import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { OperatingSystem, TrustedAppEntryTypes } from '@kbn/securitysolution-utils'; import { BaseValidator } from './base_validator'; import { ExceptionItemLikeOptions } from '../types'; import { CreateExceptionListItemOptions, UpdateExceptionListItemOptions, } from '../../../../../lists/server'; -import { - ConditionEntry, - OperatingSystem, - TrustedAppEntryTypes, -} from '../../../../common/endpoint/types'; +import { ConditionEntry } from '../../../../common/endpoint/types'; import { getDuplicateFields, isValidHash, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.test.ts index e1c380fc3fca..905a63ba48a1 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.test.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.test.ts @@ -9,7 +9,7 @@ import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants import { HostsRequestOptions } from '../../../../../../common/search_strategy/security_solution'; import * as buildQuery from './query.all_hosts.dsl'; -import * as buildRiskQuery from '../risk_score/query.hosts_risk.dsl'; +import * as buildRiskQuery from '../../risk_score/all/query.risk_score.dsl'; import { allHosts } from '.'; import { mockOptions, @@ -104,7 +104,7 @@ describe('allHosts search strategy', () => { }); test('should query host risk only for hostNames in the current page', async () => { - const buildHostsRiskQuery = jest.spyOn(buildRiskQuery, 'buildHostsRiskScoreQuery'); + const buildHostsRiskQuery = jest.spyOn(buildRiskQuery, 'buildRiskScoreQuery'); const mockedDeps = mockDeps(); // @ts-expect-error incomplete type mockedDeps.esClient.asCurrentUser.search.mockResponse({ hits: { hits: [] } }); @@ -121,7 +121,7 @@ describe('allHosts search strategy', () => { expect(buildHostsRiskQuery).toHaveBeenCalledWith({ defaultIndex: ['ml_host_risk_score_latest_test-space'], - hostNames: [hostName], + filterQuery: { terms: { 'host.name': [hostName] } }, }); }); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts index 460dfdbb5cb9..d936ffdefb6c 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts @@ -14,11 +14,14 @@ import { HostsStrategyResponse, HostsQueries, HostsRequestOptions, - HostsRiskScore, HostsEdges, } from '../../../../../../common/search_strategy/security_solution/hosts'; -import { getHostRiskIndex } from '../../../../../../common/search_strategy'; +import { + getHostRiskIndex, + buildHostNamesFilter, + HostsRiskScore, +} from '../../../../../../common/search_strategy'; import { inspectStringifyObject } from '../../../../../utils/build_query'; import { SecuritySolutionFactory } from '../../types'; @@ -26,10 +29,9 @@ import { buildHostsQuery } from './query.all_hosts.dsl'; import { formatHostEdgesData, HOSTS_FIELDS } from './helpers'; import { IScopedClusterClient } from '../../../../../../../../../src/core/server'; -import { buildHostsRiskScoreQuery } from '../risk_score/query.hosts_risk.dsl'; - import { buildHostsQueryEntities } from './query.all_hosts_entities.dsl'; import { EndpointAppContext } from '../../../../../endpoint/types'; +import { buildRiskScoreQuery } from '../../risk_score/all/query.risk_score.dsl'; export const allHosts: SecuritySolutionFactory = { buildDsl: (options: HostsRequestOptions) => { @@ -117,9 +119,9 @@ async function getHostRiskData( ) { try { const hostRiskResponse = await esClient.asCurrentUser.search( - buildHostsRiskScoreQuery({ + buildRiskScoreQuery({ defaultIndex: [getHostRiskIndex(spaceId)], - hostNames, + filterQuery: buildHostNamesFilter(hostNames), }) ); return hostRiskResponse; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.test.ts index 36add5af0ed2..fda1e1c166ce 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.test.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.test.ts @@ -11,14 +11,12 @@ import { allHosts } from './all'; import { hostDetails } from './details'; import { hostOverview } from './overview'; -import { riskScore } from './risk_score'; import { firstOrLastSeenHost } from './last_first_seen'; import { uncommonProcesses } from './uncommon_processes'; import { authentications, authenticationsEntities } from './authentications'; import { hostsKpiAuthentications, hostsKpiAuthenticationsEntities } from './kpi/authentications'; import { hostsKpiHosts, hostsKpiHostsEntities } from './kpi/hosts'; import { hostsKpiUniqueIps, hostsKpiUniqueIpsEntities } from './kpi/unique_ips'; -import { hostsKpiRiskyHosts } from './kpi/risky_hosts'; jest.mock('./all'); jest.mock('./details'); @@ -29,7 +27,6 @@ jest.mock('./authentications'); jest.mock('./kpi/authentications'); jest.mock('./kpi/hosts'); jest.mock('./kpi/unique_ips'); -jest.mock('./risk_score'); describe('hostsFactory', () => { test('should include correct apis', () => { @@ -41,12 +38,10 @@ describe('hostsFactory', () => { [HostsQueries.uncommonProcesses]: uncommonProcesses, [HostsQueries.authentications]: authentications, [HostsQueries.authenticationsEntities]: authenticationsEntities, - [HostsQueries.hostsRiskScore]: riskScore, [HostsKpiQueries.kpiAuthentications]: hostsKpiAuthentications, [HostsKpiQueries.kpiAuthenticationsEntities]: hostsKpiAuthenticationsEntities, [HostsKpiQueries.kpiHosts]: hostsKpiHosts, [HostsKpiQueries.kpiHostsEntities]: hostsKpiHostsEntities, - [HostsKpiQueries.kpiRiskyHosts]: hostsKpiRiskyHosts, [HostsKpiQueries.kpiUniqueIpsEntities]: hostsKpiUniqueIpsEntities, [HostsKpiQueries.kpiUniqueIps]: hostsKpiUniqueIps, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts index f182280667e1..cd95a38ec309 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts @@ -21,8 +21,6 @@ import { authentications, authenticationsEntities } from './authentications'; import { hostsKpiAuthentications, hostsKpiAuthenticationsEntities } from './kpi/authentications'; import { hostsKpiHosts, hostsKpiHostsEntities } from './kpi/hosts'; import { hostsKpiUniqueIps, hostsKpiUniqueIpsEntities } from './kpi/unique_ips'; -import { riskScore } from './risk_score'; -import { hostsKpiRiskyHosts } from './kpi/risky_hosts'; export const hostsFactory: Record< HostsQueries | HostsKpiQueries, @@ -36,12 +34,10 @@ export const hostsFactory: Record< [HostsQueries.uncommonProcesses]: uncommonProcesses, [HostsQueries.authentications]: authentications, [HostsQueries.authenticationsEntities]: authenticationsEntities, - [HostsQueries.hostsRiskScore]: riskScore, [HostsKpiQueries.kpiAuthentications]: hostsKpiAuthentications, [HostsKpiQueries.kpiAuthenticationsEntities]: hostsKpiAuthenticationsEntities, [HostsKpiQueries.kpiHosts]: hostsKpiHosts, [HostsKpiQueries.kpiHostsEntities]: hostsKpiHostsEntities, - [HostsKpiQueries.kpiRiskyHosts]: hostsKpiRiskyHosts, [HostsKpiQueries.kpiUniqueIps]: hostsKpiUniqueIps, [HostsKpiQueries.kpiUniqueIpsEntities]: hostsKpiUniqueIpsEntities, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/index.ts deleted file mode 100644 index f1dde9b66b69..000000000000 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/index.ts +++ /dev/null @@ -1,53 +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 { getOr } from 'lodash/fp'; - -import type { IEsSearchResponse } from '../../../../../../../../../../src/plugins/data/common'; -import type { HostsKpiQueries } from '../../../../../../../common/search_strategy'; - -import type { - HostsKpiRiskyHostsRequestOptions, - HostsKpiRiskyHostsStrategyResponse, - HostRiskSeverity, -} from '../../../../../../../common/search_strategy/security_solution/hosts/kpi/risky_hosts'; -import { inspectStringifyObject } from '../../../../../../utils/build_query'; -import type { SecuritySolutionFactory } from '../../../types'; -import { buildHostsKpiRiskyHostsQuery } from './query.hosts_kpi_risky_hosts.dsl'; - -interface AggBucket { - key: HostRiskSeverity; - doc_count: number; -} - -export const hostsKpiRiskyHosts: SecuritySolutionFactory = { - buildDsl: (options: HostsKpiRiskyHostsRequestOptions) => buildHostsKpiRiskyHostsQuery(options), - parse: async ( - options: HostsKpiRiskyHostsRequestOptions, - response: IEsSearchResponse - ): Promise => { - const inspect = { - dsl: [inspectStringifyObject(buildHostsKpiRiskyHostsQuery(options))], - }; - - const riskBuckets = getOr([], 'aggregations.risk.buckets', response.rawResponse); - - const riskyHosts: Record = riskBuckets.reduce( - (cummulative: Record, bucket: AggBucket) => ({ - ...cummulative, - [bucket.key]: getOr(0, 'unique_hosts.value', bucket), - }), - {} - ); - - return { - ...response, - riskyHosts, - inspect, - }; - }, -}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts index 5b54c63408d1..22e887b7a628 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts @@ -12,13 +12,17 @@ import { hostsFactory } from './hosts'; import { matrixHistogramFactory } from './matrix_histogram'; import { networkFactory } from './network'; import { ctiFactoryTypes } from './cti'; +import { riskScoreFactory } from './risk_score'; +import { usersFactory } from './users'; export const securitySolutionFactory: Record< FactoryQueryTypes, SecuritySolutionFactory > = { ...hostsFactory, + ...usersFactory, ...matrixHistogramFactory, ...networkFactory, ...ctiFactoryTypes, + ...riskScoreFactory, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/all/index.ts similarity index 69% rename from x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/index.ts rename to x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/all/index.ts index 3ebe7404363f..c1a1cd93dd61 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/all/index.ts @@ -7,30 +7,30 @@ import { SecuritySolutionFactory } from '../../types'; import { - HostsRiskScoreRequestOptions, - HostsQueries, - HostsRiskScoreStrategyResponse, + RiskScoreRequestOptions, + RiskScoreStrategyResponse, + RiskQueries, } from '../../../../../../common/search_strategy'; import type { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; import { inspectStringifyObject } from '../../../../../utils/build_query'; -import { buildHostsRiskScoreQuery } from './query.hosts_risk.dsl'; +import { buildRiskScoreQuery } from './query.risk_score.dsl'; import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; import { getTotalCount } from '../../cti/event_enrichment/helpers'; -export const riskScore: SecuritySolutionFactory = { - buildDsl: (options: HostsRiskScoreRequestOptions) => { +export const riskScore: SecuritySolutionFactory = { + buildDsl: (options: RiskScoreRequestOptions) => { if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); } - return buildHostsRiskScoreQuery(options); + return buildRiskScoreQuery(options); }, parse: async ( - options: HostsRiskScoreRequestOptions, + options: RiskScoreRequestOptions, response: IEsSearchResponse - ): Promise => { + ): Promise => { const inspect = { - dsl: [inspectStringifyObject(buildHostsRiskScoreQuery(options))], + dsl: [inspectStringifyObject(buildRiskScoreQuery(options))], }; const totalCount = getTotalCount(response.rawResponse.hits.total); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/query.hosts_risk.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/all/query.risk_score.dsl.ts similarity index 75% rename from x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/query.hosts_risk.dsl.ts rename to x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/all/query.risk_score.dsl.ts index 1a4b0df91024..b6e17f653254 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/query.hosts_risk.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/all/query.risk_score.dsl.ts @@ -8,18 +8,16 @@ import { Sort } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { Direction, - HostsRiskScoreRequestOptions, - HostRiskScoreFields, - HostRiskScoreSortField, + RiskScoreRequestOptions, + RiskScoreFields, + RiskScoreSortField, } from '../../../../../../common/search_strategy'; - import { createQueryFilterClauses } from '../../../../../utils/build_query'; export const QUERY_SIZE = 10; -export const buildHostsRiskScoreQuery = ({ +export const buildRiskScoreQuery = ({ timerange, - hostNames, filterQuery, defaultIndex, pagination: { querySize, cursorStart } = { @@ -27,7 +25,7 @@ export const buildHostsRiskScoreQuery = ({ cursorStart: 0, }, sort, -}: HostsRiskScoreRequestOptions) => { +}: RiskScoreRequestOptions) => { const filter = createQueryFilterClauses(filterQuery); if (timerange) { @@ -42,10 +40,6 @@ export const buildHostsRiskScoreQuery = ({ }); } - if (hostNames) { - filter.push({ terms: { 'host.name': hostNames } }); - } - const dslQuery = { index: defaultIndex, allow_no_indices: false, @@ -62,7 +56,7 @@ export const buildHostsRiskScoreQuery = ({ return dslQuery; }; -const getQueryOrder = (sort?: HostRiskScoreSortField): Sort => { +const getQueryOrder = (sort?: RiskScoreSortField): Sort => { if (!sort) { return [ { @@ -71,8 +65,8 @@ const getQueryOrder = (sort?: HostRiskScoreSortField): Sort => { ]; } - if (sort.field === HostRiskScoreFields.risk) { - return [{ [HostRiskScoreFields.riskScore]: sort.direction }]; + if (sort.field === RiskScoreFields.risk) { + return [{ [RiskScoreFields.riskScore]: sort.direction }]; } return [{ [sort.field]: sort.direction }]; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/index.ts new file mode 100644 index 000000000000..73f022c4e5c4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FactoryQueryTypes, RiskQueries } from '../../../../../common/search_strategy'; +import { SecuritySolutionFactory } from '../types'; +import { riskScore } from './all'; +import { kpiRiskScore } from './kpi'; + +export const riskScoreFactory: Record> = { + [RiskQueries.riskScore]: riskScore, + [RiskQueries.kpiRiskScore]: kpiRiskScore, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/kpi/__mocks__/index.ts similarity index 66% rename from x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/__mocks__/index.ts rename to x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/kpi/__mocks__/index.ts index c0522d61e380..89723ab180fc 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/kpi/__mocks__/index.ts @@ -6,11 +6,11 @@ */ import { - HostsKpiQueries, - HostsKpiRiskyHostsRequestOptions, -} from '../../../../../../../../common/search_strategy'; + KpiRiskScoreRequestOptions, + RiskQueries, +} from '../../../../../../../common/search_strategy'; -export const mockOptions: HostsKpiRiskyHostsRequestOptions = { +export const mockOptions: KpiRiskScoreRequestOptions = { defaultIndex: [ 'apm-*-transaction*', 'traces-apm*', @@ -21,8 +21,8 @@ export const mockOptions: HostsKpiRiskyHostsRequestOptions = { 'packetbeat-*', 'winlogbeat-*', ], - factoryQueryType: HostsKpiQueries.kpiRiskyHosts, + factoryQueryType: RiskQueries.kpiRiskScore, filterQuery: '{"bool":{"must":[],"filter":[{"match_all":{}},{"bool":{"filter":[{"bool":{"should":[{"exists":{"field":"host.name"}}],"minimum_should_match":1}}]}}],"should":[],"must_not":[]}}', - timerange: { interval: '12h', from: '2020-09-07T09:47:28.606Z', to: '2020-09-08T09:47:28.606Z' }, + aggBy: 'host.name', }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/kpi/index.test.ts similarity index 50% rename from x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/index.test.ts rename to x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/kpi/index.test.ts index cbfe63d86ea7..f14fadb54e69 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/index.test.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/kpi/index.test.ts @@ -5,17 +5,18 @@ * 2.0. */ -import { hostsKpiRiskyHosts } from '.'; -import * as buildQuery from './query.hosts_kpi_risky_hosts.dsl'; +import { kpiRiskScore } from '.'; +import * as buildQuery from './query.kpi_risk_score.dsl'; + import { mockOptions } from './__mocks__'; -describe('buildHostsKpiRiskyHostsQuery search strategy', () => { - const buildHostsKpiRiskyHostsQuery = jest.spyOn(buildQuery, 'buildHostsKpiRiskyHostsQuery'); +describe('buildKpiRiskScoreQuery search strategy', () => { + const buildKpiRiskScoreQuery = jest.spyOn(buildQuery, 'buildKpiRiskScoreQuery'); describe('buildDsl', () => { test('should build dsl query', () => { - hostsKpiRiskyHosts.buildDsl(mockOptions); - expect(buildHostsKpiRiskyHostsQuery).toHaveBeenCalledWith(mockOptions); + kpiRiskScore.buildDsl(mockOptions); + expect(buildKpiRiskScoreQuery).toHaveBeenCalledWith(mockOptions); }); }); }); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/kpi/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/kpi/index.ts new file mode 100644 index 000000000000..088e16a5edbd --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/kpi/index.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 { getOr } from 'lodash/fp'; + +import type { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import type { + KpiRiskScoreRequestOptions, + KpiRiskScoreStrategyResponse, + RiskQueries, +} from '../../../../../../common/search_strategy'; +import { RiskSeverity } from '../../../../../../common/search_strategy'; + +import { inspectStringifyObject } from '../../../../../utils/build_query'; +import type { SecuritySolutionFactory } from '../../types'; +import { buildKpiRiskScoreQuery } from './query.kpi_risk_score.dsl'; + +interface AggBucket { + key: RiskSeverity; + doc_count: number; +} + +export const kpiRiskScore: SecuritySolutionFactory = { + buildDsl: (options: KpiRiskScoreRequestOptions) => buildKpiRiskScoreQuery(options), + parse: async ( + options: KpiRiskScoreRequestOptions, + response: IEsSearchResponse + ): Promise => { + const inspect = { + dsl: [inspectStringifyObject(buildKpiRiskScoreQuery(options))], + }; + + const riskBuckets = getOr([], 'aggregations.risk.buckets', response.rawResponse); + + const result: Record = riskBuckets.reduce( + (cummulative: Record, bucket: AggBucket) => ({ + ...cummulative, + [bucket.key]: getOr(0, 'unique_entries.value', bucket), + }), + {} + ); + + return { + ...response, + kpiRiskScore: result, + inspect, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/query.hosts_kpi_risky_hosts.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/kpi/query.kpi_risk_score.dsl.ts similarity index 68% rename from x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/query.hosts_kpi_risky_hosts.dsl.ts rename to x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/kpi/query.kpi_risk_score.dsl.ts index 83c3d3b2d34f..259f64af2592 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/query.hosts_kpi_risky_hosts.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/risk_score/kpi/query.kpi_risk_score.dsl.ts @@ -5,13 +5,14 @@ * 2.0. */ -import type { HostsKpiRiskyHostsRequestOptions } from '../../../../../../../common/search_strategy/security_solution/hosts/kpi/risky_hosts'; -import { createQueryFilterClauses } from '../../../../../../utils/build_query'; +import { KpiRiskScoreRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; -export const buildHostsKpiRiskyHostsQuery = ({ +export const buildKpiRiskScoreQuery = ({ defaultIndex, filterQuery, -}: HostsKpiRiskyHostsRequestOptions) => { + aggBy, +}: KpiRiskScoreRequestOptions) => { const filter = [...createQueryFilterClauses(filterQuery)]; const dslQuery = { @@ -26,9 +27,9 @@ export const buildHostsKpiRiskyHostsQuery = ({ field: 'risk.keyword', }, aggs: { - unique_hosts: { + unique_entries: { cardinality: { - field: 'host.name', + field: aggBy, }, }, }, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/__mocks__/index.ts new file mode 100644 index 000000000000..b5eb1b7c3b6e --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/__mocks__/index.ts @@ -0,0 +1,218 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IEsSearchResponse } from '../../../../../../../../../../src/plugins/data/common'; +import { UsersQueries } from '../../../../../../../common/search_strategy/security_solution/users'; + +import { UserDetailsRequestOptions } from '../../../../../../../common/search_strategy/security_solution/users/details'; + +export const mockOptions: UserDetailsRequestOptions = { + defaultIndex: ['test_indices*'], + docValueFields: [ + { + field: '@timestamp', + format: 'date_time', + }, + ], + factoryQueryType: UsersQueries.details, + filterQuery: + '{"bool":{"must":[],"filter":[{"match_all":{}},{"match_phrase":{"user.name":{"query":"test_user"}}}],"should":[],"must_not":[]}}', + timerange: { + interval: '12h', + from: '2020-09-02T15:17:13.678Z', + to: '2020-09-03T15:17:13.678Z', + }, + params: {}, + userName: 'bastion00.siem.estc.dev', +} as UserDetailsRequestOptions; + +export const mockSearchStrategyResponse: IEsSearchResponse = { + rawResponse: { + took: 1, + timed_out: false, + _shards: { + total: 2, + successful: 2, + skipped: 1, + failed: 0, + }, + hits: { + max_score: null, + hits: [], + }, + aggregations: { + host_ip: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 665, + buckets: [ + { + key: '11.245.5.152', + doc_count: 133, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + { + key: '149.175.90.37', + doc_count: 133, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + { + key: '16.3.124.77', + doc_count: 133, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + { + key: '161.120.111.159', + doc_count: 133, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + { + key: '179.124.88.33', + doc_count: 133, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + { + key: '203.248.113.63', + doc_count: 133, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + { + key: '205.6.104.210', + doc_count: 133, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + { + key: '209.233.30.0', + doc_count: 133, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + { + key: '238.165.244.247', + doc_count: 133, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + { + key: '29.73.212.149', + doc_count: 133, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + ], + }, + first_seen: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + last_seen: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + user_domain: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'NT AUTHORITY', + doc_count: 1905, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + ], + }, + user_id: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'S-1-5-18', + doc_count: 1995, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + ], + }, + user_name: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'SYSTEM', + doc_count: 1995, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + ], + }, + host_os_family: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Windows', + doc_count: 1995, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + ], + }, + host_os_name: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Windows', + doc_count: 1995, + timestamp: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + }, + ], + }, + }, + }, + isPartial: false, + isRunning: false, + total: 2, + loaded: 2, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000000..c37a799b8946 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/__snapshots__/index.test.tsx.snap @@ -0,0 +1,373 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`userDetails search strategy parse should parse data correctly 1`] = ` +Object { + "inspect": Object { + "dsl": Array [ + "{ + \\"allow_no_indices\\": true, + \\"index\\": [ + \\"test_indices*\\" + ], + \\"ignore_unavailable\\": true, + \\"track_total_hits\\": false, + \\"body\\": { + \\"aggregations\\": { + \\"first_seen\\": { + \\"min\\": { + \\"field\\": \\"@timestamp\\" + } + }, + \\"last_seen\\": { + \\"max\\": { + \\"field\\": \\"@timestamp\\" + } + }, + \\"user_id\\": { + \\"terms\\": { + \\"field\\": \\"user.id\\", + \\"size\\": 10, + \\"order\\": { + \\"timestamp\\": \\"desc\\" + } + }, + \\"aggs\\": { + \\"timestamp\\": { + \\"max\\": { + \\"field\\": \\"@timestamp\\" + } + } + } + }, + \\"user_domain\\": { + \\"terms\\": { + \\"field\\": \\"user.domain\\", + \\"size\\": 10, + \\"order\\": { + \\"timestamp\\": \\"desc\\" + } + }, + \\"aggs\\": { + \\"timestamp\\": { + \\"max\\": { + \\"field\\": \\"@timestamp\\" + } + } + } + }, + \\"user_name\\": { + \\"terms\\": { + \\"field\\": \\"user.name\\", + \\"size\\": 10, + \\"order\\": { + \\"timestamp\\": \\"desc\\" + } + }, + \\"aggs\\": { + \\"timestamp\\": { + \\"max\\": { + \\"field\\": \\"@timestamp\\" + } + } + } + }, + \\"host_os_name\\": { + \\"terms\\": { + \\"field\\": \\"host.os.name\\", + \\"size\\": 10, + \\"order\\": { + \\"timestamp\\": \\"desc\\" + } + }, + \\"aggs\\": { + \\"timestamp\\": { + \\"max\\": { + \\"field\\": \\"@timestamp\\" + } + } + } + }, + \\"host_ip\\": { + \\"terms\\": { + \\"script\\": { + \\"source\\": \\"doc['host.ip']\\", + \\"lang\\": \\"painless\\" + }, + \\"size\\": 10, + \\"order\\": { + \\"timestamp\\": \\"desc\\" + } + }, + \\"aggs\\": { + \\"timestamp\\": { + \\"max\\": { + \\"field\\": \\"@timestamp\\" + } + } + } + }, + \\"host_os_family\\": { + \\"terms\\": { + \\"field\\": \\"host.os.family\\", + \\"size\\": 10, + \\"order\\": { + \\"timestamp\\": \\"desc\\" + } + }, + \\"aggs\\": { + \\"timestamp\\": { + \\"max\\": { + \\"field\\": \\"@timestamp\\" + } + } + } + } + }, + \\"query\\": { + \\"bool\\": { + \\"filter\\": [ + { + \\"term\\": { + \\"user.name\\": \\"bastion00.siem.estc.dev\\" + } + }, + { + \\"range\\": { + \\"@timestamp\\": { + \\"format\\": \\"strict_date_optional_time\\", + \\"gte\\": \\"2020-09-02T15:17:13.678Z\\", + \\"lte\\": \\"2020-09-03T15:17:13.678Z\\" + } + } + } + ] + } + }, + \\"size\\": 0 + } +}", + ], + }, + "isPartial": false, + "isRunning": false, + "loaded": 2, + "rawResponse": Object { + "_shards": Object { + "failed": 0, + "skipped": 1, + "successful": 2, + "total": 2, + }, + "aggregations": Object { + "first_seen": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + "host_ip": Object { + "buckets": Array [ + Object { + "doc_count": 133, + "key": "11.245.5.152", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + Object { + "doc_count": 133, + "key": "149.175.90.37", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + Object { + "doc_count": 133, + "key": "16.3.124.77", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + Object { + "doc_count": 133, + "key": "161.120.111.159", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + Object { + "doc_count": 133, + "key": "179.124.88.33", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + Object { + "doc_count": 133, + "key": "203.248.113.63", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + Object { + "doc_count": 133, + "key": "205.6.104.210", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + Object { + "doc_count": 133, + "key": "209.233.30.0", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + Object { + "doc_count": 133, + "key": "238.165.244.247", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + Object { + "doc_count": 133, + "key": "29.73.212.149", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + ], + "doc_count_error_upper_bound": 0, + "sum_other_doc_count": 665, + }, + "host_os_family": Object { + "buckets": Array [ + Object { + "doc_count": 1995, + "key": "Windows", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + ], + "doc_count_error_upper_bound": 0, + "sum_other_doc_count": 0, + }, + "host_os_name": Object { + "buckets": Array [ + Object { + "doc_count": 1995, + "key": "Windows", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + ], + "doc_count_error_upper_bound": 0, + "sum_other_doc_count": 0, + }, + "last_seen": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + "user_domain": Object { + "buckets": Array [ + Object { + "doc_count": 1905, + "key": "NT AUTHORITY", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + ], + "doc_count_error_upper_bound": 0, + "sum_other_doc_count": 0, + }, + "user_id": Object { + "buckets": Array [ + Object { + "doc_count": 1995, + "key": "S-1-5-18", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + ], + "doc_count_error_upper_bound": 0, + "sum_other_doc_count": 0, + }, + "user_name": Object { + "buckets": Array [ + Object { + "doc_count": 1995, + "key": "SYSTEM", + "timestamp": Object { + "value": 1644837532000, + "value_as_string": "2022-02-14T11:18:52.000Z", + }, + }, + ], + "doc_count_error_upper_bound": 0, + "sum_other_doc_count": 0, + }, + }, + "hits": Object { + "hits": Array [], + "max_score": null, + }, + "timed_out": false, + "took": 1, + }, + "total": 2, + "userDetails": Object { + "firstSeen": "2022-02-14T11:18:52.000Z", + "host": Object { + "ip": Array [ + "11.245.5.152", + "149.175.90.37", + "16.3.124.77", + "161.120.111.159", + "179.124.88.33", + "203.248.113.63", + "205.6.104.210", + "209.233.30.0", + "238.165.244.247", + "29.73.212.149", + ], + "os": Object { + "family": Array [ + "Windows", + ], + "name": Array [ + "Windows", + ], + }, + }, + "lastSeen": "2022-02-14T11:18:52.000Z", + "user": Object { + "domain": Array [ + "NT AUTHORITY", + ], + "id": Array [ + "S-1-5-18", + ], + "name": Array [ + "SYSTEM", + ], + }, + }, +} +`; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/__snapshots__/query.user_details.dsl.test.ts.snap b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/__snapshots__/query.user_details.dsl.test.ts.snap new file mode 100644 index 000000000000..08b234cb8370 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/__snapshots__/query.user_details.dsl.test.ts.snap @@ -0,0 +1,146 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`buildUserDetailsQuery build query from options correctly 1`] = ` +Object { + "allow_no_indices": true, + "body": Object { + "aggregations": Object { + "first_seen": Object { + "min": Object { + "field": "@timestamp", + }, + }, + "host_ip": Object { + "aggs": Object { + "timestamp": Object { + "max": Object { + "field": "@timestamp", + }, + }, + }, + "terms": Object { + "order": Object { + "timestamp": "desc", + }, + "script": Object { + "lang": "painless", + "source": "doc['host.ip']", + }, + "size": 10, + }, + }, + "host_os_family": Object { + "aggs": Object { + "timestamp": Object { + "max": Object { + "field": "@timestamp", + }, + }, + }, + "terms": Object { + "field": "host.os.family", + "order": Object { + "timestamp": "desc", + }, + "size": 10, + }, + }, + "host_os_name": Object { + "aggs": Object { + "timestamp": Object { + "max": Object { + "field": "@timestamp", + }, + }, + }, + "terms": Object { + "field": "host.os.name", + "order": Object { + "timestamp": "desc", + }, + "size": 10, + }, + }, + "last_seen": Object { + "max": Object { + "field": "@timestamp", + }, + }, + "user_domain": Object { + "aggs": Object { + "timestamp": Object { + "max": Object { + "field": "@timestamp", + }, + }, + }, + "terms": Object { + "field": "user.domain", + "order": Object { + "timestamp": "desc", + }, + "size": 10, + }, + }, + "user_id": Object { + "aggs": Object { + "timestamp": Object { + "max": Object { + "field": "@timestamp", + }, + }, + }, + "terms": Object { + "field": "user.id", + "order": Object { + "timestamp": "desc", + }, + "size": 10, + }, + }, + "user_name": Object { + "aggs": Object { + "timestamp": Object { + "max": Object { + "field": "@timestamp", + }, + }, + }, + "terms": Object { + "field": "user.name", + "order": Object { + "timestamp": "desc", + }, + "size": 10, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "user.name": "bastion00.siem.estc.dev", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "strict_date_optional_time", + "gte": "2020-09-02T15:17:13.678Z", + "lte": "2020-09-03T15:17:13.678Z", + }, + }, + }, + ], + }, + }, + "size": 0, + }, + "ignore_unavailable": true, + "index": Array [ + "test_indices*", + ], + "track_total_hits": false, +} +`; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/helper.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/helper.test.ts new file mode 100644 index 000000000000..520e24d188d6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/helper.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { UserAggEsItem } from '../../../../../../common/search_strategy/security_solution/users/common'; +import { fieldNameToAggField, formatUserItem } from './helpers'; + +describe('helpers', () => { + it('it convert field name to aggregation field name', () => { + expect(fieldNameToAggField('host.os.family')).toBe('host_os_family'); + }); + + it('it formats UserItem', () => { + const userId = '123'; + const aggregations: UserAggEsItem = { + user_id: { + buckets: [ + { + key: userId, + doc_count: 1, + }, + ], + }, + first_seen: { value_as_string: '123456789' }, + last_seen: { value_as_string: '987654321' }, + }; + + expect(formatUserItem(aggregations)).toEqual({ + firstSeen: '123456789', + lastSeen: '987654321', + user: { id: [userId] }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/helpers.ts new file mode 100644 index 000000000000..72b876014e2c --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/helpers.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { set } from '@elastic/safer-lodash-set/fp'; +import { get, has } from 'lodash/fp'; +import { + UserAggEsItem, + UserBuckets, + UserItem, +} from '../../../../../../common/search_strategy/security_solution/users/common'; + +export const USER_FIELDS = [ + 'user.id', + 'user.domain', + 'user.name', + 'host.os.name', + 'host.ip', + 'host.os.family', +]; + +export const fieldNameToAggField = (fieldName: string) => fieldName.replace(/\./g, '_'); + +export const formatUserItem = (aggregations: UserAggEsItem): UserItem => { + const firstLastSeen = { + firstSeen: get('first_seen.value_as_string', aggregations), + lastSeen: get('last_seen.value_as_string', aggregations), + }; + + return USER_FIELDS.reduce((flattenedFields, fieldName) => { + const aggField = fieldNameToAggField(fieldName); + + if (has(aggField, aggregations)) { + const data: UserBuckets = get(aggField, aggregations); + const fieldValue = data.buckets.map((obj) => obj.key); + + return set(fieldName, fieldValue, flattenedFields); + } + return flattenedFields; + }, firstLastSeen); +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/index.test.tsx b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/index.test.tsx new file mode 100644 index 000000000000..bd23c37ff24f --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/index.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as buildQuery from './query.user_details.dsl'; +import { userDetails } from '.'; +import { mockOptions, mockSearchStrategyResponse } from './__mocks__'; + +describe('userDetails search strategy', () => { + const buildHostDetailsQuery = jest.spyOn(buildQuery, 'buildUserDetailsQuery'); + + afterEach(() => { + buildHostDetailsQuery.mockClear(); + }); + + describe('buildDsl', () => { + test('should build dsl query', () => { + userDetails.buildDsl(mockOptions); + expect(buildHostDetailsQuery).toHaveBeenCalledWith(mockOptions); + }); + }); + + describe('parse', () => { + test('should parse data correctly', async () => { + const result = await userDetails.parse(mockOptions, mockSearchStrategyResponse); + expect(result).toMatchSnapshot(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/index.ts new file mode 100644 index 000000000000..1ab721cb19a9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/index.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; + +import { inspectStringifyObject } from '../../../../../utils/build_query'; +import { SecuritySolutionFactory } from '../../types'; +import { buildUserDetailsQuery } from './query.user_details.dsl'; + +import { UsersQueries } from '../../../../../../common/search_strategy/security_solution/users'; +import { + UserDetailsRequestOptions, + UserDetailsStrategyResponse, +} from '../../../../../../common/search_strategy/security_solution/users/details'; +import { formatUserItem } from './helpers'; + +export const userDetails: SecuritySolutionFactory = { + buildDsl: (options: UserDetailsRequestOptions) => buildUserDetailsQuery(options), + parse: async ( + options: UserDetailsRequestOptions, + response: IEsSearchResponse + ): Promise => { + const aggregations = response.rawResponse.aggregations; + + const inspect = { + dsl: [inspectStringifyObject(buildUserDetailsQuery(options))], + }; + + if (aggregations == null) { + return { ...response, inspect, userDetails: {} }; + } + + return { + ...response, + inspect, + userDetails: formatUserItem(aggregations), + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/query.user_details.dsl.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/query.user_details.dsl.test.ts new file mode 100644 index 000000000000..c0dde76c7714 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/query.user_details.dsl.test.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 { buildUserDetailsQuery } from './query.user_details.dsl'; +import { mockOptions } from './__mocks__/'; + +describe('buildUserDetailsQuery', () => { + test('build query from options correctly', () => { + expect(buildUserDetailsQuery(mockOptions)).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/query.user_details.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/query.user_details.dsl.ts new file mode 100644 index 000000000000..4b91391b698d --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/details/query.user_details.dsl.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ISearchRequestParams } from '../../../../../../../../../src/plugins/data/common'; +import { UserDetailsRequestOptions } from '../../../../../../common/search_strategy/security_solution/users/details'; +import { buildFieldsTermAggregation } from '../../hosts/details/helpers'; +import { USER_FIELDS } from './helpers'; + +export const buildUserDetailsQuery = ({ + userName, + defaultIndex, + timerange: { from, to }, +}: UserDetailsRequestOptions): ISearchRequestParams => { + const filter = [ + { term: { 'user.name': userName } }, + { + range: { + '@timestamp': { + format: 'strict_date_optional_time', + gte: from, + lte: to, + }, + }, + }, + ]; + + const dslQuery = { + allow_no_indices: true, + index: defaultIndex, + ignore_unavailable: true, + track_total_hits: false, + body: { + aggregations: { + first_seen: { + min: { + field: '@timestamp', + }, + }, + last_seen: { + max: { + field: '@timestamp', + }, + }, + ...buildFieldsTermAggregation(USER_FIELDS), + }, + query: { bool: { filter } }, + size: 0, + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/index.ts new file mode 100644 index 000000000000..211d9a71f8a5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FactoryQueryTypes } from '../../../../../common/search_strategy/security_solution'; +import { UsersQueries } from '../../../../../common/search_strategy/security_solution/users'; + +import { SecuritySolutionFactory } from '../types'; +import { userDetails } from './details'; + +export const usersFactory: Record> = { + [UsersQueries.details]: userDetails, +}; diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index 6a90477540eb..e3457a25aa7c 100644 --- a/x-pack/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/plugins/security_solution/server/ui_settings.ts @@ -32,6 +32,7 @@ import { IP_REPUTATION_LINKS_SETTING_DEFAULT, NEWS_FEED_URL_SETTING, NEWS_FEED_URL_SETTING_DEFAULT, + ENABLE_CCS_READ_WARNING_SETTING, } from '../common/constants'; import { transformConfigSchema } from '../common/transforms/types'; import { ExperimentalFeatures } from '../common/experimental_features'; @@ -218,6 +219,19 @@ export const initUiSettings = ( }) ), }, + [ENABLE_CCS_READ_WARNING_SETTING]: { + name: i18n.translate('xpack.securitySolution.uiSettings.enableCcsReadWarningLabel', { + defaultMessage: 'CCS Rule Privileges Warning', + }), + value: true, + description: i18n.translate('xpack.securitySolution.uiSettings.enableCcsWarningDescription', { + defaultMessage: '

    Enables privilege check warnings in rules for CCS indices

    ', + }), + type: 'boolean', + category: [APP_ID], + requiresPageReload: false, + schema: schema.boolean(), + }, // TODO: Remove this check once the experimental flag is removed ...(experimentalFeatures.metricsEntitiesEnabled ? { diff --git a/x-pack/plugins/session_view/.eslintrc.json b/x-pack/plugins/session_view/.eslintrc.json new file mode 100644 index 000000000000..2aab6c2d9093 --- /dev/null +++ b/x-pack/plugins/session_view/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "@typescript-eslint/consistent-type-definitions": 0 + } +} diff --git a/x-pack/plugins/session_view/README.md b/x-pack/plugins/session_view/README.md new file mode 100644 index 000000000000..384be8bcc292 --- /dev/null +++ b/x-pack/plugins/session_view/README.md @@ -0,0 +1,36 @@ +# Session View + +Session View is meant to provide a visualization into what is going on in a particular Linux environment where the agent is running. It looks likes a terminal emulator; however, it is a tool for introspecting process activity and understanding user and service behaviour in your Linux servers and infrastructure. It is a time-ordered series of process executions displayed in a tree over time. + +It provides an audit trail of: + +- Interactive processes being entered by a user into the terminal - User Input +- Processes and services which do not have a controlling tty (ie are not interactive) +- Output which is generated as a result of process activity - Output +- Nested sessions inside the entry session - Nested session (Note: For now nested sessions will display as they did at Cmd with no special handling for TMUX) +- Full telemetry about the process initiated event. This will include the information specified in the Linux logical event model +- Who executed the session or process, even if the user changes. + +## Development + +## Tests + +### Unit tests + +From kibana path in your terminal go to this plugin root: + +```bash +cd x-pack/plugins/session_view +``` + +Then run jest with: + +```bash +yarn test:jest +``` + +Or if running from kibana root, you can specify the `-i` to specify the path: + +```bash +yarn test:jest -i x-pack/plugins/session_view/ +``` diff --git a/x-pack/plugins/session_view/common/constants.ts b/x-pack/plugins/session_view/common/constants.ts new file mode 100644 index 000000000000..5baf690dc44a --- /dev/null +++ b/x-pack/plugins/session_view/common/constants.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. + */ + +export const PROCESS_EVENTS_ROUTE = '/internal/session_view/process_events_route'; +export const SESSION_ENTRY_LEADERS_ROUTE = '/internal/session_view/session_entry_leaders_route'; +export const PROCESS_EVENTS_INDEX = 'logs-endpoint.events.process-default'; +export const ALERTS_INDEX = '.siem-signals-default'; +export const ENTRY_SESSION_ENTITY_ID_PROPERTY = 'process.entry_leader.entity_id'; + +// We fetch a large number of events per page to mitigate a few design caveats in session viewer +// 1. Due to the hierarchical nature of the data (e.g we are rendering a time ordered pid tree) there are common scenarios where there +// are few top level processes, but many nested children. For example, a build script is run on a remote host via ssh. If for example our page +// size is 10 and the build script has 500 nested children, the user would see a load more button that they could continously click without seeing +// anychange since the next 10 events would be for processes nested under a top level process that might not be expanded. That being said, it's quite +// possible there are build scripts with many thousands of events, in which case this initial large page will have the same issue. A technique used +// in previous incarnations of session view included auto expanding the node which is receiving the new page of events so as to not confuse the user. +// We may need to include this trick as part of this implementation as well. +// 2. The plain text search that comes with Session view is currently limited in that it only searches through data that has been loaded into the browser. +// The large page size allows the user to get a broader set of results per page. That being said, this feature is kind of flawed since sessions could be many thousands +// if not 100s of thousands of events, and to be required to page through these sessions to find more search matches is not a great experience. Future iterations of the +// search functionality will instead use a separate ES backend search to avoid this. +// 3. Fewer round trips to the backend! +export const PROCESS_EVENTS_PER_PAGE = 1000; diff --git a/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts new file mode 100644 index 000000000000..b7b0bbb91b5e --- /dev/null +++ b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts @@ -0,0 +1,951 @@ +/* + * Copyright 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 { + Process, + ProcessEvent, + ProcessEventsPage, + ProcessFields, + EventAction, + EventKind, + ProcessMap, +} from '../../types/process_tree'; + +export const mockEvents: ProcessEvent[] = [ + { + '@timestamp': '2021-11-23T15:25:04.210Z', + user: { + name: '', + id: '1000', + }, + process: { + pid: 3535, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: false, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + name: '', + args_count: 0, + args: [], + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + }, + event: { + action: EventAction.fork, + category: 'process', + kind: EventKind.event, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + }, + { + '@timestamp': '2021-11-23T15:25:04.218Z', + user: { + name: '', + id: '1000', + }, + process: { + pid: 3535, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3727', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.218Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.218Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.218Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 3535, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3727', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.218Z', + }, + event: { + action: EventAction.exec, + category: 'process', + kind: EventKind.event, + }, + }, + { + '@timestamp': '2021-11-23T15:25:05.202Z', + user: { + name: '', + id: '1000', + }, + process: { + pid: 3535, + exit_code: 137, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3728', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:05.202Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:05.202Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:05.202Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 3535, + exit_code: 137, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3728', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + start: '2021-11-23T15:25:05.202Z', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + }, + event: { + action: EventAction.end, + category: 'process', + kind: EventKind.event, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + }, +] as ProcessEvent[]; + +export const mockAlerts: ProcessEvent[] = [ + { + kibana: { + alert: { + rule: { + category: 'Custom Query Rule', + consumer: 'siem', + name: 'cmd test alert', + uuid: '709d3890-4c71-11ec-8c67-01ccde9db9bf', + enabled: true, + description: 'cmd test alert', + risk_score: 21, + severity: 'low', + query: "process.executable: '/usr/bin/vi'", + }, + status: 'active', + workflow_status: 'open', + reason: 'process event created low alert cmd test alert.', + original_time: new Date('2021-11-23T15:25:04.218Z'), + original_event: { + action: 'exec', + }, + uuid: '6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38', + }, + }, + '@timestamp': '2021-11-23T15:26:34.859Z', + user: { + name: 'vagrant', + id: '1000', + }, + process: { + pid: 3535, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726', + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + event: { + action: EventAction.exec, + category: 'process', + kind: EventKind.signal, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + }, + { + kibana: { + alert: { + rule: { + category: 'Custom Query Rule', + consumer: 'siem', + name: 'cmd test alert', + uuid: '709d3890-4c71-11ec-8c67-01ccde9db9bf', + enabled: true, + description: 'cmd test alert', + risk_score: 21, + severity: 'low', + query: "process.executable: '/usr/bin/vi'", + }, + status: 'active', + workflow_status: 'open', + reason: 'process event created low alert cmd test alert.', + original_time: new Date('2021-11-23T15:25:05.202Z'), + original_event: { + action: 'exit', + }, + uuid: '2873463965b70d37ab9b2b3a90ac5a03b88e76e94ad33568285cadcefc38ed75', + }, + }, + '@timestamp': '2021-11-23T15:26:34.860Z', + user: { + name: 'vagrant', + id: '1000', + }, + process: { + pid: 3535, + exit_code: 137, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726', + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + event: { + action: EventAction.end, + category: 'process', + kind: EventKind.signal, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + }, +]; + +export const mockData: ProcessEventsPage[] = [ + { + events: mockEvents, + cursor: '2021-11-23T15:25:04.210Z', + }, +]; + +export const childProcessMock: Process = { + id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bd', + events: [], + children: [], + autoExpand: false, + searchMatched: null, + parent: undefined, + orphans: [], + addEvent: (_) => undefined, + clearSearch: () => undefined, + getChildren: () => [], + hasOutput: () => false, + hasAlerts: () => false, + getAlerts: () => [], + hasExec: () => false, + getOutput: () => '', + getDetails: () => + ({ + '@timestamp': '2021-11-23T15:25:05.210Z', + event: { + kind: EventKind.event, + category: 'process', + action: EventAction.exec, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + id: '1', + name: 'vagrant', + }, + process: { + args: ['ls', '-l'], + args_count: 2, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bd', + executable: '/bin/ls', + interactive: true, + name: 'ls', + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:05.210Z', + pid: 2, + parent: { + args: ['bash'], + args_count: 1, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + executable: '/bin/bash', + interactive: false, + name: '', + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + pid: 1, + user: { + id: '1', + name: 'vagrant', + }, + }, + session_leader: {} as ProcessFields, + entry_leader: {} as ProcessFields, + group_leader: {} as ProcessFields, + }, + } as ProcessEvent), + isUserEntered: () => false, + getMaxAlertLevel: () => null, +}; + +export const processMock: Process = { + id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726', + events: [], + children: [], + autoExpand: false, + searchMatched: null, + parent: undefined, + orphans: [], + addEvent: (_) => undefined, + clearSearch: () => undefined, + getChildren: () => [], + hasOutput: () => false, + hasAlerts: () => false, + getAlerts: () => [], + hasExec: () => false, + getOutput: () => '', + getDetails: () => + ({ + '@timestamp': '2021-11-23T15:25:04.210Z', + event: { + kind: EventKind.event, + category: 'process', + action: EventAction.exec, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + id: '1', + name: 'vagrant', + }, + process: { + args: ['bash'], + args_count: 1, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726', + executable: '/bin/bash', + exit_code: 137, + interactive: false, + name: '', + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + pid: 1, + parent: {} as ProcessFields, + session_leader: {} as ProcessFields, + entry_leader: {} as ProcessFields, + group_leader: {} as ProcessFields, + }, + } as ProcessEvent), + isUserEntered: () => false, + getMaxAlertLevel: () => null, +}; + +export const sessionViewBasicProcessMock: Process = { + ...processMock, + events: mockEvents, + hasExec: () => true, + isUserEntered: () => true, +}; + +export const sessionViewAlertProcessMock: Process = { + ...processMock, + events: [...mockEvents, ...mockAlerts], + hasAlerts: () => true, + getAlerts: () => mockEvents, + hasExec: () => true, + isUserEntered: () => true, +}; + +export const mockProcessMap = mockEvents.reduce( + (processMap, event) => { + processMap[event.process.entity_id] = { + id: event.process.entity_id, + events: [event], + children: [], + parent: undefined, + autoExpand: false, + searchMatched: null, + orphans: [], + addEvent: (_) => undefined, + clearSearch: () => undefined, + getChildren: () => [], + hasOutput: () => false, + hasAlerts: () => false, + getAlerts: () => [], + hasExec: () => false, + getOutput: () => '', + getDetails: () => event, + isUserEntered: () => false, + getMaxAlertLevel: () => null, + }; + return processMap; + }, + { + [sessionViewBasicProcessMock.id]: sessionViewBasicProcessMock, + } as ProcessMap +); diff --git a/x-pack/plugins/session_view/common/mocks/responses/session_view_process_events.mock.ts b/x-pack/plugins/session_view/common/mocks/responses/session_view_process_events.mock.ts new file mode 100644 index 000000000000..47849f859ba9 --- /dev/null +++ b/x-pack/plugins/session_view/common/mocks/responses/session_view_process_events.mock.ts @@ -0,0 +1,1236 @@ +/* + * Copyright 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 { ProcessEventResults } from '../../types/process_tree'; + +export const sessionViewProcessEventsMock: ProcessEventResults = { + events: [ + { + _index: 'cmd', + _id: 'FMUGTX0BGGlsPv9flMF7', + _score: null, + _source: { + '@timestamp': '2021-11-23T13:40:16.528Z', + event: { + kind: 'event', + category: 'process', + action: 'fork', + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + Ext: { + variant: 'CentOS', + }, + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + // To keep backwards compat and avoid data duplication. We keep user/group info for top level process at the top level + id: '0', // the effective user aka euid + name: 'root', + real: { + // ruid + id: '2', + name: 'kg', + }, + saved: { + // suid + id: '2', + name: 'kg', + }, + }, + group: { + id: '1', // the effective group aka egid + name: 'groupA', + real: { + // rgid + id: '1', + name: 'groupA', + }, + saved: { + // sgid + id: '1', + name: 'groupA', + }, + }, + process: { + entity_id: '4321', + args: ['/bin/sshd'], + args_count: 1, + command_line: 'sshd', + executable: '/bin/sshd', + name: 'sshd', + interactive: false, + working_directory: '/', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + parent: { + entity_id: '4322', + args: ['/bin/sshd'], + args_count: 1, + command_line: 'sshd', + executable: '/bin/sshd', + name: 'sshd', + interactive: true, + working_directory: '/', + pid: 2, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '2', + name: 'kg', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + group_leader: { + entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + pid: 1234, // this directly replaces parent.pgid + start: '2021-10-14T08:05:34.853Z', + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the session_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the entry_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + entry_meta: { + type: 'sshd', + source: { + ip: '10.132.0.50', + geo: { + city_name: 'Vancouver', + continent_code: 'NA', + continent_name: 'North America', + country_iso_code: 'CA', + country_name: 'Canada', + location: { + lon: -73.61483, + lat: 45.505918, + }, + postal_code: 'V9J1E3', + region_iso_code: 'BC', + region_name: 'British Columbia', + timezone: 'America/Los_Angeles', + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + { + descriptor: 1, + type: 'pipe', + pipe: { + inode: '6183207', + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + }, + sort: [1637674816528], + }, + { + _index: 'cmd', + _id: 'FsUGTX0BGGlsPv9flMGF', + _score: null, + _source: { + '@timestamp': '2021-11-23T13:40:16.541Z', + event: { + kind: 'event', + category: 'process', + action: 'exec', + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + Ext: { + variant: 'CentOS', + }, + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + id: '2', + name: 'kg', + real: { + id: '2', + name: 'kg', + }, + saved: { + id: '2', + name: 'kg', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + process: { + entity_id: '4321', + args: ['/bin/bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + previous: [{ args: ['/bin/sshd'], args_count: 1, executable: '/bin/sshd' }], + parent: { + entity_id: '4322', + args: ['/bin/sshd'], + args_count: 1, + command_line: 'sshd', + executable: '/bin/sshd', + name: 'sshd', + interactive: true, + working_directory: '/', + pid: 2, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + group_leader: { + entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + pid: 1234, // this directly replaces parent.pgid + start: '2021-10-14T08:05:34.853Z', + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the session_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the entry_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + entry_meta: { + type: 'sshd', + source: { + ip: '10.132.0.50', + geo: { + city_name: 'Vancouver', + continent_code: 'NA', + continent_name: 'North America', + country_iso_code: 'CA', + country_name: 'Canada', + location: { + lon: -73.61483, + lat: 45.505918, + }, + postal_code: 'V9J1E3', + region_iso_code: 'BC', + region_name: 'British Columbia', + timezone: 'America/Los_Angeles', + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + { + descriptor: 1, + type: 'pipe', + pipe: { + inode: '6183207', + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + }, + sort: [1637674816541], + }, + { + _index: 'cmd', + _id: 'H8UGTX0BGGlsPv9fp8F_', + _score: null, + _source: { + '@timestamp': '2021-11-23T13:40:21.392Z', + event: { + kind: 'event', + category: 'process', + action: 'end', + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + Ext: { + variant: 'CentOS', + }, + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + id: '2', + name: 'kg', + real: { + id: '2', + name: 'kg', + }, + saved: { + id: '2', + name: 'kg', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + process: { + entity_id: '4321', + args: ['/bin/bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + end: '2021-10-14T10:05:34.853Z', + exit_code: 137, + previous: [{ args: ['/bin/sshd'], args_count: 1, executable: '/bin/sshd' }], + parent: { + entity_id: '4322', + args: ['/bin/sshd'], + args_count: 1, + command_line: 'sshd', + executable: '/bin/sshd', + name: 'sshd', + interactive: true, + working_directory: '/', + pid: 2, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + group_leader: { + entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + pid: 1234, // this directly replaces parent.pgid + start: '2021-10-14T08:05:34.853Z', + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the session_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the entry_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + entry_meta: { + type: 'sshd', + source: { + ip: '10.132.0.50', + geo: { + city_name: 'Vancouver', + continent_code: 'NA', + continent_name: 'North America', + country_iso_code: 'CA', + country_name: 'Canada', + location: { + lon: -73.61483, + lat: 45.505918, + }, + postal_code: 'V9J1E3', + region_iso_code: 'BC', + region_name: 'British Columbia', + timezone: 'America/Los_Angeles', + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + { + descriptor: 1, + type: 'pipe', + pipe: { + inode: '6183207', + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + }, + sort: [1637674821392], + }, + ], +}; diff --git a/x-pack/plugins/session_view/common/types/process_tree/index.ts b/x-pack/plugins/session_view/common/types/process_tree/index.ts new file mode 100644 index 000000000000..746c1b209366 --- /dev/null +++ b/x-pack/plugins/session_view/common/types/process_tree/index.ts @@ -0,0 +1,163 @@ +/* + * Copyright 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 const enum EventKind { + event = 'event', + signal = 'signal', +} + +export const enum EventAction { + fork = 'fork', + exec = 'exec', + end = 'end', + output = 'output', +} + +export interface User { + id: string; + name: string; +} + +export interface ProcessEventResults { + events: any[]; +} + +export type EntryMetaType = + | 'init' + | 'sshd' + | 'ssm' + | 'kubelet' + | 'teleport' + | 'terminal' + | 'console'; + +export interface EntryMeta { + type: EntryMetaType; + source: { + ip: string; + }; +} + +export interface Teletype { + descriptor: number; + type: string; + char_device: { + major: number; + minor: number; + }; +} + +export interface ProcessFields { + entity_id: string; + args: string[]; + args_count: number; + command_line: string; + executable: string; + name: string; + interactive: boolean; + working_directory: string; + pid: number; + start: string; + end?: string; + user: User; + exit_code?: number; + entry_meta?: EntryMeta; + tty: Teletype; +} + +export interface ProcessSelf extends Omit { + parent: ProcessFields; + session_leader: ProcessFields; + entry_leader: ProcessFields; + group_leader: ProcessFields; +} + +export interface ProcessEventHost { + architecture: string; + hostname: string; + id: string; + ip: string; + mac: string; + name: string; + os: { + family: string; + full: string; + kernel: string; + name: string; + platform: string; + version: string; + }; +} + +export interface ProcessEventAlertRule { + category: string; + consumer: string; + description: string; + enabled: boolean; + name: string; + query: string; + risk_score: number; + severity: string; + uuid: string; +} + +export interface ProcessEventAlert { + uuid: string; + reason: string; + workflow_status: string; + status: string; + original_time: Date; + original_event: { + action: string; + }; + rule: ProcessEventAlertRule; +} + +export interface ProcessEvent { + '@timestamp': string; + event: { + kind: EventKind; + category: string; + action: EventAction; + }; + user: User; + host: ProcessEventHost; + process: ProcessSelf; + kibana?: { + alert: ProcessEventAlert; + }; +} + +export interface ProcessEventsPage { + events: ProcessEvent[]; + cursor: string; +} + +export interface Process { + id: string; // the process entity_id + events: ProcessEvent[]; + children: Process[]; + orphans: Process[]; // currently, orphans are rendered inline with the entry session leaders children + parent: Process | undefined; + autoExpand: boolean; + searchMatched: string | null; // either false, or set to searchQuery + addEvent(event: ProcessEvent): void; + clearSearch(): void; + hasOutput(): boolean; + hasAlerts(): boolean; + getAlerts(): ProcessEvent[]; + hasExec(): boolean; + getOutput(): string; + getDetails(): ProcessEvent; + isUserEntered(): boolean; + getMaxAlertLevel(): number | null; + getChildren(verboseMode: boolean): Process[]; +} + +export type ProcessMap = { + [key: string]: Process; +}; diff --git a/x-pack/plugins/session_view/common/utils/expand_dotted_object.test.ts b/x-pack/plugins/session_view/common/utils/expand_dotted_object.test.ts new file mode 100644 index 000000000000..a4a4845e759e --- /dev/null +++ b/x-pack/plugins/session_view/common/utils/expand_dotted_object.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { expandDottedObject } from './expand_dotted_object'; + +const testFlattenedObj = { + 'flattened.property.a': 'valueA', + 'flattened.property.b': 'valueB', + regularProp: { + nestedProp: 'nestedValue', + }, + 'nested.array': [ + { + arrayProp: 'arrayValue', + }, + ], + emptyArray: [], +}; +describe('expandDottedObject(obj)', () => { + it('retrieves values from flattened keys', () => { + const expanded: any = expandDottedObject(testFlattenedObj); + + expect(expanded.flattened.property.a).toEqual('valueA'); + expect(expanded.flattened.property.b).toEqual('valueB'); + }); + it('retrieves values from nested keys', () => { + const expanded: any = expandDottedObject(testFlattenedObj); + + expect(Array.isArray(expanded.nested.array)).toBeTruthy(); + expect(expanded.nested.array[0].arrayProp).toEqual('arrayValue'); + }); + it("doesn't break regular value access", () => { + const expanded: any = expandDottedObject(testFlattenedObj); + + expect(expanded.regularProp.nestedProp).toEqual('nestedValue'); + }); +}); diff --git a/x-pack/plugins/session_view/common/utils/expand_dotted_object.ts b/x-pack/plugins/session_view/common/utils/expand_dotted_object.ts new file mode 100644 index 000000000000..69a9cb8236cb --- /dev/null +++ b/x-pack/plugins/session_view/common/utils/expand_dotted_object.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { merge } from '@kbn/std'; + +const expandDottedField = (dottedFieldName: string, val: unknown): object => { + const parts = dottedFieldName.split('.'); + if (parts.length === 1) { + return { [parts[0]]: val }; + } else { + return { [parts[0]]: expandDottedField(parts.slice(1).join('.'), val) }; + } +}; + +/* + * Expands an object with "dotted" fields to a nested object with unflattened fields. + * + * Example: + * expandDottedObject({ + * "kibana.alert.depth": 1, + * "kibana.alert.ancestors": [{ + * id: "d5e8eb51-a6a0-456d-8a15-4b79bfec3d71", + * type: "event", + * index: "signal_index", + * depth: 0, + * }], + * }) + * + * => { + * kibana: { + * alert: { + * ancestors: [ + * id: "d5e8eb51-a6a0-456d-8a15-4b79bfec3d71", + * type: "event", + * index: "signal_index", + * depth: 0, + * ], + * depth: 1, + * }, + * }, + * } + */ +export const expandDottedObject = (dottedObj: object) => { + return Object.entries(dottedObj).reduce( + (acc, [key, val]) => merge(acc, expandDottedField(key, val)), + {} + ); +}; diff --git a/x-pack/plugins/session_view/common/utils/sort_processes.test.ts b/x-pack/plugins/session_view/common/utils/sort_processes.test.ts new file mode 100644 index 000000000000..b1db5381954d --- /dev/null +++ b/x-pack/plugins/session_view/common/utils/sort_processes.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 { sortProcesses } from './sort_processes'; +import { mockProcessMap } from '../mocks/constants/session_view_process.mock'; + +describe('sortProcesses(a, b)', () => { + it('sorts processes in ascending order by start time', () => { + const processes = Object.values(mockProcessMap); + + // shuffle some things to ensure all sort lines are hit + const c = processes[0]; + processes[0] = processes[processes.length - 1]; + processes[processes.length - 1] = c; + + processes.sort(sortProcesses); + + for (let i = 0; i < processes.length - 1; i++) { + const current = processes[i]; + const next = processes[i + 1]; + expect( + new Date(next.getDetails().process.start) >= new Date(current.getDetails().process.start) + ).toBeTruthy(); + } + }); +}); diff --git a/x-pack/plugins/session_view/common/utils/sort_processes.ts b/x-pack/plugins/session_view/common/utils/sort_processes.ts new file mode 100644 index 000000000000..a0a42590e457 --- /dev/null +++ b/x-pack/plugins/session_view/common/utils/sort_processes.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 { Process } from '../types/process_tree'; + +export const sortProcesses = (a: Process, b: Process) => { + const eventAStartTime = new Date(a.getDetails().process.start); + const eventBStartTime = new Date(b.getDetails().process.start); + + if (eventAStartTime < eventBStartTime) { + return -1; + } + + if (eventAStartTime > eventBStartTime) { + return 1; + } + + return 0; +}; diff --git a/x-pack/plugins/session_view/jest.config.js b/x-pack/plugins/session_view/jest.config.js new file mode 100644 index 000000000000..d35db0d36946 --- /dev/null +++ b/x-pack/plugins/session_view/jest.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/session_view'], + coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/session_view', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/plugins/session_view/{common,public,server}/**/*.{ts,tsx}', + ], + setupFiles: ['jest-canvas-mock'], +}; diff --git a/x-pack/plugins/session_view/kibana.json b/x-pack/plugins/session_view/kibana.json new file mode 100644 index 000000000000..ff9d849016c5 --- /dev/null +++ b/x-pack/plugins/session_view/kibana.json @@ -0,0 +1,19 @@ +{ + "id": "sessionView", + "version": "8.0.0", + "kibanaVersion": "kibana", + "owner": { + "name": "Security Team", + "githubTeam": "security-team" + }, + "requiredPlugins": [ + "data", + "timelines" + ], + "requiredBundles": [ + "kibanaReact", + "esUiShared" + ], + "server": true, + "ui": true +} diff --git a/x-pack/plugins/session_view/package.json b/x-pack/plugins/session_view/package.json new file mode 100644 index 000000000000..2cb3dc882ed7 --- /dev/null +++ b/x-pack/plugins/session_view/package.json @@ -0,0 +1,11 @@ +{ + "author": "Elastic", + "name": "session_view", + "version": "8.0.0", + "private": true, + "license": "Elastic-License", + "scripts": { + "test:jest": "node ../../scripts/jest", + "test:coverage": "node ../../scripts/jest --coverage" + } +} diff --git a/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.test.tsx new file mode 100644 index 000000000000..80ad3ce0c463 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelAccordion } from './index'; + +const TEST_ID = 'test'; +const TEST_LIST_ITEM = [ + { + title: 'item title', + description: 'item description', + }, +]; +const TEST_TITLE = 'accordion title'; +const ACTION_TEXT = 'extra action'; + +describe('DetailPanelAccordion component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelAccordion is mounted', () => { + it('should render basic acoordion', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('sessionView:detail-panel-accordion')).toBeVisible(); + }); + + it('should render acoordion with tooltip', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('sessionView:detail-panel-accordion')).toBeVisible(); + expect( + renderResult.queryByTestId('sessionView:detail-panel-accordion-tooltip') + ).toBeVisible(); + }); + + it('should render acoordion with extra action', async () => { + const mockFn = jest.fn(); + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('sessionView:detail-panel-accordion')).toBeVisible(); + const extraActionButton = renderResult.getByTestId( + 'sessionView:detail-panel-accordion-action' + ); + expect(extraActionButton).toHaveTextContent(ACTION_TEXT); + extraActionButton.click(); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.tsx new file mode 100644 index 000000000000..4e03931e4fcd --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { ReactNode } from 'react'; +import { EuiAccordion, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; +import { useStyles } from './styles'; +import { DetailPanelDescriptionList } from '../detail_panel_description_list'; + +interface DetailPanelAccordionDeps { + id: string; + listItems: Array<{ + title: NonNullable; + description: NonNullable; + }>; + title: string; + tooltipContent?: string; + extraActionTitle?: string; + onExtraActionClick?: () => void; +} + +/** + * An accordion section in session view detail panel. + */ +export const DetailPanelAccordion = ({ + id, + listItems, + title, + tooltipContent, + extraActionTitle, + onExtraActionClick, +}: DetailPanelAccordionDeps) => { + const styles = useStyles(); + + return ( + + + {title} + + {tooltipContent && ( + + + + )} + + } + extraAction={ + extraActionTitle ? ( + + {extraActionTitle} + + ) : null + } + css={styles.accordion} + data-test-subj="sessionView:detail-panel-accordion" + > + + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts new file mode 100644 index 000000000000..c44e069c05c0 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.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 { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const tabSection: CSSObject = { + padding: euiTheme.size.base, + }; + + const accordion: CSSObject = { + borderTop: euiTheme.border.thin, + '&:last-child': { + borderBottom: euiTheme.border.thin, + }, + }; + + const accordionButton: CSSObject = { + padding: euiTheme.size.base, + fontWeight: euiTheme.font.weight.bold, + }; + + return { + accordion, + accordionButton, + tabSection, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_copy/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_copy/index.test.tsx new file mode 100644 index 000000000000..bb1dd243621b --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_copy/index.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelCopy } from './index'; + +const TEST_TEXT_COPY = 'copy component test'; +const TEST_CHILD = {TEST_TEXT_COPY}; + +describe('DetailPanelCopy component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelCopy is mounted', () => { + it('renders DetailPanelCopy correctly', async () => { + renderResult = mockedContext.render( + {TEST_CHILD} + ); + + expect(renderResult.queryByText(TEST_TEXT_COPY)).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_copy/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_copy/index.tsx new file mode 100644 index 000000000000..a5ce77894949 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_copy/index.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { ReactNode } from 'react'; +import { EuiButtonIcon, EuiCopy } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DetailPanelListItem } from '../detail_panel_list_item'; +import { dataOrDash } from '../../utils/data_or_dash'; +import { useStyles } from './styles'; + +interface DetailPanelCopyDeps { + children: ReactNode; + textToCopy: string | number; + display?: 'inlineBlock' | 'block' | undefined; +} + +interface DetailPanelListItemProps { + copy: ReactNode; + display?: string; +} + +/** + * Copy to clipboard component in Session view detail panel. + */ +export const DetailPanelCopy = ({ + children, + textToCopy, + display = 'inlineBlock', +}: DetailPanelCopyDeps) => { + const styles = useStyles(); + + const props: DetailPanelListItemProps = { + copy: ( + + {(copy) => ( + + )} + + ), + }; + + if (display === 'block') { + props.display = display; + } + + return {children}; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_copy/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_copy/styles.ts new file mode 100644 index 000000000000..0bfc67dddb88 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_copy/styles.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 { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const copyButton: CSSObject = { + position: 'absolute', + right: euiTheme.size.s, + top: 0, + bottom: 0, + margin: 'auto', + }; + + return { + copyButton, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.test.tsx new file mode 100644 index 000000000000..aaf3086aabf5 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelDescriptionList } from './index'; + +const TEST_FIRST_TITLE = 'item title'; +const TEST_FIRST_DESCRIPTION = 'item description'; +const TEST_SECOND_TITLE = 'second title'; +const TEST_SECOND_DESCRIPTION = 'second description'; +const TEST_LIST_ITEM = [ + { + title: TEST_FIRST_TITLE, + description: TEST_FIRST_DESCRIPTION, + }, + { + title: TEST_SECOND_TITLE, + description: TEST_SECOND_DESCRIPTION, + }, +]; + +describe('DetailPanelDescriptionList component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelDescriptionList is mounted', () => { + it('renders DetailPanelDescriptionList correctly', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('sessionView:detail-panel-description-list')).toBeVisible(); + + // check list items are rendered + expect(renderResult.queryByText(TEST_FIRST_TITLE)).toBeVisible(); + expect(renderResult.queryByText(TEST_FIRST_DESCRIPTION)).toBeVisible(); + expect(renderResult.queryByText(TEST_SECOND_TITLE)).toBeVisible(); + expect(renderResult.queryByText(TEST_SECOND_DESCRIPTION)).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.tsx new file mode 100644 index 000000000000..3d942fc42326 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { ReactNode } from 'react'; +import { EuiDescriptionList } from '@elastic/eui'; +import { useStyles } from './styles'; + +interface DetailPanelDescriptionListDeps { + listItems: Array<{ + title: NonNullable; + description: NonNullable; + }>; +} + +/** + * Description list in session view detail panel. + */ +export const DetailPanelDescriptionList = ({ listItems }: DetailPanelDescriptionListDeps) => { + const styles = useStyles(); + return ( + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_description_list/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_description_list/styles.ts new file mode 100644 index 000000000000..d815cb2a4828 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_description_list/styles.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 { useMemo } from 'react'; +import { CSSObject } from '@emotion/react'; +import { useEuiTheme } from '@elastic/eui'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const descriptionList: CSSObject = { + padding: euiTheme.size.s, + }; + + const tabListTitle = { + width: '40%', + display: 'flex', + alignItems: 'center', + }; + + const tabListDescription = { + width: '60%', + display: 'flex', + alignItems: 'center', + }; + + return { + descriptionList, + tabListTitle, + tabListDescription, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx new file mode 100644 index 000000000000..2df9f47e5a41 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { ProcessEventHost } from '../../../common/types/process_tree'; +import { DetailPanelHostTab } from './index'; + +const TEST_ARCHITECTURE = 'x86_64'; +const TEST_HOSTNAME = 'host-james-fleet-714-2'; +const TEST_ID = '48c1b3f1ac5da4e0057fc9f60f4d1d5d'; +const TEST_IP = '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809'; +const TEST_MAC = '42:01:0a:84:00:32'; +const TEST_NAME = 'name-james-fleet-714-2'; +const TEST_OS_FAMILY = 'family-centos'; +const TEST_OS_FULL = 'full-CentOS 7.9.2009'; +const TEST_OS_KERNEL = '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021'; +const TEST_OS_NAME = 'os-Linux'; +const TEST_OS_PLATFORM = 'platform-centos'; +const TEST_OS_VERSION = 'version-7.9.2009'; + +const TEST_HOST: ProcessEventHost = { + architecture: TEST_ARCHITECTURE, + hostname: TEST_HOSTNAME, + id: TEST_ID, + ip: TEST_IP, + mac: TEST_MAC, + name: TEST_NAME, + os: { + family: TEST_OS_FAMILY, + full: TEST_OS_FULL, + kernel: TEST_OS_KERNEL, + name: TEST_OS_NAME, + platform: TEST_OS_PLATFORM, + version: TEST_OS_VERSION, + }, +}; + +describe('DetailPanelHostTab component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelHostTab is mounted', () => { + it('renders DetailPanelHostTab correctly', async () => { + renderResult = mockedContext.render(); + + expect(renderResult.queryByText('architecture')).toBeVisible(); + expect(renderResult.queryByText('hostname')).toBeVisible(); + expect(renderResult.queryByText('id')).toBeVisible(); + expect(renderResult.queryByText('ip')).toBeVisible(); + expect(renderResult.queryByText('mac')).toBeVisible(); + expect(renderResult.queryByText('name')).toBeVisible(); + expect(renderResult.queryByText(TEST_ARCHITECTURE)).toBeVisible(); + expect(renderResult.queryByText(TEST_HOSTNAME)).toBeVisible(); + expect(renderResult.queryByText(TEST_ID)).toBeVisible(); + expect(renderResult.queryByText(TEST_IP)).toBeVisible(); + expect(renderResult.queryByText(TEST_MAC)).toBeVisible(); + expect(renderResult.queryByText(TEST_NAME)).toBeVisible(); + + // expand host os accordion + renderResult + .queryByTestId('sessionView:detail-panel-accordion') + ?.querySelector('button') + ?.click(); + expect(renderResult.queryByText('os.family')).toBeVisible(); + expect(renderResult.queryByText('os.full')).toBeVisible(); + expect(renderResult.queryByText('os.kernel')).toBeVisible(); + expect(renderResult.queryByText('os.name')).toBeVisible(); + expect(renderResult.queryByText('os.platform')).toBeVisible(); + expect(renderResult.queryByText('os.version')).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_FAMILY)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_FULL)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_KERNEL)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_NAME)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_PLATFORM)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_VERSION)).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx new file mode 100644 index 000000000000..e46e0e275187 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiTextColor } from '@elastic/eui'; +import { ProcessEventHost } from '../../../common/types/process_tree'; +import { DetailPanelAccordion } from '../detail_panel_accordion'; +import { DetailPanelCopy } from '../detail_panel_copy'; +import { DetailPanelDescriptionList } from '../detail_panel_description_list'; +import { DetailPanelListItem } from '../detail_panel_list_item'; +import { dataOrDash } from '../../utils/data_or_dash'; +import { useStyles } from '../detail_panel_process_tab/styles'; + +interface DetailPanelHostTabDeps { + processHost: ProcessEventHost; +} + +/** + * Host Panel of session view detail panel. + */ +export const DetailPanelHostTab = ({ processHost }: DetailPanelHostTabDeps) => { + const styles = useStyles(); + + return ( + <> + hostname, + description: ( + + + {dataOrDash(processHost.hostname)} + + + ), + }, + { + title: id, + description: ( + + + {dataOrDash(processHost.id)} + + + ), + }, + { + title: ip, + description: ( + + + {dataOrDash(processHost.ip)} + + + ), + }, + { + title: mac, + description: ( + + + {dataOrDash(processHost.mac)} + + + ), + }, + { + title: name, + description: ( + + + {dataOrDash(processHost.name)} + + + ), + }, + ]} + /> + architecture, + description: ( + + + {dataOrDash(processHost.architecture)} + + + ), + }, + { + title: os.family, + description: ( + + + {dataOrDash(processHost.os.family)} + + + ), + }, + { + title: os.full, + description: ( + + + {dataOrDash(processHost.os.full)} + + + ), + }, + { + title: os.kernel, + description: ( + + + {dataOrDash(processHost.os.kernel)} + + + ), + }, + { + title: os.name, + description: ( + + + {dataOrDash(processHost.os.name)} + + + ), + }, + { + title: os.platform, + description: ( + + + {dataOrDash(processHost.os.platform)} + + + ), + }, + { + title: os.version, + description: ( + + + {dataOrDash(processHost.os.version)} + + + ), + }, + ]} + /> + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.test.tsx new file mode 100644 index 000000000000..e6572a097d85 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.test.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 { screen, fireEvent, waitFor } from '@testing-library/react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelListItem } from './index'; + +const TEST_STRING = 'item title'; +const TEST_CHILD = {TEST_STRING}; +const TEST_COPY_STRING = 'test copy button'; +const BUTTON_TEST_ID = 'sessionView:test-copy-button'; +const TEST_COPY = ; +const LIST_ITEM_TEST_ID = 'sessionView:detail-panel-list-item'; +const WAIT_TIMEOUT = 500; + +describe('DetailPanelListItem component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelListItem is mounted', () => { + it('renders DetailPanelListItem correctly', async () => { + renderResult = mockedContext.render({TEST_CHILD}); + + expect(renderResult.queryByTestId(LIST_ITEM_TEST_ID)).toBeVisible(); + expect(renderResult.queryByText(TEST_STRING)).toBeVisible(); + }); + + it('renders copy element correctly', async () => { + renderResult = mockedContext.render( + {TEST_CHILD} + ); + + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeNull(); + fireEvent.mouseEnter(renderResult.getByTestId(LIST_ITEM_TEST_ID)); + await waitFor(() => screen.queryByTestId(BUTTON_TEST_ID)); + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeVisible(); + + fireEvent.mouseLeave(renderResult.getByTestId(LIST_ITEM_TEST_ID)); + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeNull(); + }); + + it('does not have mouse events when copy prop is not present', async () => { + renderResult = mockedContext.render({TEST_CHILD}); + + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeNull(); + fireEvent.mouseEnter(renderResult.getByTestId(LIST_ITEM_TEST_ID)); + await waitFor(() => screen.queryByTestId(BUTTON_TEST_ID), { timeout: WAIT_TIMEOUT }); + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.tsx new file mode 100644 index 000000000000..93a6554bbe54 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, ReactNode } from 'react'; +import { EuiText, EuiTextProps } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; +import { useStyles } from './styles'; + +interface DetailPanelListItemDeps { + children: ReactNode; + copy?: ReactNode; + display?: string; +} + +interface EuiTextPropsCss extends EuiTextProps { + css: CSSObject; + onMouseEnter?: () => void; + onMouseLeave?: () => void; +} + +/** + * Detail panel description list item. + */ +export const DetailPanelListItem = ({ + children, + copy, + display = 'flex', +}: DetailPanelListItemDeps) => { + const [isHovered, setIsHovered] = useState(false); + const styles = useStyles({ display }); + + const props: EuiTextPropsCss = { + size: 's', + css: !!copy ? styles.copiableItem : styles.item, + }; + + if (!!copy) { + props.onMouseEnter = () => setIsHovered(true); + props.onMouseLeave = () => setIsHovered(false); + } + + return ( + + {children} + {isHovered && copy} + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_list_item/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_list_item/styles.ts new file mode 100644 index 000000000000..c370bd8adb6e --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_list_item/styles.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 { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +interface StylesDeps { + display: string | undefined; +} + +export const useStyles = ({ display }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const item: CSSObject = { + display, + alignItems: 'center', + padding: euiTheme.size.s, + width: '100%', + fontSize: 'inherit', + fontWeight: 'inherit', + minHeight: '36px', + }; + + const copiableItem: CSSObject = { + ...item, + position: 'relative', + borderRadius: euiTheme.border.radius.medium, + '&:hover': { + background: transparentize(euiTheme.colors.primary, 0.1), + }, + }; + + return { + item, + copiableItem, + }; + }, [display, euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.test.ts b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.test.ts new file mode 100644 index 000000000000..d458ee3a1d66 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.test.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 { getProcessExecutableCopyText } from './helpers'; + +describe('detail panel process tab helpers tests', () => { + it('getProcessExecutableCopyText works with empty array', () => { + const result = getProcessExecutableCopyText([]); + expect(result).toEqual(''); + }); + + it('getProcessExecutableCopyText works with array of tuples', () => { + const result = getProcessExecutableCopyText([ + ['echo', 'exec'], + ['echo', 'exit'], + ]); + expect(result).toEqual('echo exec, echo exit'); + }); + + it('getProcessExecutableCopyText returns empty string with an invalid array of tuples', () => { + // when some sub arrays only have 1 item + let result = getProcessExecutableCopyText([['echo', 'exec'], ['echo']]); + expect(result).toEqual(''); + + // when some sub arrays have more than two item + result = getProcessExecutableCopyText([ + ['echo', 'exec'], + ['echo', 'exec', 'random'], + ['echo', 'exit'], + ]); + expect(result).toEqual(''); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts new file mode 100644 index 000000000000..632e0bc5fd2e --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.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. + */ + +/** + * Serialize an array of executable tuples to a copyable text. + * + * @param {String[][]} executable + * @return {String} serialized string with data of each executable + */ +export const getProcessExecutableCopyText = (executable: string[][]) => { + try { + return executable + .map((execTuple) => { + const [execCommand, eventAction] = execTuple; + if (!execCommand || !eventAction || execTuple.length !== 2) { + throw new Error(); + } + return `${execCommand} ${eventAction}`; + }) + .join(', '); + } catch (_) { + return ''; + } +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.test.tsx new file mode 100644 index 000000000000..074c69de7e89 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.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 React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelProcess, DetailPanelProcessLeader } from '../../types'; +import { DetailPanelProcessTab } from './index'; + +const getLeaderDetail = (leader: string): DetailPanelProcessLeader => ({ + id: `${leader}-id`, + name: `${leader}-name`, + start: new Date('2022-02-24').toISOString(), + entryMetaType: 'sshd', + userName: `${leader}-jack`, + interactive: true, + pid: 1234, + entryMetaSourceIp: '10.132.0.50', + executable: '/usr/bin/bash', +}); + +const TEST_PROCESS_DETAIL: DetailPanelProcess = { + id: 'process-id', + start: new Date('2022-02-22').toISOString(), + end: new Date('2022-02-23').toISOString(), + exit_code: 137, + user: 'process-jack', + args: ['vi', 'test.txt'], + executable: [ + ['test-executable-cmd', '(fork)'], + ['test-executable-cmd', '(exec)'], + ['test-executable-cmd', '(end)'], + ], + pid: 1233, + entryLeader: getLeaderDetail('entryLeader'), + sessionLeader: getLeaderDetail('sessionLeader'), + groupLeader: getLeaderDetail('groupLeader'), + parent: getLeaderDetail('parent'), +}; + +describe('DetailPanelProcessTab component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelProcessTab is mounted', () => { + it('renders DetailPanelProcessTab correctly', async () => { + renderResult = mockedContext.render( + + ); + + // Process detail rendered correctly + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.id)).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.start)).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.end)).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.exit_code)).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.user)).toBeVisible(); + expect(renderResult.queryByText(`['vi','test.txt']`)).toBeVisible(); + expect(renderResult.queryAllByText('test-executable-cmd')).toHaveLength(3); + expect(renderResult.queryByText('(fork)')).toBeVisible(); + expect(renderResult.queryByText('(exec)')).toBeVisible(); + expect(renderResult.queryByText('(end)')).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.pid)).toBeVisible(); + + // Process tab accordions rendered correctly + expect(renderResult.queryByText('entryLeader-name')).toBeVisible(); + expect(renderResult.queryByText('sessionLeader-name')).toBeVisible(); + expect(renderResult.queryByText('groupLeader-name')).toBeVisible(); + expect(renderResult.queryByText('parent-name')).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.tsx new file mode 100644 index 000000000000..97e2cdc806c0 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.tsx @@ -0,0 +1,255 @@ +/* + * Copyright 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, { ReactNode } from 'react'; +import { EuiTextColor } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DetailPanelProcess } from '../../types'; +import { DetailPanelAccordion } from '../detail_panel_accordion'; +import { DetailPanelCopy } from '../detail_panel_copy'; +import { DetailPanelDescriptionList } from '../detail_panel_description_list'; +import { DetailPanelListItem } from '../detail_panel_list_item'; +import { dataOrDash } from '../../utils/data_or_dash'; +import { getProcessExecutableCopyText } from './helpers'; +import { useStyles } from './styles'; + +interface DetailPanelProcessTabDeps { + processDetail: DetailPanelProcess; +} + +type ListItems = Array<{ + title: NonNullable; + description: NonNullable; +}>; + +// TODO: Update placeholder descriptions for these tootips once UX Writer Team Defines them +const leaderDescriptionListInfo = [ + { + id: 'processEntryLeader', + title: 'Entry Leader', + tooltipContent: i18n.translate('xpack.sessionView.detailPanel.entryLeaderTooltip', { + defaultMessage: 'A entry leader placeholder description', + }), + }, + { + id: 'processSessionLeader', + title: 'Session Leader', + tooltipContent: i18n.translate('xpack.sessionView.detailPanel.sessionLeaderTooltip', { + defaultMessage: 'A session leader placeholder description', + }), + }, + { + id: 'processGroupLeader', + title: 'Group Leader', + tooltipContent: i18n.translate('xpack.sessionView.detailPanel.processGroupLeaderTooltip', { + defaultMessage: 'a group leader placeholder description', + }), + }, + { + id: 'processParent', + title: 'Parent', + tooltipContent: i18n.translate('xpack.sessionView.detailPanel.processParentTooltip', { + defaultMessage: 'a parent placeholder description', + }), + }, +]; + +/** + * Detail panel in the session view. + */ +export const DetailPanelProcessTab = ({ processDetail }: DetailPanelProcessTabDeps) => { + const styles = useStyles(); + const leaderListItems = [ + processDetail.entryLeader, + processDetail.sessionLeader, + processDetail.groupLeader, + processDetail.parent, + ].map((leader, idx) => { + const listItems: ListItems = [ + { + title: id, + description: ( + + + {dataOrDash(leader.id)} + + + ), + }, + { + title: start, + description: ( + + {leader.start} + + ), + }, + ]; + // Only include entry_meta.type for entry leader + if (idx === 0) { + listItems.push({ + title: entry_meta.type, + description: ( + + + {dataOrDash(leader.entryMetaType)} + + + ), + }); + } + listItems.push( + { + title: user.name, + description: ( + + {dataOrDash(leader.userName)} + + ), + }, + { + title: interactive, + description: ( + + {leader.interactive ? 'True' : 'False'} + + ), + }, + { + title: pid, + description: ( + + {dataOrDash(leader.pid)} + + ), + } + ); + // Only include entry_meta.source.ip for entry leader + if (idx === 0) { + listItems.push({ + title: entry_meta.source.ip, + description: ( + + {dataOrDash(leader.entryMetaSourceIp)} + + ), + }); + } + return { + ...leaderDescriptionListInfo[idx], + name: leader.name, + listItems, + }; + }); + + const processArgs = processDetail.args.length + ? `[${processDetail.args.map((arg) => `'${arg}'`)}]` + : '-'; + + return ( + <> + id, + description: ( + + + {dataOrDash(processDetail.id)} + + + ), + }, + { + title: start, + description: ( + + {processDetail.start} + + ), + }, + { + title: end, + description: ( + + {processDetail.end} + + ), + }, + { + title: exit_code, + description: ( + + + {dataOrDash(processDetail.exit_code)} + + + ), + }, + { + title: user, + description: ( + + {dataOrDash(processDetail.user)} + + ), + }, + { + title: args, + description: ( + + {processArgs} + + ), + }, + { + title: executable, + description: ( + + {processDetail.executable.map((execTuple, idx) => { + const [executable, eventAction] = execTuple; + return ( +
    + + {executable} + + + {eventAction} + +
    + ); + })} +
    + ), + }, + { + title: process.pid, + description: ( + + + {dataOrDash(processDetail.pid)} + + + ), + }, + ]} + /> + {leaderListItems.map((leader) => ( + + ))} + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/styles.ts new file mode 100644 index 000000000000..8c1154f0c007 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/styles.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const description: CSSObject = { + width: `calc(100% - ${euiTheme.size.xl})`, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }; + + const descriptionSemibold: CSSObject = { + ...description, + fontWeight: euiTheme.font.weight.medium, + }; + + const executableAction: CSSObject = { + fontWeight: euiTheme.font.weight.semiBold, + paddingLeft: euiTheme.size.xs, + }; + + return { + description, + descriptionSemibold, + executableAction, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts b/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts new file mode 100644 index 000000000000..9092009a7d29 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/helpers.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 { + mockData, + mockProcessMap, +} from '../../../common/mocks/constants/session_view_process.mock'; +import { Process, ProcessMap } from '../../../common/types/process_tree'; +import { + updateProcessMap, + buildProcessTree, + searchProcessTree, + autoExpandProcessTree, +} from './helpers'; + +const SESSION_ENTITY_ID = '3d0192c6-7c54-5ee6-a110-3539a7cf42bc'; +const SEARCH_QUERY = 'vi'; +const SEARCH_RESULT_PROCESS_ID = '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3727'; + +const mockEvents = mockData[0].events; + +describe('process tree hook helpers tests', () => { + let processMap: ProcessMap; + + beforeEach(() => { + processMap = {}; + }); + + it('updateProcessMap works', () => { + processMap = updateProcessMap(processMap, mockEvents); + + // processes are added to processMap + mockEvents.forEach((event) => { + expect(processMap[event.process.entity_id]).toBeTruthy(); + }); + }); + + it('buildProcessTree works', () => { + const newOrphans = buildProcessTree(mockProcessMap, mockEvents, [], SESSION_ENTITY_ID); + + const sessionLeaderChildrenIds = new Set( + mockProcessMap[SESSION_ENTITY_ID].children.map((child: Process) => child.id) + ); + + // processes are added under their parent's childrean array in processMap + mockEvents.forEach((event) => { + expect(sessionLeaderChildrenIds.has(event.process.entity_id)); + }); + + expect(newOrphans.length).toBe(0); + }); + + it('searchProcessTree works', () => { + const searchResults = searchProcessTree(mockProcessMap, SEARCH_QUERY); + + // search returns the process with search query in its event args + expect(searchResults[0].id).toBe(SEARCH_RESULT_PROCESS_ID); + }); + + it('autoExpandProcessTree works', () => { + processMap = mockProcessMap; + // mock what buildProcessTree does + const childProcesses = Object.values(processMap).filter( + (process) => process.id !== SESSION_ENTITY_ID + ); + processMap[SESSION_ENTITY_ID].children = childProcesses; + + expect(processMap[SESSION_ENTITY_ID].autoExpand).toBeFalsy(); + processMap = autoExpandProcessTree(processMap); + // session leader should have autoExpand to be true + expect(processMap[SESSION_ENTITY_ID].autoExpand).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree/helpers.ts b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts new file mode 100644 index 000000000000..d3d7af1c62ed --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts @@ -0,0 +1,170 @@ +/* + * Copyright 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 { Process, ProcessEvent, ProcessMap } from '../../../common/types/process_tree'; +import { ProcessImpl } from './hooks'; + +// given a page of new events, add these events to the appropriate process class model +// create a new process if none are created and return the mutated processMap +export const updateProcessMap = (processMap: ProcessMap, events: ProcessEvent[]) => { + events.forEach((event) => { + const { entity_id: id } = event.process; + let process = processMap[id]; + + if (!process) { + process = new ProcessImpl(id); + processMap[id] = process; + } + + process.addEvent(event); + }); + + return processMap; +}; + +// given a page of events, update process model parent child relationships +// if we cannot find a parent for a process include said process +// in the array of orphans. We track orphans in their own array, so +// we can attempt to re-parent the orphans when new pages of events are +// processed. This is especially important when paginating backwards +// (e.g in the case where the SessionView jumpToEvent prop is used, potentially skipping over ancestor processes) +export const buildProcessTree = ( + processMap: ProcessMap, + events: ProcessEvent[], + orphans: Process[], + sessionEntityId: string, + backwardDirection: boolean = false +) => { + // we process events in reverse order when paginating backwards. + if (backwardDirection) { + events = events.slice().reverse(); + } + + events.forEach((event) => { + const process = processMap[event.process.entity_id]; + const parentProcess = processMap[event.process.parent?.entity_id]; + + // if session leader, or process already has a parent, return + if (process.id === sessionEntityId || process.parent) { + return; + } + + if (parentProcess) { + process.parent = parentProcess; // handy for recursive operations (like auto expand) + + if (backwardDirection) { + parentProcess.children.unshift(process); + } else { + parentProcess.children.push(process); + } + } else if (!orphans?.includes(process)) { + // if no parent process, process is probably orphaned + if (backwardDirection) { + orphans?.unshift(process); + } else { + orphans?.push(process); + } + } + }); + + const newOrphans: Process[] = []; + + // with this new page of events processed, lets try re-parent any orphans + orphans?.forEach((process) => { + const parentProcess = processMap[process.getDetails().process.parent.entity_id]; + + if (parentProcess) { + process.parent = parentProcess; // handy for recursive operations (like auto expand) + + parentProcess.children.push(process); + } else { + newOrphans.push(process); + } + }); + + return newOrphans; +}; + +// given a plain text searchQuery, iterates over all processes in processMap +// and marks ones which match the below text (currently what is rendered in the process line item) +// process.searchMatched is used by process_tree_node to highlight the text which matched the search +// this funtion also returns a list of process results which is used by session_view_search_bar to drive +// result navigation UX +// FYI: this function mutates properties of models contained in processMap +export const searchProcessTree = (processMap: ProcessMap, searchQuery: string | undefined) => { + const results = []; + + for (const processId of Object.keys(processMap)) { + const process = processMap[processId]; + + if (searchQuery) { + const event = process.getDetails(); + const { working_directory: workingDirectory, args } = event.process; + + // TODO: the text we search is the same as what we render. + // in future we may support KQL searches to match against any property + // for now plain text search is limited to searching process.working_directory + process.args + const text = `${workingDirectory} ${args?.join(' ')}`; + + process.searchMatched = text.includes(searchQuery) ? searchQuery : null; + + if (process.searchMatched) { + results.push(process); + } + } else { + process.clearSearch(); + } + } + + return results; +}; + +// Iterate over all processes in processMap, and mark each process (and it's ancestors) for auto expansion if: +// a) the process was "user entered" (aka an interactive group leader) +// b) matches the plain text search above +// Returns the processMap with it's processes autoExpand bool set to true or false +// process.autoExpand is read by process_tree_node to determine whether to auto expand it's child processes. +export const autoExpandProcessTree = (processMap: ProcessMap) => { + for (const processId of Object.keys(processMap)) { + const process = processMap[processId]; + + if (process.searchMatched || process.isUserEntered()) { + let { parent } = process; + const parentIdSet = new Set(); + + while (parent && !parentIdSet.has(parent.id)) { + parentIdSet.add(parent.id); + parent.autoExpand = true; + parent = parent.parent; + } + } + } + + return processMap; +}; + +export const processNewEvents = ( + eventsProcessMap: ProcessMap, + events: ProcessEvent[] | undefined, + orphans: Process[], + sessionEntityId: string, + backwardDirection: boolean = false +): [ProcessMap, Process[]] => { + if (!events || events.length === 0) { + return [eventsProcessMap, orphans]; + } + + const updatedProcessMap = updateProcessMap(eventsProcessMap, events); + const newOrphans = buildProcessTree( + updatedProcessMap, + events, + orphans, + sessionEntityId, + backwardDirection + ); + + return [autoExpandProcessTree(updatedProcessMap), newOrphans]; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.test.tsx b/x-pack/plugins/session_view/public/components/process_tree/hooks.test.tsx new file mode 100644 index 000000000000..9cece96fe846 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EventAction } from '../../../common/types/process_tree'; +import { mockEvents } from '../../../common/mocks/constants/session_view_process.mock'; +import { ProcessImpl } from './hooks'; + +describe('ProcessTree hooks', () => { + describe('ProcessImpl.getDetails memoize will cache bust on new events', () => { + it('should return the exec event details when this.events changes', () => { + const process = new ProcessImpl(mockEvents[0].process.entity_id); + + process.addEvent(mockEvents[0]); + + let result = process.getDetails(); + + // push exec event + process.addEvent(mockEvents[1]); + + result = process.getDetails(); + + expect(result.event.action).toEqual(EventAction.exec); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts new file mode 100644 index 000000000000..a8c6ffe8e75d --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts @@ -0,0 +1,255 @@ +/* + * Copyright 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 memoizeOne from 'memoize-one'; +import { useState, useEffect } from 'react'; +import { + EventAction, + EventKind, + Process, + ProcessEvent, + ProcessMap, + ProcessEventsPage, +} from '../../../common/types/process_tree'; +import { processNewEvents, searchProcessTree, autoExpandProcessTree } from './helpers'; +import { sortProcesses } from '../../../common/utils/sort_processes'; + +interface UseProcessTreeDeps { + sessionEntityId: string; + data: ProcessEventsPage[]; + searchQuery?: string; +} + +export class ProcessImpl implements Process { + id: string; + events: ProcessEvent[]; + children: Process[]; + parent: Process | undefined; + autoExpand: boolean; + searchMatched: string | null; + orphans: Process[]; + + constructor(id: string) { + this.id = id; + this.events = []; + this.children = []; + this.orphans = []; + this.autoExpand = false; + this.searchMatched = null; + } + + addEvent(event: ProcessEvent) { + // rather than push new events on the array, we return a new one + // this helps the below memoizeOne functions to behave correctly. + this.events = this.events.concat(event); + } + + clearSearch() { + this.searchMatched = null; + this.autoExpand = false; + } + + getChildren(verboseMode: boolean) { + let children = this.children; + + // if there are orphans, we just render them inline with the other child processes (currently only session leader does this) + if (this.orphans.length) { + children = [...children, ...this.orphans].sort(sortProcesses); + } + + // When verboseMode is false, we filter out noise via a few techniques. + // This option is driven by the "verbose mode" toggle in SessionView/index.tsx + if (!verboseMode) { + return children.filter((child) => { + const { group_leader: groupLeader, session_leader: sessionLeader } = + child.getDetails().process; + + // search matches will never be filtered out + if (child.searchMatched) { + return true; + } + + // Hide processes that have their session leader as their process group leader. + // This accounts for a lot of noise from bash and other shells forking, running auto completion processes and + // other shell startup activities (e.g bashrc .profile etc) + if (groupLeader.pid === sessionLeader.pid) { + return false; + } + + // If the process has no children and has not exec'd (fork only), we hide it. + if (child.children.length === 0 && !child.hasExec()) { + return false; + } + + return true; + }); + } + + return children; + } + + hasOutput() { + return !!this.findEventByAction(this.events, EventAction.output); + } + + hasAlerts() { + return !!this.findEventByKind(this.events, EventKind.signal); + } + + getAlerts() { + return this.filterEventsByKind(this.events, EventKind.signal); + } + + hasExec() { + return !!this.findEventByAction(this.events, EventAction.exec); + } + + hasExited() { + return !!this.findEventByAction(this.events, EventAction.end); + } + + getDetails() { + return this.getDetailsMemo(this.events); + } + + getOutput() { + // not implemented, output ECS schema not defined (for a future release) + return ''; + } + + // isUserEntered is a best guess at which processes were initiated by a real person + // In most situations a user entered command in a shell such as bash, will cause bash + // to fork, create a new process group, and exec the command (e.g ls). If the session + // has a controlling tty (aka an interactive session), we assume process group leaders + // with a session leader for a parent are "user entered". + // Because of the presence of false positives in this calculation, it is currently + // only used to auto expand parts of the tree that could be of interest. + isUserEntered() { + const event = this.getDetails(); + const { + pid, + tty, + parent, + session_leader: sessionLeader, + group_leader: groupLeader, + } = event.process; + + const parentIsASessionLeader = parent.pid === sessionLeader.pid; // possibly bash, zsh or some other shell + const processIsAGroupLeader = pid === groupLeader.pid; + const sessionIsInteractive = !!tty; + + return sessionIsInteractive && parentIsASessionLeader && processIsAGroupLeader; + } + + getMaxAlertLevel() { + // TODO: as part of alerts details work + tie in with the new alert flyout + return null; + } + + findEventByAction = memoizeOne((events: ProcessEvent[], action: EventAction) => { + return events.find(({ event }) => event.action === action); + }); + + findEventByKind = memoizeOne((events: ProcessEvent[], kind: EventKind) => { + return events.find(({ event }) => event.kind === kind); + }); + + filterEventsByAction = memoizeOne((events: ProcessEvent[], action: EventAction) => { + return events.filter(({ event }) => event.action === action); + }); + + filterEventsByKind = memoizeOne((events: ProcessEvent[], kind: EventKind) => { + return events.filter(({ event }) => event.kind === kind); + }); + + // returns the most recent fork, exec, or end event + // to be used as a source for the most up to date details + // on the processes lifecycle. + getDetailsMemo = memoizeOne((events: ProcessEvent[]) => { + const actionsToFind = [EventAction.fork, EventAction.exec, EventAction.end]; + const filtered = events.filter((processEvent) => { + return actionsToFind.includes(processEvent.event.action); + }); + + // because events is already ordered by @timestamp we take the last event + // which could be a fork (w no exec or exit), most recent exec event (there can be multiple), or end event. + // If a process has an 'end' event will always be returned (since it is last and includes details like exit_code and end time) + return filtered[filtered.length - 1] || ({} as ProcessEvent); + }); +} + +export const useProcessTree = ({ sessionEntityId, data, searchQuery }: UseProcessTreeDeps) => { + // initialize map, as well as a placeholder for session leader process + // we add a fake session leader event, sourced from wide event data. + // this is because we might not always have a session leader event + // especially if we are paging in reverse from deep within a large session + const fakeLeaderEvent = data[0].events.find((event) => event.event.kind === EventKind.event); + const sessionLeaderProcess = new ProcessImpl(sessionEntityId); + + if (fakeLeaderEvent) { + fakeLeaderEvent.process = { + ...fakeLeaderEvent.process, + ...fakeLeaderEvent.process.entry_leader, + parent: fakeLeaderEvent.process.parent, + }; + sessionLeaderProcess.events.push(fakeLeaderEvent); + } + + const initializedProcessMap: ProcessMap = { + [sessionEntityId]: sessionLeaderProcess, + }; + + const [processMap, setProcessMap] = useState(initializedProcessMap); + const [processedPages, setProcessedPages] = useState([]); + const [searchResults, setSearchResults] = useState([]); + const [orphans, setOrphans] = useState([]); + + useEffect(() => { + let updatedProcessMap: ProcessMap = processMap; + let newOrphans: Process[] = orphans; + const newProcessedPages: ProcessEventsPage[] = []; + + data.forEach((page, i) => { + const processed = processedPages.find((p) => p.cursor === page.cursor); + + if (!processed) { + const backwards = i < processedPages.length; + + const result = processNewEvents( + updatedProcessMap, + page.events, + orphans, + sessionEntityId, + backwards + ); + + updatedProcessMap = result[0]; + newOrphans = result[1]; + + newProcessedPages.push(page); + } + }); + + if (newProcessedPages.length > 0) { + setProcessMap({ ...updatedProcessMap }); + setProcessedPages([...processedPages, ...newProcessedPages]); + setOrphans(newOrphans); + } + }, [data, processMap, orphans, processedPages, sessionEntityId]); + + useEffect(() => { + setSearchResults(searchProcessTree(processMap, searchQuery)); + autoExpandProcessTree(processMap); + }, [searchQuery, processMap]); + + // set new orphans array on the session leader + const sessionLeader = processMap[sessionEntityId]; + + sessionLeader.orphans = orphans; + + return { sessionLeader: processMap[sessionEntityId], processMap, searchResults }; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx new file mode 100644 index 000000000000..ac6807984ba8 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mockData } from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { ProcessImpl } from './hooks'; +import { ProcessTree } from './index'; + +describe('ProcessTree component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When ProcessTree is mounted', () => { + it('should render given a valid sessionEntityId and data', () => { + renderResult = mockedContext.render( + true} + hasNextPage={false} + fetchPreviousPage={() => true} + hasPreviousPage={false} + onProcessSelected={jest.fn()} + /> + ); + expect(renderResult.queryByTestId('sessionView:sessionViewProcessTree')).toBeTruthy(); + expect(renderResult.queryAllByTestId('sessionView:processTreeNode')).toBeTruthy(); + }); + + it('should insert a DOM element used to highlight a process when selectedProcess is set', () => { + const mockSelectedProcess = new ProcessImpl(mockData[0].events[0].process.entity_id); + + renderResult = mockedContext.render( + true} + hasNextPage={false} + fetchPreviousPage={() => true} + hasPreviousPage={false} + selectedProcess={mockSelectedProcess} + onProcessSelected={jest.fn()} + /> + ); + + // click on view more button + renderResult.getByTestId('sessionView:processTreeNodeChildProcessesButton').click(); + + expect( + renderResult + .queryByTestId('sessionView:processTreeSelectionArea') + ?.parentElement?.getAttribute('data-id') + ).toEqual(mockSelectedProcess.id); + + // change the selected process + const mockSelectedProcess2 = new ProcessImpl(mockData[0].events[1].process.entity_id); + + renderResult.rerender( + true} + hasNextPage={false} + fetchPreviousPage={() => true} + hasPreviousPage={false} + selectedProcess={mockSelectedProcess2} + onProcessSelected={jest.fn()} + /> + ); + + expect( + renderResult + .queryByTestId('sessionView:processTreeSelectionArea') + ?.parentElement?.getAttribute('data-id') + ).toEqual(mockSelectedProcess2.id); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.tsx new file mode 100644 index 000000000000..6b3061a0d77b --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/index.tsx @@ -0,0 +1,179 @@ +/* + * Copyright 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, { useRef, useEffect, useLayoutEffect, useCallback } from 'react'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { ProcessTreeNode } from '../process_tree_node'; +import { useProcessTree } from './hooks'; +import { Process, ProcessEventsPage, ProcessEvent } from '../../../common/types/process_tree'; +import { useScroll } from '../../hooks/use_scroll'; +import { useStyles } from './styles'; + +type FetchFunction = () => void; + +interface ProcessTreeDeps { + // process.entity_id to act as root node (typically a session (or entry session) leader). + sessionEntityId: string; + + data: ProcessEventsPage[]; + + jumpToEvent?: ProcessEvent; + isFetching: boolean; + hasNextPage: boolean | undefined; + hasPreviousPage: boolean | undefined; + fetchNextPage: FetchFunction; + fetchPreviousPage: FetchFunction; + + // plain text search query (only searches "process.working_directory process.args.join(' ')" + searchQuery?: string; + + // currently selected process + selectedProcess?: Process | null; + onProcessSelected: (process: Process) => void; + setSearchResults?: (results: Process[]) => void; +} + +export const ProcessTree = ({ + sessionEntityId, + data, + jumpToEvent, + isFetching, + hasNextPage, + hasPreviousPage, + fetchNextPage, + fetchPreviousPage, + searchQuery, + selectedProcess, + onProcessSelected, + setSearchResults, +}: ProcessTreeDeps) => { + const styles = useStyles(); + + const { sessionLeader, processMap, searchResults } = useProcessTree({ + sessionEntityId, + data, + searchQuery, + }); + + const scrollerRef = useRef(null); + const selectionAreaRef = useRef(null); + + useEffect(() => { + if (setSearchResults) { + setSearchResults(searchResults); + } + }, [searchResults, setSearchResults]); + + useScroll({ + div: scrollerRef.current, + handler: (pos: number, endReached: boolean) => { + if (!isFetching && endReached) { + fetchNextPage(); + } + }, + }); + + /** + * highlights a process in the tree + * we do it this way to avoid state changes on potentially thousands of components + */ + const selectProcess = useCallback( + (process: Process) => { + if (!selectionAreaRef?.current || !scrollerRef?.current) { + return; + } + + const selectionAreaEl = selectionAreaRef.current; + selectionAreaEl.style.display = 'block'; + + // TODO: concept of alert level unknown wrt to elastic security + const alertLevel = process.getMaxAlertLevel(); + + if (alertLevel && alertLevel >= 0) { + selectionAreaEl.style.backgroundColor = + alertLevel > 0 ? styles.alertSelected : styles.defaultSelected; + } else { + selectionAreaEl.style.backgroundColor = ''; + } + + // find the DOM element for the command which is selected by id + const processEl = scrollerRef.current.querySelector(`[data-id="${process.id}"]`); + + if (processEl) { + processEl.prepend(selectionAreaEl); + + const cTop = scrollerRef.current.scrollTop; + const cBottom = cTop + scrollerRef.current.clientHeight; + + const eTop = processEl.offsetTop; + const eBottom = eTop + processEl.clientHeight; + const isVisible = eTop >= cTop && eBottom <= cBottom; + + if (!isVisible) { + processEl.scrollIntoView({ block: 'center' }); + } + } + }, + [styles.alertSelected, styles.defaultSelected] + ); + + useLayoutEffect(() => { + if (selectedProcess) { + selectProcess(selectedProcess); + } + }, [selectedProcess, selectProcess]); + + useEffect(() => { + // after 2 pages are loaded (due to bi-directional jump to), auto select the process + // for the jumpToEvent + if (jumpToEvent && data.length === 2) { + const process = processMap[jumpToEvent.process.entity_id]; + + if (process) { + onProcessSelected(process); + } + } + }, [jumpToEvent, processMap, onProcessSelected, data]); + + // auto selects the session leader process if no selection is made yet + useEffect(() => { + if (!selectedProcess) { + onProcessSelected(sessionLeader); + } + }, [sessionLeader, onProcessSelected, selectedProcess]); + + return ( +
    + {hasPreviousPage && ( + + + + )} + {sessionLeader && ( + + )} +
    + {hasNextPage && ( + + + + )} +
    + ); +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/styles.ts b/x-pack/plugins/session_view/public/components/process_tree/styles.ts new file mode 100644 index 000000000000..65fb66ad90aa --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/styles.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { transparentize, useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const defaultSelectionColor = euiTheme.colors.accent; + + const scroller: CSSObject = { + position: 'relative', + fontFamily: euiTheme.font.familyCode, + overflow: 'auto', + height: '100%', + backgroundColor: euiTheme.colors.lightestShade, + }; + + const selectionArea: CSSObject = { + position: 'absolute', + display: 'none', + marginLeft: '-50%', + width: '150%', + height: '100%', + backgroundColor: defaultSelectionColor, + pointerEvents: 'none', + opacity: 0.1, + }; + + const defaultSelected = transparentize(euiTheme.colors.primary, 0.008); + const alertSelected = transparentize(euiTheme.colors.danger, 0.008); + + return { + scroller, + selectionArea, + defaultSelected, + alertSelected, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx new file mode 100644 index 000000000000..618b36578d7d --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { ProcessTreeAlerts } from './index'; + +describe('ProcessTreeAlerts component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When ProcessTreeAlerts is mounted', () => { + it('should return null if no alerts', async () => { + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeNull(); + }); + + it('should return an array of alert details', async () => { + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy(); + mockAlerts.forEach((alert) => { + if (!alert.kibana) { + return; + } + const { uuid, rule, original_event: event, workflow_status: status } = alert.kibana.alert; + const { name, query, severity } = rule; + + expect( + renderResult.queryByTestId(`sessionView:sessionViewAlertDetail-${uuid}`) + ).toBeTruthy(); + expect( + renderResult.queryByTestId(`sessionView:sessionViewAlertDetailViewRule-${uuid}`) + ).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(event.action, 'i')).length).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(status, 'i')).length).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(name, 'i')).length).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(query, 'i')).length).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(severity, 'i')).length).toBeTruthy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx new file mode 100644 index 000000000000..5312c09867b9 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButton, EuiText, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useStyles } from './styles'; +import { ProcessEvent } from '../../../common/types/process_tree'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { CoreStart } from '../../../../../../src/core/public'; + +interface ProcessTreeAlertsDeps { + alerts: ProcessEvent[]; +} + +const getRuleUrl = (alert: ProcessEvent, http: CoreStart['http']) => { + return http.basePath.prepend(`/app/security/rules/id/${alert.kibana?.alert.rule.uuid}`); +}; + +const ProcessTreeAlert = ({ alert }: { alert: ProcessEvent }) => { + const { http } = useKibana().services; + + if (!alert.kibana) { + return null; + } + + const { uuid, rule, original_event: event, workflow_status: status } = alert.kibana.alert; + const { name, query, severity } = rule; + + return ( + + + +
    + +
    + {name} +
    + +
    + {query} +
    + +
    + +
    + {severity} +
    + +
    + {status} +
    + +
    + +
    + {event.action} + +
    + + + +
    +
    +
    +
    + ); +}; + +export function ProcessTreeAlerts({ alerts }: ProcessTreeAlertsDeps) { + const styles = useStyles(); + + if (alerts.length === 0) { + return null; + } + + return ( +
    + {alerts.map((alert: ProcessEvent) => ( + + ))} +
    + ); +} diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts new file mode 100644 index 000000000000..d60189159130 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.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 { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { size, colors, border } = euiTheme; + + const container: CSSObject = { + marginTop: size.s, + marginRight: size.s, + color: colors.text, + padding: size.m, + borderStyle: 'solid', + borderColor: colors.lightShade, + borderWidth: border.width.thin, + borderRadius: border.radius.medium, + maxWidth: 800, + backgroundColor: 'white', + '&>div': { + borderTop: border.thin, + marginTop: size.m, + paddingTop: size.m, + '&:first-child': { + borderTop: 'none', + }, + }, + }; + + return { + container, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx new file mode 100644 index 000000000000..16cb94617469 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiButton, EuiIcon, EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { Process } from '../../../common/types/process_tree'; +import { useButtonStyles } from './use_button_styles'; + +export const ChildrenProcessesButton = ({ + onToggle, + isExpanded, +}: { + onToggle: () => void; + isExpanded: boolean; +}) => { + const { button, buttonArrow, getExpandedIcon } = useButtonStyles(); + + return ( + + + + + ); +}; + +export const SessionLeaderButton = ({ + process, + onClick, + showGroupLeadersOnly, + childCount, +}: { + process: Process; + onClick: () => void; + showGroupLeadersOnly: boolean; + childCount: number; +}) => { + const groupLeaderCount = process.getChildren(false).length; + const sameGroupCount = childCount - groupLeaderCount; + const { button, buttonArrow, getExpandedIcon } = useButtonStyles(); + + if (sameGroupCount > 0) { + return ( + + +

    + } + > + + + + +
    + ); + } + return null; +}; + +export const AlertButton = ({ + isExpanded, + onToggle, +}: { + isExpanded: boolean; + onToggle: () => void; +}) => { + const { alertButton, buttonArrow, getExpandedIcon } = useButtonStyles(); + + return ( + + + + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx new file mode 100644 index 000000000000..2a3bf9408602 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { + processMock, + childProcessMock, + sessionViewAlertProcessMock, +} from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { ProcessTreeNode } from './index'; + +describe('ProcessTreeNode component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When ProcessTreeNode is mounted', () => { + it('should render given a valid process', async () => { + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('sessionView:processTreeNode')).toBeTruthy(); + }); + + it('should have an alternate rendering for a session leader', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.container.textContent).toEqual(' bash started by vagrant'); + }); + + // commented out until we get new UX for orphans treatment aka disjointed tree + // it('renders orphaned node', async () => { + // renderResult = mockedContext.render(); + // expect(renderResult.queryByText(/orphaned/i)).toBeTruthy(); + // }); + + it('renders Exec icon and exit code for executed process', async () => { + const executedProcessMock: typeof processMock = { + ...processMock, + hasExec: () => true, + }; + + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('sessionView:processTreeNodeExecIcon')).toBeTruthy(); + expect(renderResult.queryByTestId('sessionView:processTreeNodeExitCode')).toBeTruthy(); + }); + + it('does not render exit code if it does not exist', async () => { + const processWithoutExitCode: typeof processMock = { + ...processMock, + hasExec: () => true, + getDetails: () => ({ + ...processMock.getDetails(), + process: { + ...processMock.getDetails().process, + exit_code: undefined, + }, + }), + }; + + renderResult = mockedContext.render(); + expect(renderResult.queryByTestId('sessionView:processTreeNodeExitCode')).toBeFalsy(); + }); + + it('renders Root Escalation flag properly', async () => { + const rootEscalationProcessMock: typeof processMock = { + ...processMock, + getDetails: () => ({ + ...processMock.getDetails(), + user: { + id: '-1', + name: 'root', + }, + process: { + ...processMock.getDetails().process, + parent: { + ...processMock.getDetails().process.parent, + user: { + name: 'test', + id: '1000', + }, + }, + }, + }), + }; + + renderResult = mockedContext.render(); + + expect( + renderResult.queryByTestId('sessionView:processTreeNodeRootEscalationFlag') + ).toBeTruthy(); + }); + + it('executes callback function when user Clicks', async () => { + const onProcessSelected = jest.fn(); + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId('sessionView:processTreeNodeRow')); + expect(onProcessSelected).toHaveBeenCalled(); + }); + + it('does not executes callback function when user is Clicking to copy text', async () => { + const windowGetSelectionSpy = jest.spyOn(window, 'getSelection'); + + const onProcessSelected = jest.fn(); + + renderResult = mockedContext.render( + + ); + + // @ts-ignore + windowGetSelectionSpy.mockImplementation(() => ({ type: 'Range' })); + + userEvent.click(renderResult.getByTestId('sessionView:processTreeNodeRow')); + expect(onProcessSelected).not.toHaveBeenCalled(); + + // cleanup + windowGetSelectionSpy.mockRestore(); + }); + describe('Alerts', () => { + it('renders Alert button when process has alerts', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('processTreeNodeAlertButton')).toBeTruthy(); + }); + it('toggle Alert Details button when Alert button is clicked', async () => { + renderResult = mockedContext.render( + + ); + userEvent.click(renderResult.getByTestId('processTreeNodeAlertButton')); + expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy(); + userEvent.click(renderResult.getByTestId('processTreeNodeAlertButton')); + expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeFalsy(); + }); + }); + describe('Child processes', () => { + it('renders Child processes button when process has Child processes', async () => { + const processMockWithChildren: typeof processMock = { + ...processMock, + getChildren: () => [childProcessMock], + }; + + renderResult = mockedContext.render(); + + expect( + renderResult.queryByTestId('sessionView:processTreeNodeChildProcessesButton') + ).toBeTruthy(); + }); + it('toggle Child processes nodes when Child processes button is clicked', async () => { + const processMockWithChildren: typeof processMock = { + ...processMock, + getChildren: () => [childProcessMock], + }; + + renderResult = mockedContext.render(); + + expect(renderResult.getAllByTestId('sessionView:processTreeNode')).toHaveLength(1); + + userEvent.click( + renderResult.getByTestId('sessionView:processTreeNodeChildProcessesButton') + ); + expect(renderResult.getAllByTestId('sessionView:processTreeNode')).toHaveLength(2); + + userEvent.click( + renderResult.getByTestId('sessionView:processTreeNodeChildProcessesButton') + ); + expect(renderResult.getAllByTestId('sessionView:processTreeNode')).toHaveLength(1); + }); + }); + describe('Search', () => { + it('highlights text within the process node line item if it matches the searchQuery', () => { + // set a mock search matched indicator for the process (typically done by ProcessTree/helpers.ts) + processMock.searchMatched = '/vagrant'; + + renderResult = mockedContext.render(); + + expect( + renderResult.getByTestId('sessionView:processNodeSearchHighlight').textContent + ).toEqual('/vagrant'); + }); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx new file mode 100644 index 000000000000..9db83f58f773 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx @@ -0,0 +1,213 @@ +/* + * Copyright 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 React, { + useRef, + useLayoutEffect, + useState, + useEffect, + MouseEvent, + useCallback, +} from 'react'; +import { EuiButton, EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { Process } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; +import { ProcessTreeAlerts } from '../process_tree_alerts'; +import { SessionLeaderButton, AlertButton, ChildrenProcessesButton } from './buttons'; +import { useButtonStyles } from './use_button_styles'; +interface ProcessDeps { + process: Process; + isSessionLeader?: boolean; + depth?: number; + onProcessSelected?: (process: Process) => void; +} + +/** + * Renders a node on the process tree + */ +export function ProcessTreeNode({ + process, + isSessionLeader = false, + depth = 0, + onProcessSelected, +}: ProcessDeps) { + const textRef = useRef(null); + + const [childrenExpanded, setChildrenExpanded] = useState(isSessionLeader || process.autoExpand); + const [alertsExpanded, setAlertsExpanded] = useState(false); + const [showGroupLeadersOnly, setShowGroupLeadersOnly] = useState(isSessionLeader); + const { searchMatched } = process; + + useEffect(() => { + setChildrenExpanded(isSessionLeader || process.autoExpand); + }, [isSessionLeader, process.autoExpand]); + + const alerts = process.getAlerts(); + const styles = useStyles({ depth, hasAlerts: !!alerts.length }); + const buttonStyles = useButtonStyles(); + + useLayoutEffect(() => { + if (searchMatched !== null && textRef.current) { + const regex = new RegExp(searchMatched); + const text = textRef.current.textContent; + + if (text) { + const html = text.replace(regex, (match) => { + return `${match}`; + }); + + // eslint-disable-next-line no-unsanitized/property + textRef.current.innerHTML = html; + } + } + }, [searchMatched, styles.searchHighlight]); + + const onShowGroupLeaderOnlyClick = useCallback(() => { + setShowGroupLeadersOnly(!showGroupLeadersOnly); + }, [showGroupLeadersOnly]); + + const onChildrenToggle = useCallback(() => { + setChildrenExpanded(!childrenExpanded); + }, [childrenExpanded]); + + const onAlertsToggle = useCallback(() => { + setAlertsExpanded(!alertsExpanded); + }, [alertsExpanded]); + + const onProcessClicked = (e: MouseEvent) => { + e.stopPropagation(); + + const selection = window.getSelection(); + + // do not select the command if the user was just selecting text for copy. + if (selection && selection.type === 'Range') { + return; + } + + onProcessSelected?.(process); + }; + + const processDetails = process.getDetails(); + + if (!processDetails) { + return null; + } + + const id = process.id; + const { user } = processDetails; + const { + args, + name, + tty, + parent, + working_directory: workingDirectory, + exit_code: exitCode, + } = processDetails.process; + + const children = process.getChildren(!showGroupLeadersOnly); + const childCount = process.getChildren(true).length; + const shouldRenderChildren = childrenExpanded && children && children.length > 0; + const childrenTreeDepth = depth + 1; + + const showRootEscalation = user.name === 'root' && user.id !== parent.user.id; + const interactiveSession = !!tty; + const sessionIcon = interactiveSession ? 'consoleApp' : 'compute'; + const hasExec = process.hasExec(); + const iconTestSubj = hasExec + ? 'sessionView:processTreeNodeExecIcon' + : 'sessionView:processTreeNodeForkIcon'; + const processIcon = hasExec ? 'console' : 'branch'; + + return ( +
    +
    + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */} +
    + {isSessionLeader ? ( + <> + {name || args[0]}{' '} + {' '} + {user.name} + + + ) : ( + + + + {workingDirectory}  + {args[0]}  + {args.slice(1).join(' ')} + {exitCode !== undefined && ( + + {' '} + [exit_code: {exitCode}] + + )} + + + )} + + {showRootEscalation && ( + + + + )} + {!isSessionLeader && childCount > 0 && ( + + )} + {alerts.length > 0 && ( + + )} +
    +
    + + {alertsExpanded && } + + {shouldRenderChildren && ( +
    + {children.map((child) => { + return ( + + ); + })} +
    + )} +
    + ); +} diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts new file mode 100644 index 000000000000..07092d6de28e --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +interface StylesDeps { + depth: number; + hasAlerts: boolean; +} + +export const useStyles = ({ depth, hasAlerts }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, border, size } = euiTheme; + + const TREE_INDENT = euiTheme.base * 2; + + const darkText: CSSObject = { + color: colors.text, + }; + + const searchHighlight = ` + background-color: ${colors.highlight}; + color: ${colors.fullShade}; + border-radius: ${border.radius.medium}; + `; + + const children: CSSObject = { + position: 'relative', + color: colors.ghost, + marginLeft: size.base, + paddingLeft: size.s, + borderLeft: border.editable, + marginTop: size.s, + }; + + /** + * gets border, bg and hover colors for a process + */ + const getHighlightColors = () => { + let bgColor = 'none'; + const hoverColor = transparentize(colors.primary, 0.04); + let borderColor = 'transparent'; + + // TODO: alerts highlight colors + if (hasAlerts) { + bgColor = transparentize(colors.danger, 0.04); + borderColor = transparentize(colors.danger, 0.48); + } + + return { bgColor, borderColor, hoverColor }; + }; + + const { bgColor, borderColor, hoverColor } = getHighlightColors(); + + const processNode: CSSObject = { + display: 'block', + cursor: 'pointer', + position: 'relative', + margin: `${size.s} 0px`, + '&:not(:first-child)': { + marginTop: size.s, + }, + '&:hover:before': { + backgroundColor: hoverColor, + }, + '&:before': { + position: 'absolute', + height: '100%', + pointerEvents: 'none', + content: `''`, + marginLeft: `-${depth * TREE_INDENT}px`, + borderLeft: `${size.xs} solid ${borderColor}`, + backgroundColor: bgColor, + width: `calc(100% + ${depth * TREE_INDENT}px)`, + }, + }; + + const wrapper: CSSObject = { + paddingLeft: size.s, + position: 'relative', + verticalAlign: 'middle', + color: colors.mediumShade, + wordBreak: 'break-all', + minHeight: size.l, + lineHeight: size.l, + }; + + const workingDir: CSSObject = { + color: colors.successText, + }; + + const alertDetails: CSSObject = { + padding: size.s, + border: border.editable, + borderRadius: border.radius.medium, + }; + + return { + darkText, + searchHighlight, + children, + processNode, + wrapper, + workingDir, + alertDetails, + }; + }, [depth, euiTheme, hasAlerts]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts b/x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts new file mode 100644 index 000000000000..d208fa8f079a --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { euiLightVars as theme } from '@kbn/ui-theme'; +import { CSSObject } from '@emotion/react'; + +export const useButtonStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, border, font, size } = euiTheme; + + const button: CSSObject = { + background: transparentize(theme.euiColorVis6, 0.04), + border: `${border.width.thin} solid ${transparentize(theme.euiColorVis6, 0.48)}`, + lineHeight: '18px', + height: '20px', + fontSize: '11px', + fontFamily: font.familyCode, + borderRadius: border.radius.medium, + color: colors.text, + marginLeft: size.s, + minWidth: 0, + }; + + const buttonArrow: CSSObject = { + marginLeft: size.s, + }; + + const alertButton: CSSObject = { + ...button, + background: transparentize(colors.dangerText, 0.04), + border: `${border.width.thin} solid ${transparentize(colors.dangerText, 0.48)}`, + }; + + const userChangedButton: CSSObject = { + ...button, + background: transparentize(theme.euiColorVis1, 0.04), + border: `${border.width.thin} solid ${transparentize(theme.euiColorVis1, 0.48)}`, + }; + + const getExpandedIcon = (expanded: boolean) => { + return expanded ? 'arrowUp' : 'arrowDown'; + }; + + return { + buttonArrow, + button, + alertButton, + userChangedButton, + getExpandedIcon, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/session_view/hooks.ts b/x-pack/plugins/session_view/public/components/session_view/hooks.ts new file mode 100644 index 000000000000..b93e5b43ddf8 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view/hooks.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useEffect, useState } from 'react'; +import { useInfiniteQuery } from 'react-query'; +import { EuiSearchBarOnChangeArgs } from '@elastic/eui'; +import { CoreStart } from 'kibana/public'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { ProcessEvent, ProcessEventResults } from '../../../common/types/process_tree'; +import { PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE } from '../../../common/constants'; + +export const useFetchSessionViewProcessEvents = ( + sessionEntityId: string, + jumpToEvent: ProcessEvent | undefined +) => { + const { http } = useKibana().services; + + const jumpToCursor = jumpToEvent && jumpToEvent['@timestamp']; + + const query = useInfiniteQuery( + 'sessionViewProcessEvents', + async ({ pageParam = {} }) => { + let { cursor } = pageParam; + const { forward } = pageParam; + + if (!cursor && jumpToCursor) { + cursor = jumpToCursor; + } + + const res = await http.get(PROCESS_EVENTS_ROUTE, { + query: { + sessionEntityId, + cursor, + forward, + }, + }); + + const events = res.events.map((event: any) => event._source as ProcessEvent); + + return { events, cursor }; + }, + { + getNextPageParam: (lastPage, pages) => { + if (lastPage.events.length === PROCESS_EVENTS_PER_PAGE) { + return { + cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'], + forward: true, + }; + } + }, + getPreviousPageParam: (firstPage, pages) => { + if (jumpToEvent && firstPage.events.length === PROCESS_EVENTS_PER_PAGE) { + return { + cursor: firstPage.events[0]['@timestamp'], + forward: false, + }; + } + }, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + } + ); + + useEffect(() => { + if (jumpToEvent && query.data?.pages.length === 1) { + query.fetchPreviousPage(); + } + }, [jumpToEvent, query]); + + return query; +}; + +export const useSearchQuery = () => { + const [searchQuery, setSearchQuery] = useState(''); + const onSearch = ({ query }: EuiSearchBarOnChangeArgs) => { + if (query) { + setSearchQuery(query.text); + } else { + setSearchQuery(''); + } + }; + + return { + searchQuery, + onSearch, + }; +}; diff --git a/x-pack/plugins/session_view/public/components/session_view/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view/index.test.tsx new file mode 100644 index 000000000000..41336977cf78 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view/index.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 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 { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import React from 'react'; +import { sessionViewProcessEventsMock } from '../../../common/mocks/responses/session_view_process_events.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { SessionView } from './index'; +import userEvent from '@testing-library/user-event'; + +describe('SessionView component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let mockedApi: AppContextTestRender['coreStart']['http']['get']; + + const waitForApiCall = () => waitFor(() => expect(mockedApi).toHaveBeenCalled()); + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockedApi = mockedContext.coreStart.http.get; + render = () => + (renderResult = mockedContext.render()); + }); + + describe('When SessionView is mounted', () => { + describe('And no data exists', () => { + beforeEach(async () => { + mockedApi.mockResolvedValue({ + events: [], + }); + }); + + it('should show the Empty message', async () => { + render(); + await waitForApiCall(); + expect(renderResult.getByTestId('sessionView:sessionViewProcessEventsEmpty')).toBeTruthy(); + }); + + it('should not display the search bar', async () => { + render(); + await waitForApiCall(); + expect( + renderResult.queryByTestId('sessionView:sessionViewProcessEventsSearch') + ).toBeFalsy(); + }); + }); + + describe('And data exists', () => { + beforeEach(async () => { + mockedApi.mockResolvedValue(sessionViewProcessEventsMock); + }); + + it('should show loading indicator while retrieving data and hide it when it gets it', async () => { + let releaseApiResponse: (value?: unknown) => void; + + // make the request wait + mockedApi.mockReturnValue(new Promise((resolve) => (releaseApiResponse = resolve))); + render(); + await waitForApiCall(); + + // see if loader is present + expect(renderResult.getByText('Loading session…')).toBeTruthy(); + + // release the request + releaseApiResponse!(mockedApi); + + // check the loader is gone + await waitForElementToBeRemoved(renderResult.getByText('Loading session…')); + }); + + it('should display the search bar', async () => { + render(); + await waitForApiCall(); + expect(renderResult.getByTestId('sessionView:sessionViewProcessEventsSearch')).toBeTruthy(); + }); + + it('should show items on the list, and auto selects session leader', async () => { + render(); + await waitForApiCall(); + + expect(renderResult.getAllByTestId('sessionView:processTreeNode')).toBeTruthy(); + + const selectionArea = renderResult.queryByTestId('sessionView:processTreeSelectionArea'); + + expect(selectionArea?.parentElement?.getAttribute('data-id')).toEqual('test-entity-id'); + }); + + it('should toggle detail panel visibilty when detail button clicked', async () => { + render(); + await waitForApiCall(); + + userEvent.click(renderResult.getByTestId('sessionViewDetailPanelToggle')); + expect(renderResult.getByText('Process')).toBeTruthy(); + expect(renderResult.getByText('Host')).toBeTruthy(); + expect(renderResult.getByText('Alerts')).toBeTruthy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/session_view/index.tsx b/x-pack/plugins/session_view/public/components/session_view/index.tsx new file mode 100644 index 000000000000..7a82edc94ff1 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view/index.tsx @@ -0,0 +1,205 @@ +/* + * Copyright 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, useCallback } from 'react'; +import { + EuiEmptyPrompt, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiResizableContainer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { SectionLoading } from '../../shared_imports'; +import { ProcessTree } from '../process_tree'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { SessionViewDetailPanel } from '../session_view_detail_panel'; +import { SessionViewSearchBar } from '../session_view_search_bar'; +import { useStyles } from './styles'; +import { useFetchSessionViewProcessEvents } from './hooks'; + +interface SessionViewDeps { + // the root node of the process tree to render. e.g process.entry.entity_id or process.session_leader.entity_id + sessionEntityId: string; + height?: number; + jumpToEvent?: ProcessEvent; +} + +/** + * The main wrapper component for the session view. + */ +export const SessionView = ({ sessionEntityId, height, jumpToEvent }: SessionViewDeps) => { + const [isDetailOpen, setIsDetailOpen] = useState(false); + const [selectedProcess, setSelectedProcess] = useState(null); + + const styles = useStyles({ height }); + + const onProcessSelected = useCallback((process: Process) => { + setSelectedProcess(process); + }, []); + + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState(null); + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetching, + fetchPreviousPage, + hasPreviousPage, + } = useFetchSessionViewProcessEvents(sessionEntityId, jumpToEvent); + + const hasData = data && data.pages.length > 0 && data.pages[0].events.length > 0; + const renderIsLoading = isFetching && !data; + const renderDetails = isDetailOpen && selectedProcess; + const toggleDetailPanel = () => { + setIsDetailOpen(!isDetailOpen); + }; + + if (!isFetching && !hasData) { + return ( + + + + } + body={ +

    + +

    + } + /> + ); + } + + return ( + <> + + + + + + + + + + + + {(EuiResizablePanel, EuiResizableButton) => ( + <> + + {renderIsLoading && ( + + + + )} + + {error && ( + + + + } + body={ +

    + +

    + } + /> + )} + + {hasData && ( +
    + +
    + )} +
    + + {renderDetails ? ( + <> + + + + + + ) : ( + <> + {/* Returning an empty element here (instead of false) to avoid a bug in EuiResizableContainer */} + + )} + + )} +
    + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SessionView as default }; diff --git a/x-pack/plugins/session_view/public/components/session_view/styles.ts b/x-pack/plugins/session_view/public/components/session_view/styles.ts new file mode 100644 index 000000000000..d7159ec5b1b3 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view/styles.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 { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +interface StylesDeps { + height: number | undefined; +} + +export const useStyles = ({ height = 500 }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const processTree: CSSObject = { + height: `${height}px`, + paddingTop: euiTheme.size.s, + }; + + const detailPanel: CSSObject = { + height: `${height}px`, + }; + + return { + processTree, + detailPanel, + }; + }, [height, euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/helpers.ts b/x-pack/plugins/session_view/public/components/session_view_detail_panel/helpers.ts new file mode 100644 index 000000000000..295371fbff96 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/helpers.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 { Process, ProcessFields } from '../../../common/types/process_tree'; +import { DetailPanelProcess, EuiTabProps } from '../../types'; + +const getDetailPanelProcessLeader = (leader: ProcessFields) => ({ + ...leader, + id: leader.entity_id, + entryMetaType: leader.entry_meta?.type || '', + userName: leader.user.name, + entryMetaSourceIp: leader.entry_meta?.source.ip || '', +}); + +export const getDetailPanelProcess = (process: Process) => { + const processData = {} as DetailPanelProcess; + + processData.id = process.id; + processData.start = process.events[0]['@timestamp']; + processData.end = process.events[process.events.length - 1]['@timestamp']; + const args = new Set(); + processData.executable = []; + + process.events.forEach((event) => { + if (!processData.user) { + processData.user = event.user.name; + } + if (!processData.pid) { + processData.pid = event.process.pid; + } + + if (event.process.args.length > 0) { + args.add(event.process.args.join(' ')); + } + if (event.process.executable) { + processData.executable.push([event.process.executable, `(${event.event.action})`]); + } + if (event.process.exit_code) { + processData.exit_code = event.process.exit_code; + } + }); + + processData.args = [...args]; + processData.entryLeader = getDetailPanelProcessLeader(process.events[0].process.entry_leader); + processData.sessionLeader = getDetailPanelProcessLeader(process.events[0].process.session_leader); + processData.groupLeader = getDetailPanelProcessLeader(process.events[0].process.group_leader); + processData.parent = getDetailPanelProcessLeader(process.events[0].process.parent); + + return processData; +}; + +export const getSelectedTabContent = (tabs: EuiTabProps[], selectedTabId: string) => { + const selectedTab = tabs.find((tab) => tab.id === selectedTabId); + + if (selectedTab) { + return selectedTab.content; + } + + return null; +}; diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx new file mode 100644 index 000000000000..f754086fe5fa --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx @@ -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 React from 'react'; +import { sessionViewBasicProcessMock } from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { SessionViewDetailPanel } from './index'; + +describe('SessionView component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When SessionViewDetailPanel is mounted', () => { + it('shows process detail by default', async () => { + renderResult = mockedContext.render( + + ); + expect(renderResult.queryByText('8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726')).toBeVisible(); + }); + + it('can switch tabs to show host details', async () => { + renderResult = mockedContext.render( + + ); + + renderResult.queryByText('Host')?.click(); + expect(renderResult.queryByText('hostname')).toBeVisible(); + expect(renderResult.queryAllByText('james-fleet-714-2')).toHaveLength(2); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx new file mode 100644 index 000000000000..a47ce1d91ac9 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.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 React, { useState, useMemo, useCallback } from 'react'; +import { EuiTabs, EuiTab, EuiNotificationBadge } from '@elastic/eui'; +import { EuiTabProps } from '../../types'; +import { Process } from '../../../common/types/process_tree'; +import { getDetailPanelProcess, getSelectedTabContent } from './helpers'; +import { DetailPanelProcessTab } from '../detail_panel_process_tab'; +import { DetailPanelHostTab } from '../detail_panel_host_tab'; + +interface SessionViewDetailPanelDeps { + selectedProcess: Process; + onProcessSelected?: (process: Process) => void; +} + +/** + * Detail panel in the session view. + */ +export const SessionViewDetailPanel = ({ selectedProcess }: SessionViewDetailPanelDeps) => { + const [selectedTabId, setSelectedTabId] = useState('process'); + const processDetail = useMemo(() => getDetailPanelProcess(selectedProcess), [selectedProcess]); + + const tabs: EuiTabProps[] = useMemo( + () => [ + { + id: 'process', + name: 'Process', + content: , + }, + { + id: 'host', + name: 'Host', + content: , + }, + { + id: 'alerts', + disabled: true, + name: 'Alerts', + append: ( + + 10 + + ), + content: null, + }, + ], + [processDetail, selectedProcess.events] + ); + + const onSelectedTabChanged = useCallback((id: string) => { + setSelectedTabId(id); + }, []); + + const tabContent = useMemo( + () => getSelectedTabContent(tabs, selectedTabId), + [tabs, selectedTabId] + ); + + return ( + <> + + {tabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + prepend={tab.prepend} + append={tab.append} + > + {tab.name} + + ))} + + {tabContent} + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/session_view_search_bar/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view_search_bar/index.test.tsx new file mode 100644 index 000000000000..b27260668af0 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_search_bar/index.test.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { processMock } from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { SessionViewSearchBar } from './index'; +import userEvent from '@testing-library/user-event'; +import { fireEvent } from '@testing-library/dom'; + +describe('SessionViewSearchBar component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + it('handles a typed search query', async () => { + const mockSetSearchQuery = jest.fn((query) => query); + const mockOnProcessSelected = jest.fn((process) => process); + + renderResult = mockedContext.render( + + ); + + const searchInput = renderResult.getByTestId('sessionView:searchInput').querySelector('input'); + + expect(searchInput?.value).toEqual('ls'); + + if (searchInput) { + userEvent.type(searchInput, ' -la'); + fireEvent.keyUp(searchInput, { key: 'Enter', code: 'Enter' }); + } + + expect(searchInput?.value).toEqual('ls -la'); + expect(mockSetSearchQuery.mock.calls.length).toBe(1); + expect(mockSetSearchQuery.mock.results[0].value).toBe('ls -la'); + }); + + it('shows a results navigator when searchResults provided', async () => { + const processMock2 = { ...processMock }; + const processMock3 = { ...processMock }; + const mockResults = [processMock, processMock2, processMock3]; + const mockSetSearchQuery = jest.fn((query) => query); + const mockOnProcessSelected = jest.fn((process) => process); + + renderResult = mockedContext.render( + + ); + + const searchPagination = renderResult.getByTestId('sessionView:searchPagination'); + expect(searchPagination).toBeTruthy(); + + const paginationTextClass = '.euiPagination__compressedText'; + expect(searchPagination.querySelector(paginationTextClass)?.textContent).toEqual('1 of 3'); + + userEvent.click(renderResult.getByTestId('pagination-button-next')); + expect(searchPagination.querySelector(paginationTextClass)?.textContent).toEqual('2 of 3'); + + const searchInput = renderResult.getByTestId('sessionView:searchInput').querySelector('input'); + + if (searchInput) { + userEvent.type(searchInput, ' -la'); + fireEvent.keyUp(searchInput, { key: 'Enter', code: 'Enter' }); + } + + // after search is changed, results index should reset to 1 + expect(searchPagination.querySelector(paginationTextClass)?.textContent).toEqual('1 of 3'); + + // setSelectedProcess should be called 3 times: + // 1. searchResults is set so auto select first item + // 2. next button hit, so call with 2nd item + // 3. search changed, so call with first result. + expect(mockOnProcessSelected.mock.calls.length).toBe(3); + expect(mockOnProcessSelected.mock.results[0].value).toEqual(processMock); + expect(mockOnProcessSelected.mock.results[1].value).toEqual(processMock2); + expect(mockOnProcessSelected.mock.results[1].value).toEqual(processMock); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/session_view_search_bar/index.tsx b/x-pack/plugins/session_view/public/components/session_view_search_bar/index.tsx new file mode 100644 index 000000000000..f4e4dac7a94c --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_search_bar/index.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useEffect } from 'react'; +import { EuiSearchBar, EuiPagination } from '@elastic/eui'; +import { EuiSearchBarOnChangeArgs } from '@elastic/eui'; +import { Process } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; + +interface SessionViewSearchBarDeps { + searchQuery: string; + setSearchQuery(val: string): void; + searchResults: Process[] | null; + onProcessSelected(process: Process): void; +} + +/** + * The main wrapper component for the session view. + */ +export const SessionViewSearchBar = ({ + searchQuery, + setSearchQuery, + onProcessSelected, + searchResults, +}: SessionViewSearchBarDeps) => { + const styles = useStyles(); + + const [selectedResult, setSelectedResult] = useState(0); + + const onSearch = ({ query }: EuiSearchBarOnChangeArgs) => { + setSelectedResult(0); + + if (query) { + setSearchQuery(query.text); + } else { + setSearchQuery(''); + } + }; + + useEffect(() => { + if (searchResults) { + const process = searchResults[selectedResult]; + + if (process) { + onProcessSelected(process); + } + } + }, [searchResults, onProcessSelected, selectedResult]); + + const showPagination = !!searchResults?.length; + + return ( +
    + + {showPagination && ( + + )} +
    + ); +}; diff --git a/x-pack/plugins/session_view/public/components/session_view_search_bar/styles.ts b/x-pack/plugins/session_view/public/components/session_view_search_bar/styles.ts new file mode 100644 index 000000000000..97a49ca2aa8c --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_search_bar/styles.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 { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const pagination: CSSObject = { + position: 'absolute', + top: euiTheme.size.s, + right: euiTheme.size.xxl, + }; + + return { + pagination, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/hooks/use_scroll.ts b/x-pack/plugins/session_view/public/hooks/use_scroll.ts new file mode 100644 index 000000000000..716e35dbb098 --- /dev/null +++ b/x-pack/plugins/session_view/public/hooks/use_scroll.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 } from 'react'; +import _ from 'lodash'; + +const SCROLL_END_BUFFER_HEIGHT = 20; +const DEBOUNCE_TIMEOUT = 500; + +function getScrollPosition(div: HTMLElement) { + if (div) { + return div.scrollTop; + } else { + return document.documentElement.scrollTop || document.body.scrollTop; + } +} + +interface IUseScrollDeps { + div: HTMLElement | null; + handler(pos: number, endReached: boolean): void; +} + +/** + * listens to scroll events on given div, if scroll reaches bottom, calls a callback + * @param {ref} ref to listen to scroll events on + * @param {function} handler function receives params (scrollTop, endReached) + */ +export function useScroll({ div, handler }: IUseScrollDeps) { + useEffect(() => { + if (div) { + const debounced = _.debounce(() => { + const pos = getScrollPosition(div); + const endReached = pos + div.offsetHeight > div.scrollHeight - SCROLL_END_BUFFER_HEIGHT; + + handler(pos, endReached); + }, DEBOUNCE_TIMEOUT); + + div.onscroll = debounced; + + return () => { + debounced.cancel(); + + div.onscroll = null; + }; + } + }, [div, handler]); +} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/index.ts b/x-pack/plugins/session_view/public/index.ts similarity index 69% rename from x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/index.ts rename to x-pack/plugins/session_view/public/index.ts index 4dd64e5c2f93..90043e9a691d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/index.ts +++ b/x-pack/plugins/session_view/public/index.ts @@ -5,4 +5,8 @@ * 2.0. */ -export { PolicyEventFiltersDeleteModal } from './policy_event_filters_delete_modal'; +import { SessionViewPlugin } from './plugin'; + +export function plugin() { + return new SessionViewPlugin(); +} diff --git a/x-pack/plugins/session_view/public/methods/index.tsx b/x-pack/plugins/session_view/public/methods/index.tsx new file mode 100644 index 000000000000..560bb302ebab --- /dev/null +++ b/x-pack/plugins/session_view/public/methods/index.tsx @@ -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 React, { lazy, Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { QueryClient, QueryClientProvider } from 'react-query'; + +// Initializing react-query +const queryClient = new QueryClient(); + +const SessionViewLazy = lazy(() => import('../components/session_view')); + +export const getSessionViewLazy = (sessionEntityId: string) => { + return ( + + }> + + + + ); +}; diff --git a/x-pack/plugins/session_view/public/plugin.ts b/x-pack/plugins/session_view/public/plugin.ts new file mode 100644 index 000000000000..d25c95b00b2c --- /dev/null +++ b/x-pack/plugins/session_view/public/plugin.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 { CoreSetup, CoreStart, Plugin } from '../../../../src/core/public'; +import { SessionViewServices } from './types'; +import { getSessionViewLazy } from './methods'; + +export class SessionViewPlugin implements Plugin { + public setup(core: CoreSetup) {} + + public start(core: CoreStart) { + return { + getSessionView: (sessionEntityId: string) => getSessionViewLazy(sessionEntityId), + }; + } + + public stop() {} +} diff --git a/x-pack/plugins/lens/common/expressions/metric_chart/index.ts b/x-pack/plugins/session_view/public/shared_imports.ts similarity index 76% rename from x-pack/plugins/lens/common/expressions/metric_chart/index.ts rename to x-pack/plugins/session_view/public/shared_imports.ts index 40bd4f388645..0a087e1ac36a 100644 --- a/x-pack/plugins/lens/common/expressions/metric_chart/index.ts +++ b/x-pack/plugins/session_view/public/shared_imports.ts @@ -5,5 +5,4 @@ * 2.0. */ -export * from './types'; -export * from './metric_chart'; +export { SectionLoading } from '../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/plugins/session_view/public/test/index.tsx b/x-pack/plugins/session_view/public/test/index.tsx new file mode 100644 index 000000000000..8570e142538d --- /dev/null +++ b/x-pack/plugins/session_view/public/test/index.tsx @@ -0,0 +1,137 @@ +/* + * Copyright 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, { memo, ReactNode, useMemo } from 'react'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react'; +import { QueryClient, QueryClientProvider, setLogger } from 'react-query'; +import { Router } from 'react-router-dom'; +import { History } from 'history'; +import useObservable from 'react-use/lib/useObservable'; +import { I18nProvider } from '@kbn/i18n-react'; +import { CoreStart } from 'src/core/public'; +import { coreMock } from 'src/core/public/mocks'; +import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; +import { EuiThemeProvider } from 'src/plugins/kibana_react/common'; + +type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; + +// hide react-query output in console +setLogger({ + error: () => {}, + // eslint-disable-next-line no-console + log: console.log, + // eslint-disable-next-line no-console + warn: console.warn, +}); + +/** + * Mocked app root context renderer + */ +export interface AppContextTestRender { + history: ReturnType; + coreStart: ReturnType; + /** + * A wrapper around `AppRootContext` component. Uses the mocked modules as input to the + * `AppRootContext` + */ + AppWrapper: React.FC; + /** + * Renders the given UI within the created `AppWrapper` providing the given UI a mocked + * endpoint runtime context environment + */ + render: UiRender; +} + +const createCoreStartMock = ( + history: MemoryHistory +): ReturnType => { + const coreStart = coreMock.createStart({ basePath: '/mock' }); + + // Mock the certain APP Ids returned by `application.getUrlForApp()` + coreStart.application.getUrlForApp.mockImplementation((appId) => { + switch (appId) { + case 'sessionView': + return '/app/sessionView'; + default: + return `${appId} not mocked!`; + } + }); + + coreStart.application.navigateToUrl.mockImplementation((url) => { + history.push(url.replace('/app/sessionView', '')); + return Promise.resolve(); + }); + + return coreStart; +}; + +const AppRootProvider = memo<{ + history: History; + coreStart: CoreStart; + children: ReactNode | ReactNode[]; +}>(({ history, coreStart: { http, notifications, uiSettings, application }, children }) => { + const isDarkMode = useObservable(uiSettings.get$('theme:darkMode')); + const services = useMemo( + () => ({ http, notifications, application }), + [application, http, notifications] + ); + return ( + + + + {children} + + + + ); +}); + +AppRootProvider.displayName = 'AppRootProvider'; + +/** + * Creates a mocked app context custom renderer that can be used to render + * component that depend upon the application's surrounding context providers. + * Factory also returns the content that was used to create the custom renderer, allowing + * for further customization. + */ + +export const createAppRootMockRenderer = (): AppContextTestRender => { + const history = createMemoryHistory(); + const coreStart = createCoreStartMock(history); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // turns retries off + retry: false, + // prevent jest did not exit errors + cacheTime: Infinity, + }, + }, + }); + + const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => ( + + {children} + + ); + + const render: UiRender = (ui, options = {}) => { + return reactRender(ui, { + wrapper: AppWrapper as React.ComponentType, + ...options, + }); + }; + + return { + history, + coreStart, + AppWrapper, + render, + }; +}; diff --git a/x-pack/plugins/session_view/public/types.ts b/x-pack/plugins/session_view/public/types.ts new file mode 100644 index 000000000000..2349b8423eb3 --- /dev/null +++ b/x-pack/plugins/session_view/public/types.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ReactNode } from 'react'; +import { CoreStart } from '../../../../src/core/public'; +import { TimelinesUIStart } from '../../timelines/public'; + +export type SessionViewServices = CoreStart & { + timelines: TimelinesUIStart; +}; + +export interface EuiTabProps { + id: string; + name: string; + content: ReactNode; + disabled?: boolean; + append?: ReactNode; + prepend?: ReactNode; +} + +export interface DetailPanelProcess { + id: string; + start: string; + end: string; + exit_code: number; + user: string; + args: string[]; + executable: string[][]; + pid: number; + entryLeader: DetailPanelProcessLeader; + sessionLeader: DetailPanelProcessLeader; + groupLeader: DetailPanelProcessLeader; + parent: DetailPanelProcessLeader; +} + +export interface DetailPanelProcessLeader { + id: string; + name: string; + start: string; + entryMetaType: string; + userName: string; + interactive: boolean; + pid: number; + entryMetaSourceIp: string; + executable: string; +} diff --git a/x-pack/plugins/session_view/public/utils/data_or_dash.test.ts b/x-pack/plugins/session_view/public/utils/data_or_dash.test.ts new file mode 100644 index 000000000000..12ef44cf1d70 --- /dev/null +++ b/x-pack/plugins/session_view/public/utils/data_or_dash.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 { dataOrDash } from './data_or_dash'; + +const TEST_STRING = '123'; +const TEST_NUMBER = 123; +const DASH = '-'; + +describe('dataOrDash(data)', () => { + it('works for a valid string', () => { + expect(dataOrDash(TEST_STRING)).toEqual(TEST_STRING); + }); + it('works for a valid number', () => { + expect(dataOrDash(TEST_NUMBER)).toEqual(TEST_NUMBER); + }); + it('returns dash for undefined', () => { + expect(dataOrDash(undefined)).toEqual(DASH); + }); + it('returns dash for empty string', () => { + expect(dataOrDash('')).toEqual(DASH); + }); + it('returns dash for NaN', () => { + expect(dataOrDash(NaN)).toEqual(DASH); + }); +}); diff --git a/x-pack/plugins/session_view/public/utils/data_or_dash.ts b/x-pack/plugins/session_view/public/utils/data_or_dash.ts new file mode 100644 index 000000000000..ff6c2fb9bc1f --- /dev/null +++ b/x-pack/plugins/session_view/public/utils/data_or_dash.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. + */ + +/** + * Returns a dash ('-') if data is undefined, and empty string, or a NaN. + * + * Used by frontend components + * + * @param {String | Number | undefined} data + * @return {String | Number} either data itself or if invalid, a dash ('-') + */ +export const dataOrDash = (data: string | number | undefined): string | number => { + if (data === undefined || data === '' || (typeof data === 'number' && isNaN(data))) { + return '-'; + } + + return data; +}; diff --git a/x-pack/plugins/session_view/server/index.ts b/x-pack/plugins/session_view/server/index.ts new file mode 100644 index 000000000000..a86684094dfd --- /dev/null +++ b/x-pack/plugins/session_view/server/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext } from '../../../../src/core/server'; +import { SessionViewPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new SessionViewPlugin(initializerContext); +} diff --git a/x-pack/plugins/session_view/server/plugin.ts b/x-pack/plugins/session_view/server/plugin.ts new file mode 100644 index 000000000000..c7fd511b3de0 --- /dev/null +++ b/x-pack/plugins/session_view/server/plugin.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CoreSetup, + CoreStart, + Plugin, + Logger, + PluginInitializerContext, +} from '../../../../src/core/server'; +import { SessionViewSetupPlugins, SessionViewStartPlugins } from './types'; +import { registerRoutes } from './routes'; + +export class SessionViewPlugin implements Plugin { + private logger: Logger; + + /** + * Initialize SessionViewPlugin class properties (logger, etc) that is accessible + * through the initializerContext. + */ + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup, plugins: SessionViewSetupPlugins) { + this.logger.debug('session view: Setup'); + const router = core.http.createRouter(); + + // Register server routes + registerRoutes(router); + } + + public start(core: CoreStart, plugins: SessionViewStartPlugins) { + this.logger.debug('session view: Start'); + } + + public stop() { + this.logger.debug('session view: Stop'); + } +} diff --git a/x-pack/plugins/session_view/server/routes/index.ts b/x-pack/plugins/session_view/server/routes/index.ts new file mode 100644 index 000000000000..7b9cfb45f580 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { IRouter } from '../../../../../src/core/server'; +import { registerProcessEventsRoute } from './process_events_route'; +import { sessionEntryLeadersRoute } from './session_entry_leaders_route'; + +export const registerRoutes = (router: IRouter) => { + registerProcessEventsRoute(router); + sessionEntryLeadersRoute(router); +}; diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.test.ts b/x-pack/plugins/session_view/server/routes/process_events_route.test.ts new file mode 100644 index 000000000000..76f54eb4b8ab --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/process_events_route.test.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 { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { doSearch } from './process_events_route'; +import { mockEvents } from '../../common/mocks/constants/session_view_process.mock'; + +const getEmptyResponse = async () => { + return { + hits: { + total: { value: 0, relation: 'eq' }, + hits: [], + }, + }; +}; + +const getResponse = async () => { + return { + hits: { + total: { value: mockEvents.length, relation: 'eq' }, + hits: mockEvents.map((event) => { + return { _source: event }; + }), + }, + }; +}; + +describe('process_events_route.ts', () => { + describe('doSearch(client, entityId, cursor, forward)', () => { + it('should return an empty events array for a non existant entity_id', async () => { + const client = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse()); + + const body = await doSearch(client, 'asdf', undefined); + + expect(body.events.length).toBe(0); + }); + + it('returns results for a particular session entity_id', async () => { + const client = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + + const body = await doSearch(client, 'mockId', undefined); + + expect(body.events.length).toBe(mockEvents.length); + }); + + it('returns hits in reverse order when paginating backwards', async () => { + const client = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + + const body = await doSearch(client, 'mockId', undefined, false); + + expect(body.events[0]._source).toEqual(mockEvents[mockEvents.length - 1]); + }); + }); +}); diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.ts b/x-pack/plugins/session_view/server/routes/process_events_route.ts new file mode 100644 index 000000000000..47e2d917733d --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/process_events_route.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema } from '@kbn/config-schema'; +import type { ElasticsearchClient } from 'kibana/server'; +import { IRouter } from '../../../../../src/core/server'; +import { + PROCESS_EVENTS_ROUTE, + PROCESS_EVENTS_PER_PAGE, + PROCESS_EVENTS_INDEX, + ALERTS_INDEX, + ENTRY_SESSION_ENTITY_ID_PROPERTY, +} from '../../common/constants'; +import { expandDottedObject } from '../../common/utils/expand_dotted_object'; + +export const registerProcessEventsRoute = (router: IRouter) => { + router.get( + { + path: PROCESS_EVENTS_ROUTE, + validate: { + query: schema.object({ + sessionEntityId: schema.string(), + cursor: schema.maybe(schema.string()), + forward: schema.maybe(schema.boolean()), + }), + }, + }, + async (context, request, response) => { + const client = context.core.elasticsearch.client.asCurrentUser; + const { sessionEntityId, cursor, forward = true } = request.query; + const body = await doSearch(client, sessionEntityId, cursor, forward); + + return response.ok({ body }); + } + ); +}; + +export const doSearch = async ( + client: ElasticsearchClient, + sessionEntityId: string, + cursor: string | undefined, + forward = true +) => { + const search = await client.search({ + // TODO: move alerts into it's own route with it's own pagination. + index: [PROCESS_EVENTS_INDEX, ALERTS_INDEX], + ignore_unavailable: true, + body: { + query: { + match: { + [ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId, + }, + }, + // This runtime_mappings is a temporary fix, so we are able to Query these ECS fields while they are not available + // TODO: Remove the runtime_mappings once process.entry_leader.entity_id is implemented to ECS + runtime_mappings: { + [ENTRY_SESSION_ENTITY_ID_PROPERTY]: { + type: 'keyword', + }, + }, + size: PROCESS_EVENTS_PER_PAGE, + sort: [{ '@timestamp': forward ? 'asc' : 'desc' }], + search_after: cursor ? [cursor] : undefined, + }, + }); + + const events = search.hits.hits.map((hit: any) => { + // TODO: re-eval if this is needed after moving alerts to it's own route. + // the .siem-signals-default index flattens many properties. this util unflattens them. + hit._source = expandDottedObject(hit._source); + + return hit; + }); + + if (!forward) { + events.reverse(); + } + + return { + events, + }; +}; diff --git a/x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts b/x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts new file mode 100644 index 000000000000..98aee357fb91 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 '../../../../../src/core/server'; +import { SESSION_ENTRY_LEADERS_ROUTE, PROCESS_EVENTS_INDEX } from '../../common/constants'; + +export const sessionEntryLeadersRoute = (router: IRouter) => { + router.get( + { + path: SESSION_ENTRY_LEADERS_ROUTE, + validate: { + query: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = context.core.elasticsearch.client.asCurrentUser; + const { id } = request.query; + + const result = await client.get({ + index: PROCESS_EVENTS_INDEX, + id, + }); + + return response.ok({ + body: { + session_entry_leader: result?._source, + }, + }); + } + ); +}; diff --git a/x-pack/plugins/session_view/server/types.ts b/x-pack/plugins/session_view/server/types.ts new file mode 100644 index 000000000000..0d1375081ca8 --- /dev/null +++ b/x-pack/plugins/session_view/server/types.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. + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SessionViewSetupPlugins {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SessionViewStartPlugins {} diff --git a/x-pack/plugins/session_view/tsconfig.json b/x-pack/plugins/session_view/tsconfig.json new file mode 100644 index 000000000000..a99e83976a31 --- /dev/null +++ b/x-pack/plugins/session_view/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + // add all the folders containg files to be compiled + "common/**/*", + "public/**/*", + "server/**/*", + "server/**/*.json", + "scripts/**/*", + "package.json", + "storybook/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + // add references to other TypeScript projects the plugin depends on + + // requiredPlugins from ./kibana.json + { "path": "../licensing/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../encrypted_saved_objects/tsconfig.json" }, + + // optionalPlugins from ./kibana.json + { "path": "../security/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + + // requiredBundles from ./kibana.json + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, + { "path": "../infra/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" } + ] +} diff --git a/x-pack/plugins/spaces/public/space_avatar/types.ts b/x-pack/plugins/spaces/public/space_avatar/types.ts index 365c71eeeea7..15a7a4dab839 100644 --- a/x-pack/plugins/spaces/public/space_avatar/types.ts +++ b/x-pack/plugins/spaces/public/space_avatar/types.ts @@ -33,4 +33,19 @@ export interface SpaceAvatarProps { * Default value is false. */ isDisabled?: boolean; + + /** + * Callback to be invoked when the avatar is clicked. + */ + onClick?: (event: React.MouseEvent) => void; + + /** + * Callback to be invoked when the avatar is clicked via keyboard. + */ + onKeyPress?: (event: React.KeyboardEvent) => void; + + /** + * Style props for the avatar. + */ + style?: React.CSSProperties; } diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx index cc365e943a51..de407c2d51c2 100644 --- a/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx @@ -80,6 +80,9 @@ describe('SpaceListInternal', () => { function getButton(wrapper: ReactWrapper) { return wrapper.find('EuiButtonEmpty'); } + async function getListClickTarget(wrapper: ReactWrapper) { + return (await wrapper.find('[data-test-subj="space-avatar-alpha"]')).first(); + } describe('using default properties', () => { describe('with only the active space', () => { @@ -235,15 +238,18 @@ describe('SpaceListInternal', () => { const { spaces, namespaces } = getSpaceData(8); it('with displayLimit=0, shows badges without button', async () => { - const props = { namespaces: [...namespaces, '?'], displayLimit: 0 }; + const props = { namespaces: [...namespaces, '?'], displayLimit: 0, listOnClick: jest.fn() }; const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); expect(getButton(wrapper)).toHaveLength(0); + + (await getListClickTarget(wrapper)).simulate('click'); + expect(props.listOnClick).toHaveBeenCalledTimes(1); }); it('with displayLimit=1, shows badges with button', async () => { - const props = { namespaces: [...namespaces, '?'], displayLimit: 1 }; + const props = { namespaces: [...namespaces, '?'], displayLimit: 1, listOnClick: jest.fn() }; const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A']); @@ -257,6 +263,9 @@ describe('SpaceListInternal', () => { const badgeText = getListText(wrapper); expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); expect(button.text()).toEqual('show less'); + + (await getListClickTarget(wrapper)).simulate('click'); + expect(props.listOnClick).toHaveBeenCalledTimes(1); }); it('with displayLimit=7, shows badges with button', async () => { diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx index 50f24c8df2f3..17403fe7134e 100644 --- a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx @@ -43,6 +43,7 @@ export const SpaceListInternal = ({ namespaces, displayLimit = DEFAULT_DISPLAY_LIMIT, behaviorContext, + listOnClick = () => {}, }: SpaceListProps) => { const { spacesDataPromise } = useSpaces(); @@ -103,14 +104,22 @@ export const SpaceListInternal = ({ if (displayLimit && authorizedSpaceTargets.length > displayLimit) { button = isExpanded ? ( - setIsExpanded(false)}> + setIsExpanded(false)} + style={{ alignSelf: 'center' }} + > ) : ( - setIsExpanded(true)}> + setIsExpanded(true)} + style={{ alignSelf: 'center' }} + > - + ); })} diff --git a/x-pack/plugins/spaces/public/space_list/types.ts b/x-pack/plugins/spaces/public/space_list/types.ts index 2e7e813a48a2..a167b5115503 100644 --- a/x-pack/plugins/spaces/public/space_list/types.ts +++ b/x-pack/plugins/spaces/public/space_list/types.ts @@ -28,4 +28,8 @@ export interface SpaceListProps { * the active space. */ behaviorContext?: 'within-space' | 'outside-space'; + /** + * Click handler for spaces list, specifically excluding expand and contract buttons. + */ + listOnClick?: () => void; } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx index e5c8343fddf6..7b27167d5f5f 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx @@ -11,6 +11,14 @@ import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { IndexSelectPopover } from './index_select_popover'; import { EuiComboBox } from '@elastic/eui'; +jest.mock('lodash', () => { + const module = jest.requireActual('lodash'); + return { + ...module, + debounce: (fn: () => unknown) => fn, + }; +}); + jest.mock('../../../../triggers_actions_ui/public', () => { const original = jest.requireActual('../../../../triggers_actions_ui/public'); return { diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx index fbfb296c7b27..a8b9f3f56dd0 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx @@ -7,7 +7,7 @@ import React, { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { isString } from 'lodash'; +import { isString, debounce } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButtonIcon, @@ -27,7 +27,6 @@ import { firstFieldOption, getFields, getIndexOptions, - getIndexPatterns, getTimeFieldOptions, IErrorObject, } from '../../../../triggers_actions_ui/public'; @@ -62,16 +61,14 @@ export const IndexSelectPopover: React.FunctionComponent = ({ const [indexPopoverOpen, setIndexPopoverOpen] = useState(false); const [indexOptions, setIndexOptions] = useState([]); - const [indexPatterns, setIndexPatterns] = useState([]); const [areIndicesLoading, setAreIndicesLoading] = useState(false); const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); - useEffect(() => { - const indexPatternsFunction = async () => { - setIndexPatterns(await getIndexPatterns()); - }; - indexPatternsFunction(); - }, []); + const loadIndexOptions = debounce(async (search: string) => { + setAreIndicesLoading(true); + setIndexOptions(await getIndexOptions(http!, search)); + setAreIndicesLoading(false); + }, 250); useEffect(() => { const timeFields = getTimeFieldOptions(esFields); @@ -193,11 +190,7 @@ export const IndexSelectPopover: React.FunctionComponent = ({ setTimeFieldOptions([firstFieldOption, ...timeFields]); } }} - onSearchChange={async (search) => { - setAreIndicesLoading(true); - setIndexOptions(await getIndexOptions(http!, search, indexPatterns)); - setAreIndicesLoading(false); - }} + onSearchChange={loadIndexOptions} onBlur={() => { if (!index) { onIndexChange([]); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts index 307496e2be39..e117f1db008f 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts @@ -134,7 +134,7 @@ describe('alertType', () => { const alertServices: AlertServicesMock = alertsMock.createAlertServices(); const searchResult: ESSearchResponse = generateResults([]); - alertServices.search.asCurrentUser.search.mockResolvedValueOnce( + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(searchResult) ); @@ -213,7 +213,7 @@ describe('alertType', () => { 'time-field': newestDocumentTimestamp - 2000, }, ]); - alertServices.search.asCurrentUser.search.mockResolvedValueOnce( + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(searchResult) ); @@ -286,7 +286,7 @@ describe('alertType', () => { const previousTimestamp = Date.now(); const newestDocumentTimestamp = previousTimestamp + 1000; - alertServices.search.asCurrentUser.search.mockResolvedValueOnce( + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( generateResults([ { @@ -356,7 +356,7 @@ describe('alertType', () => { const oldestDocumentTimestamp = Date.now(); - alertServices.search.asCurrentUser.search.mockResolvedValueOnce( + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( generateResults([ { @@ -437,7 +437,7 @@ describe('alertType', () => { const oldestDocumentTimestamp = Date.now(); - alertServices.search.asCurrentUser.search.mockResolvedValueOnce( + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( generateResults([ { @@ -500,7 +500,7 @@ describe('alertType', () => { }); const newestDocumentTimestamp = oldestDocumentTimestamp + 5000; - alertServices.search.asCurrentUser.search.mockResolvedValueOnce( + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( generateResults([ { @@ -545,7 +545,7 @@ describe('alertType', () => { const oldestDocumentTimestamp = Date.now(); - alertServices.search.asCurrentUser.search.mockResolvedValueOnce( + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( generateResults( [ @@ -628,7 +628,7 @@ describe('alertType', () => { const oldestDocumentTimestamp = Date.now(); - alertServices.search.asCurrentUser.search.mockResolvedValueOnce( + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( generateResults( [ diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts index 6a1fcc6a3d7b..d0a23dd40341 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts @@ -160,10 +160,10 @@ export function getAlertType(logger: Logger): RuleType< > ) { const { alertId, name, services, params, state } = options; - const { alertFactory, search } = services; + const { alertFactory, scopedClusterClient } = services; const previousTimestamp = state.latestTimestamp; - const abortableEsClient = search.asCurrentUser; + const esClient = scopedClusterClient.asCurrentUser; const { parsedQuery, dateStart, dateEnd } = getSearchParams(params); const compareFn = ComparatorFns.get(params.thresholdComparator); @@ -224,7 +224,7 @@ export function getAlertType(logger: Logger): RuleType< logger.debug(`alert ${ES_QUERY_ID}:${alertId} "${name}" query - ${JSON.stringify(query)}`); - const { body: searchResult } = await abortableEsClient.search(query); + const { body: searchResult } = await esClient.search(query, { meta: true }); logger.debug( `alert ${ES_QUERY_ID}:${alertId} "${name}" result - ${JSON.stringify(searchResult)}` diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index 7725721ed8ef..1d838fda0e8e 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -135,7 +135,7 @@ export function getAlertType( options: AlertExecutorOptions ) { const { alertId: ruleId, name, services, params } = options; - const { alertFactory, search } = services; + const { alertFactory, scopedClusterClient } = services; const compareFn = ComparatorFns.get(params.thresholdComparator); if (compareFn == null) { @@ -149,7 +149,7 @@ export function getAlertType( ); } - const abortableEsClient = search.asCurrentUser; + const esClient = scopedClusterClient.asCurrentUser; const date = new Date().toISOString(); // the undefined values below are for config-schema optional types const queryParams: TimeSeriesQuery = { @@ -171,7 +171,7 @@ export function getAlertType( await data ).timeSeriesQuery({ logger, - abortableEsClient, + esClient, query: queryParams, }); logger.debug(`rule ${ID}:${ruleId} "${name}" query result: ${JSON.stringify(result)}`); diff --git a/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.test.ts b/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.test.ts index 9b66792efcd9..b7a52a7a41bc 100644 --- a/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.test.ts +++ b/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.test.ts @@ -9,7 +9,7 @@ import { merge } from 'lodash'; import { loggingSystemMock } from 'src/core/server/mocks'; import { Collector, - createCollectorFetchContextWithKibanaMock, + createCollectorFetchContextMock, createUsageCollectionSetupMock, } from 'src/plugins/usage_collection/server/mocks'; import { HealthStatus } from '../monitoring'; @@ -26,7 +26,7 @@ describe('registerTaskManagerUsageCollector', () => { it('should report telemetry on the ephemeral queue', async () => { const monitoringStats$ = new Subject(); const usageCollectionMock = createUsageCollectionSetupMock(); - const fetchContext = createCollectorFetchContextWithKibanaMock(); + const fetchContext = createCollectorFetchContextMock(); usageCollectionMock.makeUsageCollector.mockImplementation((config) => { collector = new Collector(logger, config); return createUsageCollectionSetupMock().makeUsageCollector(config); @@ -53,7 +53,7 @@ describe('registerTaskManagerUsageCollector', () => { it('should report telemetry on the excluded task types', async () => { const monitoringStats$ = new Subject(); const usageCollectionMock = createUsageCollectionSetupMock(); - const fetchContext = createCollectorFetchContextWithKibanaMock(); + const fetchContext = createCollectorFetchContextMock(); usageCollectionMock.makeUsageCollector.mockImplementation((config) => { collector = new Collector(logger, config); return createUsageCollectionSetupMock().makeUsageCollector(config); diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index e0dd709a54e5..f7a82f69ae81 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -3720,6 +3720,12 @@ "description": "Number of times the user toggled fullscreen mode on formula." } }, + "toggle_autoapply": { + "type": "long", + "_meta": { + "description": "Number of times the user toggled auto-apply." + } + }, "indexpattern_field_info_click": { "type": "long" }, @@ -3967,6 +3973,12 @@ "description": "Number of times the user toggled fullscreen mode on formula." } }, + "toggle_autoapply": { + "type": "long", + "_meta": { + "description": "Number of times the user toggled auto-apply." + } + }, "indexpattern_field_info_click": { "type": "long" }, diff --git a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts index 7febebc2a517..e1bea8d1aa0e 100644 --- a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts @@ -112,7 +112,6 @@ describe('Telemetry Collection: Get Aggregated Stats', () => { esClient, usageCollection, soClient, - kibanaRequest: undefined, refreshCache: false, }, context @@ -135,7 +134,6 @@ describe('Telemetry Collection: Get Aggregated Stats', () => { esClient, usageCollection, soClient, - kibanaRequest: undefined, refreshCache: false, }, context @@ -163,7 +161,6 @@ describe('Telemetry Collection: Get Aggregated Stats', () => { esClient, usageCollection, soClient, - kibanaRequest: undefined, refreshCache: false, }, context diff --git a/x-pack/plugins/timelines/common/index.ts b/x-pack/plugins/timelines/common/index.ts index 0002dd6eb143..96728a07432f 100644 --- a/x-pack/plugins/timelines/common/index.ts +++ b/x-pack/plugins/timelines/common/index.ts @@ -20,7 +20,6 @@ export type { ActionProps, AlertWorkflowStatus, CellValueElementProps, - CreateFieldComponentType, ColumnId, ColumnRenderer, ColumnHeaderType, @@ -28,6 +27,7 @@ export type { ControlColumnProps, DataProvidersAnd, DataProvider, + FieldBrowserOptions, GenericActionRowCellRenderProps, HeaderActionProps, HeaderCellRender, diff --git a/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts b/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts index 0b83cf28f9bb..544ca033b060 100644 --- a/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts +++ b/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts @@ -11,6 +11,7 @@ import type { IEsSearchRequest, IEsSearchResponse, FieldSpec, + RuntimeField, } from '../../../../../../src/plugins/data/common'; import type { DocValueFields, Maybe } from '../common'; @@ -71,6 +72,7 @@ export interface BrowserField { type: string; subType?: IFieldSubType; readFromDocValues: boolean; + runtimeField?: RuntimeField; } export type BrowserFields = Readonly>>; diff --git a/x-pack/plugins/timelines/common/types/fields_browser/index.ts b/x-pack/plugins/timelines/common/types/fields_browser/index.ts new file mode 100644 index 000000000000..7aac02be877d --- /dev/null +++ b/x-pack/plugins/timelines/common/types/fields_browser/index.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBasicTableColumn } from '@elastic/eui'; +import { BrowserFields } from '../../search_strategy'; +import { ColumnHeaderOptions } from '../timeline/columns'; + +/** + * An item rendered in the table + */ +export interface BrowserFieldItem { + name: string; + type?: string; + description?: string; + example?: string; + category: string; + selected: boolean; + isRuntime: boolean; +} + +export type OnFieldSelected = (fieldId: string) => void; + +export type CreateFieldComponent = React.FC<{ + onClick: () => void; +}>; +export type FieldTableColumns = Array>; +export type GetFieldTableColumns = (highlight: string) => FieldTableColumns; +export interface FieldBrowserOptions { + createFieldButton?: CreateFieldComponent; + getFieldTableColumns?: GetFieldTableColumns; +} + +export interface FieldBrowserProps { + /** The timeline associated with this field browser */ + timelineId: string; + /** The timeline's current column headers */ + columnHeaders: ColumnHeaderOptions[]; + /** A map of categoryId -> metadata about the fields in that category */ + browserFields: BrowserFields; + /** When true, this Fields Browser is being used as an "events viewer" */ + isEventViewer?: boolean; + /** The options to customize the field browser, supporting columns rendering and button to create fields */ + options?: FieldBrowserOptions; + /** The width of the field browser */ + width?: number; +} diff --git a/x-pack/plugins/timelines/common/types/index.ts b/x-pack/plugins/timelines/common/types/index.ts index 9464a33082a4..f8e2b0306386 100644 --- a/x-pack/plugins/timelines/common/types/index.ts +++ b/x-pack/plugins/timelines/common/types/index.ts @@ -5,4 +5,5 @@ * 2.0. */ +export * from './fields_browser'; export * from './timeline'; diff --git a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts index 0662c63f35ed..6a9c6bf8e74a 100644 --- a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts @@ -7,11 +7,12 @@ import { ComponentType, JSXElementConstructor } from 'react'; import { EuiDataGridControlColumn, EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { CreateFieldComponentType, OnRowSelected, SortColumnTimeline, TimelineTabs } from '..'; +import { OnRowSelected, SortColumnTimeline, TimelineTabs } from '..'; import { BrowserFields } from '../../../search_strategy/index_fields'; import { ColumnHeaderOptions } from '../columns'; import { TimelineNonEcsData } from '../../../search_strategy'; import { Ecs } from '../../../ecs'; +import { FieldBrowserOptions } from '../../fields_browser'; export interface ActionProps { action?: RowCellRender; @@ -67,7 +68,7 @@ export interface HeaderActionProps { width: number; browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; - createFieldComponent?: CreateFieldComponentType; + fieldBrowserOptions?: FieldBrowserOptions; isEventViewer?: boolean; isSelectAllChecked: boolean; onSelectAll: ({ isSelected }: { isSelected: boolean }) => void; diff --git a/x-pack/plugins/timelines/common/types/timeline/index.ts b/x-pack/plugins/timelines/common/types/timeline/index.ts index 4ebc84a41f4b..a6c8ed1b74bf 100644 --- a/x-pack/plugins/timelines/common/types/timeline/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/index.ts @@ -465,10 +465,6 @@ export enum TimelineTabs { eql = 'eql', } -export type CreateFieldComponentType = React.FC<{ - onClick: () => void; -}>; - // eslint-disable-next-line @typescript-eslint/no-explicit-any type EmptyObject = Partial>; diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.test.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.test.tsx deleted file mode 100644 index 80ae1cfa4446..000000000000 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.test.tsx +++ /dev/null @@ -1,195 +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 from 'react'; -import { mount } from 'enzyme'; -import { - TestProviders, - mockGetAllCasesSelectorModal, - mockGetCreateCaseFlyout, -} from '../../../../mock'; -import { AddToCaseAction } from './add_to_case_action'; -import { SECURITY_SOLUTION_OWNER } from '../../../../../../cases/common'; -import { AddToCaseActionButton } from './add_to_case_action_button'; -import { ALERT_RULE_UUID } from '@kbn/rule-data-utils'; - -jest.mock('react-router-dom', () => ({ - useLocation: () => ({ - search: '', - }), -})); -jest.mock('./helpers'); - -describe('AddToCaseAction', () => { - const props = { - event: { - _id: 'test-id', - data: [], - ecs: { - _id: 'test-id', - _index: 'test-index', - signal: { rule: { id: ['rule-id'], name: ['rule-name'], false_positives: [] } }, - }, - }, - casePermissions: { - crud: true, - read: true, - }, - appId: 'securitySolutionUI', - owner: 'securitySolution', - onClose: () => null, - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('it renders', () => { - const wrapper = mount( - - - - - ); - - expect(wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).exists()).toBeTruthy(); - }); - - it('it opens the context menu', () => { - const wrapper = mount( - - - - - ); - - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); - expect(wrapper.find(`[data-test-subj="add-new-case-item"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).exists()).toBeTruthy(); - }); - - it('it opens the create case flyout', () => { - const wrapper = mount( - - - - - ); - - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="add-new-case-item"]`).first().simulate('click'); - expect(mockGetCreateCaseFlyout).toHaveBeenCalled(); - }); - - it('it opens the all cases modal', () => { - const wrapper = mount( - - - - - ); - - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).first().simulate('click'); - - expect(mockGetAllCasesSelectorModal).toHaveBeenCalled(); - }); - - it('it set rule information as null when missing', () => { - const wrapper = mount( - - - - - ); - - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).first().simulate('click'); - expect(mockGetAllCasesSelectorModal.mock.calls[0][0].alertData).toEqual({ - alertId: 'test-id', - index: 'test-index', - rule: { - id: 'rule-id', - name: null, - }, - owner: SECURITY_SOLUTION_OWNER, - }); - }); - - it('disabled when event type is not supported', () => { - const wrapper = mount( - - - - - ); - - expect( - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().prop('isDisabled') - ).toBeTruthy(); - }); - - it('hides the icon when user does not have crud permissions', () => { - const newProps = { - ...props, - casePermissions: { - crud: false, - read: true, - }, - }; - const wrapper = mount( - - - - - ); - - expect(wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).exists()).toBeFalsy(); - }); -}); diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.tsx deleted file mode 100644 index 193d63e2e849..000000000000 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.tsx +++ /dev/null @@ -1,147 +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, { memo, useMemo, useCallback } from 'react'; -import { useDispatch } from 'react-redux'; -import { - GetAllCasesSelectorModalProps, - GetCreateCaseFlyoutProps, -} from '../../../../../../cases/public'; -import { - CaseStatuses, - StatusAll, - CasesFeatures, - CommentType, -} from '../../../../../../cases/common'; -import { TimelineItem } from '../../../../../common/search_strategy'; -import { useAddToCase, normalizedEventFields } from '../../../../hooks/use_add_to_case'; -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { TimelinesStartServices } from '../../../../types'; -import { setOpenAddToExistingCase, setOpenAddToNewCase } from '../../../../store/t_grid/actions'; - -export interface AddToCaseActionProps { - event?: TimelineItem; - useInsertTimeline?: Function; - casePermissions: { - crud: boolean; - read: boolean; - } | null; - appId: string; - owner: string; - onClose?: Function; - casesFeatures?: CasesFeatures; -} - -const AddToCaseActionComponent: React.FC = ({ - event, - useInsertTimeline, - casePermissions, - appId, - owner, - onClose, - casesFeatures, -}) => { - const eventId = event?.ecs._id ?? ''; - const eventIndex = event?.ecs._index ?? ''; - const dispatch = useDispatch(); - const { cases } = useKibana().services; - const { - onCaseClicked, - onCaseSuccess, - onCaseCreated, - isAllCaseModalOpen, - isCreateCaseFlyoutOpen, - } = useAddToCase({ event, casePermissions, appId, owner, onClose }); - - const allCasesSelectorModalProps: GetAllCasesSelectorModalProps = useMemo(() => { - const { ruleId, ruleName } = normalizedEventFields(event); - return { - alertData: { - alertId: eventId, - index: eventIndex ?? '', - rule: { - id: ruleId, - name: ruleName, - }, - owner, - }, - hooks: { - useInsertTimeline, - }, - hiddenStatuses: [CaseStatuses.closed, StatusAll], - onRowClick: onCaseClicked, - updateCase: onCaseSuccess, - userCanCrud: casePermissions?.crud ?? false, - owner: [owner], - onClose: () => dispatch(setOpenAddToExistingCase({ id: eventId, isOpen: false })), - }; - }, [ - casePermissions?.crud, - onCaseSuccess, - onCaseClicked, - eventId, - eventIndex, - dispatch, - owner, - useInsertTimeline, - event, - ]); - - const closeCaseFlyoutOpen = useCallback(() => { - dispatch(setOpenAddToNewCase({ id: eventId, isOpen: false })); - }, [dispatch, eventId]); - - const createCaseFlyoutProps: GetCreateCaseFlyoutProps = useMemo(() => { - const { ruleId, ruleName } = normalizedEventFields(event); - const attachments = [ - { - alertId: eventId, - index: eventIndex ?? '', - rule: { - id: ruleId, - name: ruleName, - }, - owner, - type: CommentType.alert as const, - }, - ]; - return { - afterCaseCreated: onCaseCreated, - onClose: closeCaseFlyoutOpen, - onSuccess: onCaseSuccess, - useInsertTimeline, - owner: [owner], - userCanCrud: casePermissions?.crud ?? false, - features: casesFeatures, - attachments, - }; - }, [ - event, - eventId, - eventIndex, - owner, - onCaseCreated, - closeCaseFlyoutOpen, - onCaseSuccess, - useInsertTimeline, - casePermissions?.crud, - casesFeatures, - ]); - - return ( - <> - {isCreateCaseFlyoutOpen && cases.getCreateCaseFlyout(createCaseFlyoutProps)} - {isAllCaseModalOpen && cases.getAllCasesSelectorModal(allCasesSelectorModalProps)} - - ); -}; -AddToCaseActionComponent.displayName = 'AddToCaseAction'; - -export const AddToCaseAction = memo(AddToCaseActionComponent); - -// eslint-disable-next-line import/no-default-export -export default AddToCaseAction; diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action_button.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action_button.tsx deleted file mode 100644 index 9757bd94b174..000000000000 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action_button.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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo } from 'react'; -import { - EuiPopover, - EuiButtonIcon, - EuiContextMenuPanel, - EuiText, - EuiContextMenuItem, - EuiToolTip, -} from '@elastic/eui'; -import { AddToCaseActionProps } from './add_to_case_action'; -import { useAddToCase } from '../../../../hooks/use_add_to_case'; -import { ActionIconItem } from '../../action_icon_item'; -import * as i18n from './translations'; - -const AddToCaseActionButtonComponent: React.FC = ({ - event, - useInsertTimeline, - casePermissions, - appId, - owner, - onClose, -}) => { - const { - addNewCaseClick, - addExistingCaseClick, - isDisabled, - userCanCrud, - isEventSupported, - openPopover, - closePopover, - isPopoverOpen, - } = useAddToCase({ event, useInsertTimeline, casePermissions, appId, owner, onClose }); - const tooltipContext = userCanCrud - ? isEventSupported - ? i18n.ACTION_ADD_TO_CASE_TOOLTIP - : i18n.UNSUPPORTED_EVENTS_MSG - : i18n.PERMISSIONS_MSG; - const items = useMemo( - () => [ - - {i18n.ACTION_ADD_NEW_CASE} - , - - {i18n.ACTION_ADD_EXISTING_CASE} - , - ], - [addExistingCaseClick, addNewCaseClick, isDisabled] - ); - - const button = useMemo( - () => ( - - - - ), - [isDisabled, openPopover, tooltipContext] - ); - - return ( - <> - {userCanCrud && ( - - - - - - )} - - ); -}; - -export const AddToCaseActionButton = memo(AddToCaseActionButtonComponent); - -// eslint-disable-next-line import/no-default-export -export default AddToCaseActionButton; diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_existing_case_button.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_existing_case_button.tsx deleted file mode 100644 index 4d19a8909688..000000000000 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_existing_case_button.tsx +++ /dev/null @@ -1,76 +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, { memo } from 'react'; -import { EuiContextMenuItem } from '@elastic/eui'; - -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { TimelinesStartServices } from '../../../../types'; -import { useAddToCase } from '../../../../hooks/use_add_to_case'; -import { AddToCaseActionProps } from './add_to_case_action'; -import * as i18n from './translations'; - -interface AddToCaseActionButtonProps extends AddToCaseActionProps { - ariaLabel?: string; -} - -const AddToCaseActionButtonComponent: React.FC = ({ - ariaLabel = i18n.ACTION_ADD_TO_CASE_ARIA_LABEL, - event, - useInsertTimeline, - casePermissions, - appId, - owner, - onClose, -}) => { - const { onCaseSuccess, onCaseClicked, isDisabled, userCanCrud, caseAttachments } = useAddToCase({ - event, - useInsertTimeline, - casePermissions, - appId, - owner, - onClose, - }); - const { cases } = useKibana().services; - const addToCaseModal = cases.hooks.getUseCasesAddToExistingCaseModal({ - attachments: caseAttachments, - updateCase: onCaseSuccess, - onRowClick: onCaseClicked, - }); - - // TODO To be further refactored and moved to cases plugins - // https://github.com/elastic/kibana/issues/123183 - const handleClick = () => { - // close the popover - if (onClose) { - onClose(); - } - addToCaseModal.open(); - }; - - return ( - <> - {userCanCrud && ( - - {i18n.ACTION_ADD_EXISTING_CASE} - - )} - - ); -}; - -export const AddToExistingCaseButton = memo(AddToCaseActionButtonComponent); - -// eslint-disable-next-line import/no-default-export -export default AddToExistingCaseButton; diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_new_case_button.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_new_case_button.tsx deleted file mode 100644 index eb83cb21aea6..000000000000 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_new_case_button.tsx +++ /dev/null @@ -1,77 +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, { memo } from 'react'; -import { EuiContextMenuItem } from '@elastic/eui'; - -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { TimelinesStartServices } from '../../../../types'; -import { useAddToCase } from '../../../../hooks/use_add_to_case'; -import { AddToCaseActionProps } from './add_to_case_action'; -import * as i18n from './translations'; - -export interface AddToNewCaseButtonProps extends AddToCaseActionProps { - ariaLabel?: string; -} - -const AddToNewCaseButtonComponent: React.FC = ({ - ariaLabel = i18n.ACTION_ADD_TO_CASE_ARIA_LABEL, - event, - useInsertTimeline, - casePermissions, - appId, - owner, - onClose, -}) => { - const { isDisabled, userCanCrud, caseAttachments, onCaseSuccess, onCaseCreated } = useAddToCase({ - event, - useInsertTimeline, - casePermissions, - appId, - owner, - onClose, - }); - const { cases } = useKibana().services; - const createCaseFlyout = cases.hooks.getUseCasesAddToNewCaseFlyout({ - attachments: caseAttachments, - afterCaseCreated: onCaseCreated, - onSuccess: onCaseSuccess, - }); - - // TODO To be further refactored and moved to cases plugins - // https://github.com/elastic/kibana/issues/123183 - const handleClick = () => { - // close the popover - if (onClose) { - onClose(); - } - createCaseFlyout.open(); - }; - - return ( - <> - {userCanCrud && ( - - {i18n.ACTION_ADD_NEW_CASE} - - )} - - ); -}; -AddToNewCaseButtonComponent.displayName = 'AddToNewCaseButton'; - -export const AddToNewCaseButton = memo(AddToNewCaseButtonComponent); - -// eslint-disable-next-line import/no-default-export -export default AddToNewCaseButton; diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/helpers.test.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/helpers.test.tsx deleted file mode 100644 index efb11393a424..000000000000 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/helpers.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import 'jest-styled-components'; -import type { MockedKeys } from '@kbn/utility-types/jest'; -import { CoreStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; -import type { IToasts } from '../../../../../../../../src/core/public'; - -import { createUpdateSuccessToaster } from './helpers'; -import { Case } from '../../../../../../cases/common'; - -let mockCoreStart: MockedKeys; -let toasts: IToasts; -let toastsSpy: jest.SpyInstance; - -const theCase = { - id: 'case-id', - title: 'My case', - settings: { - syncAlerts: true, - }, -} as Case; - -describe('helpers', () => { - beforeEach(() => { - mockCoreStart = coreMock.createStart(); - }); - - describe('createUpdateSuccessToaster', () => { - it('creates the correct toast when the sync alerts is on', () => { - const onViewCaseClick = jest.fn(); - - toasts = mockCoreStart.notifications.toasts; - toastsSpy = jest.spyOn(mockCoreStart.notifications.toasts, 'addSuccess'); - createUpdateSuccessToaster(toasts, theCase, onViewCaseClick); - - expect(toastsSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/helpers.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/helpers.tsx deleted file mode 100644 index 71e2f41a5288..000000000000 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/helpers.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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import styled from 'styled-components'; -import { ToasterContent } from './toaster_content'; -import * as i18n from './translations'; -import type { Case } from '../../../../../../cases/common'; -import type { ToastsStart, Toast } from '../../../../../../../../src/core/public'; -import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; - -const LINE_CLAMP = 3; - -const Title = styled.span` - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: ${LINE_CLAMP}; - -webkit-box-orient: vertical; - overflow: hidden; -`; - -export const createUpdateSuccessToaster = ( - toasts: ToastsStart, - theCase: Case, - onViewCaseClick: (id: string) => void -): Toast => { - return toasts.addSuccess({ - color: 'success', - iconType: 'check', - title: toMountPoint({i18n.CASE_CREATED_SUCCESS_TOAST(theCase.title)}), - text: toMountPoint( - - ), - }); -}; diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/index.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/index.tsx deleted file mode 100644 index bb3bd63e316e..000000000000 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export * from './add_to_case_action'; -export * from './toaster_content'; -export * from './add_to_existing_case_button'; -export * from './add_to_new_case_button'; diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/toaster_content.test.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/toaster_content.test.tsx deleted file mode 100644 index fd20366e7891..000000000000 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/toaster_content.test.tsx +++ /dev/null @@ -1,46 +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 from 'react'; -import { mount } from 'enzyme'; - -import { ToasterContent } from './toaster_content'; - -describe('ToasterContent', () => { - const onViewCaseClick = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders with syncAlerts=true', () => { - const wrapper = mount( - - ); - - expect(wrapper.find('[data-test-subj="toaster-content-case-view-link"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="toaster-content-sync-text"]').exists()).toBeTruthy(); - }); - - it('renders with syncAlerts=false', () => { - const wrapper = mount( - - ); - - expect(wrapper.find('[data-test-subj="toaster-content-case-view-link"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="toaster-content-sync-text"]').exists()).toBeFalsy(); - }); - - it('calls onViewCaseClick', () => { - const wrapper = mount( - - ); - - wrapper.find('[data-test-subj="toaster-content-case-view-link"]').first().simulate('click'); - expect(onViewCaseClick).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/toaster_content.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/toaster_content.tsx deleted file mode 100644 index 147dd3f5e839..000000000000 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/toaster_content.tsx +++ /dev/null @@ -1,47 +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, { memo, useCallback } from 'react'; -import { EuiButtonEmpty, EuiText } from '@elastic/eui'; -import styled from 'styled-components'; - -import * as i18n from './translations'; - -const EuiTextStyled = styled(EuiText)` - ${({ theme }) => ` - margin-bottom: ${theme.eui?.paddingSizes?.s ?? 8}px; - `} -`; - -interface Props { - caseId: string; - syncAlerts: boolean; - onViewCaseClick: (id: string) => void; -} - -const ToasterContentComponent: React.FC = ({ caseId, syncAlerts, onViewCaseClick }) => { - const onClick = useCallback(() => onViewCaseClick(caseId), [caseId, onViewCaseClick]); - return ( - <> - {syncAlerts && ( - - {i18n.CASE_CREATED_SUCCESS_TOAST_TEXT} - - )} - - {i18n.VIEW_CASE} - - - ); -}; - -export const ToasterContent = memo(ToasterContentComponent); diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/translations.ts b/x-pack/plugins/timelines/public/components/actions/timeline/cases/translations.ts deleted file mode 100644 index df0dfb9048ac..000000000000 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/translations.ts +++ /dev/null @@ -1,75 +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 { i18n } from '@kbn/i18n'; - -export const ACTION_ADD_CASE = i18n.translate('xpack.timelines.cases.timeline.actions.addCase', { - defaultMessage: 'Add to case', -}); - -export const ACTION_ADD_NEW_CASE = i18n.translate( - 'xpack.timelines.cases.timeline.actions.addNewCase', - { - defaultMessage: 'Add to new case', - } -); - -export const ACTION_ADD_EXISTING_CASE = i18n.translate( - 'xpack.timelines.cases.timeline.actions.addExistingCase', - { - defaultMessage: 'Add to existing case', - } -); - -export const ACTION_ADD_TO_CASE_ARIA_LABEL = i18n.translate( - 'xpack.timelines.cases.timeline.actions.addToCaseAriaLabel', - { - defaultMessage: 'Attach alert to case', - } -); - -export const ACTION_ADD_TO_CASE_TOOLTIP = i18n.translate( - 'xpack.timelines.cases.timeline.actions.addToCaseTooltip', - { - defaultMessage: 'Add to case', - } -); - -export const CASE_CREATED_SUCCESS_TOAST = (title: string) => - i18n.translate('xpack.timelines.cases.timeline.actions.caseCreatedSuccessToast', { - values: { title }, - defaultMessage: 'An alert has been added to "{title}"', - }); - -export const CASE_CREATED_SUCCESS_TOAST_TEXT = i18n.translate( - 'xpack.timelines.cases.timeline.actions.caseCreatedSuccessToastText', - { - defaultMessage: 'Alerts in this case have their status synched with the case status', - } -); - -export const VIEW_CASE = i18n.translate( - 'xpack.timelines.cases.timeline.actions.caseCreatedSuccessToastViewCaseLink', - { - defaultMessage: 'View Case', - } -); - -export const PERMISSIONS_MSG = i18n.translate( - 'xpack.timelines.cases.timeline.actions.permissionsMessage', - { - defaultMessage: - 'You are currently missing the required permissions to attach alerts to cases. Please contact your administrator for further assistance.', - } -); - -export const UNSUPPORTED_EVENTS_MSG = i18n.translate( - 'xpack.timelines.cases.timeline.actions.unsupportedEventsMessage', - { - defaultMessage: 'This event cannot be attached to a case', - } -); diff --git a/x-pack/plugins/timelines/public/components/fields_browser/index.tsx b/x-pack/plugins/timelines/public/components/fields_browser/index.tsx index 02fd0553f401..12133cbee303 100644 --- a/x-pack/plugins/timelines/public/components/fields_browser/index.tsx +++ b/x-pack/plugins/timelines/public/components/fields_browser/index.tsx @@ -9,9 +9,14 @@ import React from 'react'; import type { Store } from 'redux'; import { Provider } from 'react-redux'; import { I18nProvider } from '@kbn/i18n-react'; -import type { FieldBrowserProps } from '../t_grid/toolbar/fields_browser/types'; import { StatefulFieldsBrowser } from '../t_grid/toolbar/fields_browser'; -export type { FieldBrowserProps } from '../t_grid/toolbar/fields_browser/types'; +import { FieldBrowserProps } from '../../../common/types/fields_browser'; +export type { + CreateFieldComponent, + FieldBrowserOptions, + FieldBrowserProps, + GetFieldTableColumns, +} from '../../../common/types/fields_browser'; const EMPTY_BROWSER_FIELDS = {}; @@ -28,10 +33,7 @@ export const FieldBrowserWrappedComponent = (props: FieldBrowserWrappedComponent return ( - + ); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx index 2ae0146f80f7..4ba36a3ec641 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -47,7 +47,6 @@ import { TimelineTabs, SetEventsLoading, SetEventsDeleted, - CreateFieldComponentType, } from '../../../../common/types/timeline'; import type { TimelineItem, TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; @@ -63,10 +62,11 @@ import { import type { BrowserFields } from '../../../../common/search_strategy/index_fields'; import type { OnRowSelected, OnSelectAll } from '../types'; +import type { FieldBrowserOptions } from '../../../../common/types'; import type { Refetch } from '../../../store/t_grid/inputs'; import { getPageRowIndex } from '../../../../common/utils/pagination'; import { StatefulEventContext } from '../../../components/stateful_event_context'; -import { StatefulFieldsBrowser } from '../../../components/t_grid/toolbar/fields_browser'; +import { StatefulFieldsBrowser } from '../toolbar/fields_browser'; import { tGridActions, TGridModel, tGridSelectors, TimelineState } from '../../../store/t_grid'; import { useDeepEqualSelector } from '../../../hooks/use_selector'; import { RowAction } from './row_action'; @@ -88,10 +88,10 @@ interface OwnProps { appId?: string; browserFields: BrowserFields; bulkActions?: BulkActionsProp; - createFieldComponent?: CreateFieldComponentType; data: TimelineItem[]; defaultCellActions?: TGridCellAction[]; disabledCellActions: string[]; + fieldBrowserOptions?: FieldBrowserOptions; filters?: Filter[]; filterQuery?: string; filterStatus?: AlertStatus; @@ -149,8 +149,8 @@ const EuiDataGridContainer = styled.div<{ hideLastPage: boolean }>` const transformControlColumns = ({ columnHeaders, controlColumns, - createFieldComponent, data, + fieldBrowserOptions, isEventViewer = false, loadingEventIds, onRowSelected, @@ -171,9 +171,9 @@ const transformControlColumns = ({ }: { columnHeaders: ColumnHeaderOptions[]; controlColumns: ControlColumnProps[]; - createFieldComponent?: CreateFieldComponentType; data: TimelineItem[]; disabledCellActions: string[]; + fieldBrowserOptions?: FieldBrowserOptions; isEventViewer?: boolean; loadingEventIds: string[]; onRowSelected: OnRowSelected; @@ -209,6 +209,7 @@ const transformControlColumns = ({ )} @@ -303,10 +303,10 @@ export const BodyComponent = React.memo( bulkActions = true, clearSelected, columnHeaders, - createFieldComponent, data, defaultCellActions, disabledCellActions, + fieldBrowserOptions, filterQuery, filters, filterStatus, @@ -502,7 +502,7 @@ export const BodyComponent = React.memo( @@ -529,6 +529,7 @@ export const BodyComponent = React.memo( id, totalSelectAllAlerts, totalItems, + fieldBrowserOptions, filterStatus, filterQuery, indexNames, @@ -539,7 +540,6 @@ export const BodyComponent = React.memo( additionalControls, browserFields, columnHeaders, - createFieldComponent, ] ); @@ -629,9 +629,9 @@ export const BodyComponent = React.memo( transformControlColumns({ columnHeaders, controlColumns, - createFieldComponent, data, disabledCellActions, + fieldBrowserOptions, isEventViewer, loadingEventIds, onRowSelected, @@ -656,9 +656,9 @@ export const BodyComponent = React.memo( leadingControlColumns, trailingControlColumns, columnHeaders, - createFieldComponent, data, disabledCellActions, + fieldBrowserOptions, isEventViewer, id, loadingEventIds, diff --git a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx index b97e4047d10e..69c04b31fa44 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx @@ -21,7 +21,6 @@ import type { CoreStart } from '../../../../../../../src/core/public'; import type { BrowserFields } from '../../../../common/search_strategy/index_fields'; import { BulkActionsProp, - CreateFieldComponentType, TGridCellAction, TimelineId, TimelineTabs, @@ -43,6 +42,7 @@ import { defaultHeaders } from '../body/column_headers/default_headers'; import { buildCombinedQuery, getCombinedFilterQuery, resolverIsShowing } from '../helpers'; import { tGridActions, tGridSelectors } from '../../../store/t_grid'; import { useTimelineEvents, InspectResponse, Refetch } from '../../../container'; +import { FieldBrowserOptions } from '../../fields_browser'; import { StatefulBody } from '../body'; import { SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexGroup, UpdatedFlexItem } from '../styles'; import { Sort } from '../body/sort'; @@ -98,7 +98,6 @@ export interface TGridIntegratedProps { browserFields: BrowserFields; bulkActions?: BulkActionsProp; columns: ColumnHeaderOptions[]; - createFieldComponent?: CreateFieldComponentType; data?: DataPublicPluginStart; dataProviders: DataProvider[]; dataViewId?: string | null; @@ -108,6 +107,7 @@ export interface TGridIntegratedProps { docValueFields: DocValueFields[]; end: string; entityType: EntityType; + fieldBrowserOptions?: FieldBrowserOptions; filters: Filter[]; filterStatus?: AlertStatus; globalFullScreen: boolean; @@ -153,12 +153,12 @@ const TGridIntegratedComponent: React.FC = ({ docValueFields, end, entityType, + fieldBrowserOptions, filters, filterStatus, globalFullScreen, graphEventId, graphOverlay = null, - createFieldComponent, hasAlertsCrud, id, indexNames, @@ -363,10 +363,10 @@ const TGridIntegratedComponent: React.FC = ({ appId={appId} browserFields={browserFields} bulkActions={bulkActions} - createFieldComponent={createFieldComponent} data={nonDeletedEvents} defaultCellActions={defaultCellActions} disabledCellActions={disabledCellActions} + fieldBrowserOptions={fieldBrowserOptions} filterQuery={filterQuery} filters={filters} filterStatus={filterStatus} diff --git a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx index 75f6803ddf0b..be71d159eafc 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx @@ -8,7 +8,7 @@ import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { useEffect, useMemo, useState, useRef } from 'react'; import styled from 'styled-components'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { Filter, Query } from '@kbn/es-query'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; @@ -39,7 +39,6 @@ import { LastUpdatedAt } from '../..'; import { SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexItem, UpdatedFlexGroup } from '../styles'; import { InspectButton, InspectButtonContainer } from '../../inspect'; import { useFetchIndex } from '../../../container/source'; -import { AddToCaseAction } from '../../actions/timeline/cases/add_to_case_action'; import { TGridLoading, TGridEmpty, TimelineContext } from '../shared'; const FullWidthFlexGroup = styled(EuiFlexGroup)<{ $visible: boolean }>` @@ -49,7 +48,7 @@ const FullWidthFlexGroup = styled(EuiFlexGroup)<{ $visible: boolean }>` `; export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px -const STANDALONE_ID = 'standalone-t-grid'; +export const STANDALONE_ID = 'standalone-t-grid'; const EMPTY_DATA_PROVIDERS: DataProvider[] = []; const TitleText = styled.span` @@ -76,16 +75,7 @@ const ScrollableFlexItem = styled(EuiFlexItem)` overflow: auto; `; -const casesFeatures = { alerts: { sync: false } }; - export interface TGridStandaloneProps { - appId: string; - casesOwner: string; - casePermissions: { - crud: boolean; - read: boolean; - } | null; - afterCaseSelection?: Function; columns: ColumnHeaderOptions[]; dataViewId?: string | null; defaultCellActions?: TGridCellAction[]; @@ -127,10 +117,6 @@ export interface TGridStandaloneProps { } const TGridStandaloneComponent: React.FC = ({ - afterCaseSelection, - appId, - casesOwner, - casePermissions, columns, dataViewId = null, defaultCellActions, @@ -272,26 +258,6 @@ const TGridStandaloneComponent: React.FC = ({ ); const hasAlerts = totalCountMinusDeleted > 0; - const activeCaseFlowId = useSelector((state: State) => tGridSelectors.activeCaseFlowId(state)); - const selectedEvent = useMemo(() => { - const matchedEvent = events.find((event) => event.ecs._id === activeCaseFlowId); - if (matchedEvent) { - return matchedEvent; - } else { - return undefined; - } - }, [events, activeCaseFlowId]); - - const addToCaseActionProps = useMemo(() => { - return { - event: selectedEvent, - casePermissions: casePermissions ?? null, - appId, - owner: casesOwner, - onClose: afterCaseSelection, - }; - }, [appId, casePermissions, afterCaseSelection, selectedEvent, casesOwner]); - const nonDeletedEvents = useMemo( () => events.filter((e) => !deletedEventIds.includes(e._id)), [deletedEventIds, events] @@ -425,7 +391,6 @@ const TGridStandaloneComponent: React.FC = ({ ) : null} - ); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_badges.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_badges.test.tsx new file mode 100644 index 000000000000..e945f91c47af --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_badges.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 { render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../../../mock'; + +import { CategoriesBadges } from './categories_badges'; + +const mockSetSelectedCategoryIds = jest.fn(); +const defaultProps = { + setSelectedCategoryIds: mockSetSelectedCategoryIds, + selectedCategoryIds: [], +}; + +describe('CategoriesBadges', () => { + beforeEach(() => { + mockSetSelectedCategoryIds.mockClear(); + }); + + it('should render empty badges', () => { + const result = render( + + + + ); + + const badges = result.getByTestId('category-badges'); + expect(badges).toBeInTheDocument(); + expect(badges.childNodes.length).toBe(0); + }); + + it('should render the selector button with selected categories', () => { + const result = render( + + + + ); + + const badges = result.getByTestId('category-badges'); + expect(badges.childNodes.length).toBe(2); + expect(result.getByTestId('category-badge-base')).toBeInTheDocument(); + expect(result.getByTestId('category-badge-event')).toBeInTheDocument(); + }); + + it('should call the set selected callback when badge unselect button clicked', () => { + const result = render( + + + + ); + + result.getByTestId('category-badge-unselect-base').click(); + expect(mockSetSelectedCategoryIds).toHaveBeenCalledWith(['event']); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_badges.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_badges.tsx new file mode 100644 index 000000000000..14b928d18de4 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_badges.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 React, { useCallback } from 'react'; +import styled from 'styled-components'; +import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +interface CategoriesBadgesProps { + setSelectedCategoryIds: (categoryIds: string[]) => void; + selectedCategoryIds: string[]; +} + +const CategoriesBadgesGroup = styled(EuiFlexGroup)` + margin-top: ${({ theme }) => theme.eui.euiSizeXS}; + min-height: 24px; +`; +CategoriesBadgesGroup.displayName = 'CategoriesBadgesGroup'; + +const CategoriesBadgesComponent: React.FC = ({ + setSelectedCategoryIds, + selectedCategoryIds, +}) => { + const onUnselectCategory = useCallback( + (categoryId: string) => { + setSelectedCategoryIds( + selectedCategoryIds.filter((selectedCategoryId) => selectedCategoryId !== categoryId) + ); + }, + [setSelectedCategoryIds, selectedCategoryIds] + ); + + return ( + + {selectedCategoryIds.map((categoryId) => ( + + onUnselectCategory(categoryId)} + iconOnClickAriaLabel="unselect category" + data-test-subj={`category-badge-${categoryId}`} + closeButtonProps={{ 'data-test-subj': `category-badge-unselect-${categoryId}` }} + > + {categoryId} + + + ))} + + ); +}; + +export const CategoriesBadges = React.memo(CategoriesBadgesComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_pane.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_pane.test.tsx deleted file mode 100644 index e2f1d78cf5bc..000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_pane.test.tsx +++ /dev/null @@ -1,51 +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 { mount } from 'enzyme'; -import React from 'react'; - -import { mockBrowserFields } from '../../../../mock'; - -import { CATEGORY_PANE_WIDTH } from './helpers'; -import { CategoriesPane } from './categories_pane'; -import * as i18n from './translations'; - -const timelineId = 'test'; - -describe('CategoriesPane', () => { - test('it renders the expected title', () => { - const wrapper = mount( - - ); - - expect(wrapper.find('[data-test-subj="categories-pane-title"]').first().text()).toEqual( - i18n.CATEGORIES - ); - }); - - test('it renders a "No fields match" message when filteredBrowserFields is empty', () => { - const wrapper = mount( - - ); - - expect(wrapper.find('[data-test-subj="categories-container"] tbody').first().text()).toEqual( - i18n.NO_FIELDS_MATCH - ); - }); -}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_pane.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_pane.tsx deleted file mode 100644 index ffb93aee11b5..000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_pane.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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiInMemoryTable, EuiTitle } from '@elastic/eui'; -import { noop } from 'lodash/fp'; -import React, { useCallback, useRef } from 'react'; -import styled from 'styled-components'; -import { - DATA_COLINDEX_ATTRIBUTE, - DATA_ROWINDEX_ATTRIBUTE, - onKeyDownFocusHandler, -} from '../../../../../common/utils/accessibility'; -import type { BrowserFields } from '../../../../../common/search_strategy'; -import { getCategoryColumns } from './category_columns'; -import { CATEGORIES_PANE_CLASS_NAME, TABLE_HEIGHT } from './helpers'; - -import * as i18n from './translations'; - -const CategoryNames = styled.div<{ height: number; width: number }>` - ${({ width }) => `width: ${width}px`}; - ${({ height }) => `height: ${height}px`}; - overflow-y: hidden; - padding: 5px; - thead { - display: none; - } -`; - -CategoryNames.displayName = 'CategoryNames'; - -const Title = styled(EuiTitle)` - padding-left: 5px; -`; - -const H3 = styled.h3` - text-align: left; -`; - -Title.displayName = 'Title'; - -interface Props { - /** - * A map of categoryId -> metadata about the fields in that category, - * filtered such that the name of every field in the category includes - * the filter input (as a substring). - */ - filteredBrowserFields: BrowserFields; - /** - * Invoked when the user clicks on the name of a category in the left-hand - * side of the field browser - */ - onCategorySelected: (categoryId: string) => void; - /** The category selected on the left-hand side of the field browser */ - selectedCategoryId: string; - timelineId: string; - /** The width of the categories pane */ - width: number; -} - -export const CategoriesPane = React.memo( - ({ filteredBrowserFields, onCategorySelected, selectedCategoryId, timelineId, width }) => { - const containerElement = useRef(null); - const onKeyDown = useCallback( - (e: React.KeyboardEvent) => { - onKeyDownFocusHandler({ - colindexAttribute: DATA_COLINDEX_ATTRIBUTE, - containerElement: containerElement?.current, - event: e, - maxAriaColindex: 1, - maxAriaRowindex: Object.keys(filteredBrowserFields).length, - onColumnFocused: noop, - rowindexAttribute: DATA_ROWINDEX_ATTRIBUTE, - }); - }, - [containerElement, filteredBrowserFields] - ); - - return ( - <> - - <H3 data-test-subj="categories-pane-title">{i18n.CATEGORIES}</H3> - - - - ({ categoryId, ariaRowindex: i + 1 }))} - message={i18n.NO_FIELDS_MATCH} - pagination={false} - sorting={false} - tableCaption={i18n.CATEGORIES} - /> - - - ); - } -); - -CategoriesPane.displayName = 'CategoriesPane'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_selector.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_selector.test.tsx new file mode 100644 index 000000000000..eff37376a296 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_selector.test.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 { render } from '@testing-library/react'; +import React from 'react'; +import { mockBrowserFields, TestProviders } from '../../../../mock'; + +import { CategoriesSelector } from './categories_selector'; + +const mockSetSelectedCategoryIds = jest.fn(); +const defaultProps = { + filteredBrowserFields: mockBrowserFields, + setSelectedCategoryIds: mockSetSelectedCategoryIds, + selectedCategoryIds: [], +}; + +describe('CategoriesSelector', () => { + beforeEach(() => { + mockSetSelectedCategoryIds.mockClear(); + }); + + it('should render the default selector button', () => { + const categoriesCount = Object.keys(mockBrowserFields).length; + const result = render( + + + + ); + + expect(result.getByTestId('categories-filter-button')).toBeInTheDocument(); + expect(result.getByText('Categories')).toBeInTheDocument(); + expect(result.getByText(categoriesCount)).toBeInTheDocument(); + }); + + it('should render the selector button with selected categories', () => { + const result = render( + + + + ); + + expect(result.getByTestId('categories-filter-button')).toBeInTheDocument(); + expect(result.getByText('Categories')).toBeInTheDocument(); + expect(result.getByText('2')).toBeInTheDocument(); + }); + + it('should open the category selector', () => { + const result = render( + + + + ); + + result.getByTestId('categories-filter-button').click(); + + expect(result.getByTestId('categories-selector-search')).toBeInTheDocument(); + expect(result.getByTestId(`categories-selector-option-base`)).toBeInTheDocument(); + }); + + it('should open the category selector with selected categories', () => { + const result = render( + + + + ); + + result.getByTestId('categories-filter-button').click(); + + expect(result.getByTestId('categories-selector-search')).toBeInTheDocument(); + expect(result.getByTestId(`categories-selector-option-base`)).toBeInTheDocument(); + expect(result.getByTestId(`categories-selector-option-name-base`)).toHaveStyleRule( + 'font-weight', + 'bold' + ); + }); + + it('should call setSelectedCategoryIds when category selected', () => { + const result = render( + + + + ); + + result.getByTestId('categories-filter-button').click(); + result.getByTestId(`categories-selector-option-base`).click(); + expect(mockSetSelectedCategoryIds).toHaveBeenCalledWith(['base']); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_selector.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_selector.tsx new file mode 100644 index 000000000000..6aebd32543ea --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/categories_selector.tsx @@ -0,0 +1,173 @@ +/* + * Copyright 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, { useCallback, useMemo, useState } from 'react'; +import { omit } from 'lodash'; +import { + EuiFilterButton, + EuiFilterGroup, + EuiFlexGroup, + EuiFlexItem, + EuiHighlight, + EuiPopover, + EuiSelectable, + FilterChecked, +} from '@elastic/eui'; +import { BrowserFields } from '../../../../../common'; +import * as i18n from './translations'; +import { CountBadge, getFieldCount, CategoryName, CategorySelectableContainer } from './helpers'; +import { isEscape } from '../../../../../common/utils/accessibility'; + +interface CategoriesSelectorProps { + /** + * A map of categoryId -> metadata about the fields in that category, + * filtered such that the name of every field in the category includes + * the filter input (as a substring). + */ + filteredBrowserFields: BrowserFields; + /** + * Invoked when the user clicks on the name of a category in the left-hand + * side of the field browser + */ + setSelectedCategoryIds: (categoryIds: string[]) => void; + /** The category selected on the left-hand side of the field browser */ + selectedCategoryIds: string[]; +} + +interface CategoryOption { + label: string; + count: number; + checked?: FilterChecked; +} + +const renderOption = (option: CategoryOption, searchValue: string) => { + const { label, count, checked } = option; + // Some category names have spaces, but test selectors don't like spaces, + // Tests are not able to find subjects with spaces, so we need to clean them. + const idAttr = label.replace(/\s/g, ''); + return ( + + + + {label} + + + + {count} + + + ); +}; + +const CategoriesSelectorComponent: React.FC = ({ + filteredBrowserFields, + setSelectedCategoryIds, + selectedCategoryIds, +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const togglePopover = useCallback(() => { + setIsPopoverOpen((open) => !open); + }, []); + const closePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const totalCategories = useMemo( + () => Object.keys(filteredBrowserFields).length, + [filteredBrowserFields] + ); + + const categoryOptions: CategoryOption[] = useMemo(() => { + const unselectedCategoryIds = Object.keys( + omit(filteredBrowserFields, selectedCategoryIds) + ).sort(); + return [ + ...selectedCategoryIds.map((categoryId) => ({ + label: categoryId, + count: getFieldCount(filteredBrowserFields[categoryId]), + checked: 'on', + })), + ...unselectedCategoryIds.map((categoryId) => ({ + label: categoryId, + count: getFieldCount(filteredBrowserFields[categoryId]), + })), + ]; + }, [selectedCategoryIds, filteredBrowserFields]); + + const onCategoriesChange = useCallback( + (options: CategoryOption[]) => { + setSelectedCategoryIds( + options.filter(({ checked }) => checked === 'on').map(({ label }) => label) + ); + }, + [setSelectedCategoryIds] + ); + + const onKeyDown = useCallback((keyboardEvent: React.KeyboardEvent) => { + if (isEscape(keyboardEvent)) { + // Prevent escape to close the field browser modal after closing the category selector + keyboardEvent.stopPropagation(); + } + }, []); + + return ( + + 0} + iconType="arrowDown" + isSelected={isPopoverOpen} + numActiveFilters={selectedCategoryIds.length} + numFilters={totalCategories} + onClick={togglePopover} + > + {i18n.CATEGORIES} + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + > + + + {(list, search) => ( + <> + {search} + {list} + + )} + + + + + ); +}; + +export const CategoriesSelector = React.memo(CategoriesSelectorComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category.test.tsx deleted file mode 100644 index 98f02a9484ea..000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category.test.tsx +++ /dev/null @@ -1,100 +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 from 'react'; - -import { useMountAppended } from '../../../utils/use_mount_appended'; - -import { Category } from './category'; -import { getFieldItems } from './field_items'; -import { FIELDS_PANE_WIDTH } from './helpers'; -import { mockBrowserFields, TestProviders } from '../../../../mock'; - -import * as i18n from './translations'; - -describe('Category', () => { - const timelineId = 'test'; - const selectedCategoryId = 'client'; - const mount = useMountAppended(); - - test('it renders the category id as the value of the title', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="selected-category-title"]').first().text()).toEqual( - selectedCategoryId - ); - }); - - test('it renders the Field column header', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('.euiTableCellContent__text').at(1).text()).toEqual(i18n.FIELD); - }); - - test('it renders the Description column header', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('.euiTableCellContent__text').at(2).text()).toEqual(i18n.DESCRIPTION); - }); -}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category.tsx deleted file mode 100644 index 3130c46aa068..000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category.tsx +++ /dev/null @@ -1,114 +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 { EuiInMemoryTable } from '@elastic/eui'; -import { noop } from 'lodash/fp'; -import React, { useCallback, useMemo, useRef } from 'react'; -import styled from 'styled-components'; -import { - arrayIndexToAriaIndex, - DATA_COLINDEX_ATTRIBUTE, - DATA_ROWINDEX_ATTRIBUTE, - onKeyDownFocusHandler, -} from '../../../../../common/utils/accessibility'; -import type { BrowserFields } from '../../../../../common/search_strategy'; -import type { OnUpdateColumns } from '../../../../../common/types'; - -import { CategoryTitle } from './category_title'; -import { getFieldColumns } from './field_items'; -import type { FieldItem } from './field_items'; -import { CATEGORY_TABLE_CLASS_NAME, TABLE_HEIGHT } from './helpers'; - -import * as i18n from './translations'; - -const TableContainer = styled.div<{ height: number; width: number }>` - ${({ height }) => `height: ${height}px`}; - ${({ width }) => `width: ${width}px`}; - overflow: hidden; -`; - -TableContainer.displayName = 'TableContainer'; - -/** - * This callback, invoked via `EuiInMemoryTable`'s `rowProps, assigns - * attributes to every ``. - */ -const getAriaRowindex = (fieldItem: FieldItem) => - fieldItem.ariaRowindex != null ? { 'data-rowindex': fieldItem.ariaRowindex } : {}; - -interface Props { - categoryId: string; - fieldItems: FieldItem[]; - filteredBrowserFields: BrowserFields; - onCategorySelected: (categoryId: string) => void; - onUpdateColumns: OnUpdateColumns; - timelineId: string; - width: number; -} - -export const Category = React.memo( - ({ categoryId, filteredBrowserFields, fieldItems, onUpdateColumns, timelineId, width }) => { - const containerElement = useRef(null); - const onKeyDown = useCallback( - (keyboardEvent: React.KeyboardEvent) => { - onKeyDownFocusHandler({ - colindexAttribute: DATA_COLINDEX_ATTRIBUTE, - containerElement: containerElement?.current, - event: keyboardEvent, - maxAriaColindex: 3, - maxAriaRowindex: fieldItems.length, - onColumnFocused: noop, - rowindexAttribute: DATA_ROWINDEX_ATTRIBUTE, - }); - }, - [fieldItems.length] - ); - - const fieldItemsWithRowindex = useMemo( - () => - fieldItems.map((fieldItem, i) => ({ - ...fieldItem, - ariaRowindex: arrayIndexToAriaIndex(i), - })), - [fieldItems] - ); - - const columns = useMemo(() => getFieldColumns(), []); - - return ( - <> - - - - - - - ); - } -); - -Category.displayName = 'Category'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category_columns.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category_columns.test.tsx deleted file mode 100644 index a94ffee597c7..000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category_columns.test.tsx +++ /dev/null @@ -1,153 +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 { mount } from 'enzyme'; -import React from 'react'; - -import { mockBrowserFields, TestProviders } from '../../../../mock'; - -import { CATEGORY_PANE_WIDTH, getFieldCount, VIEW_ALL_BUTTON_CLASS_NAME } from './helpers'; -import { CategoriesPane } from './categories_pane'; -import { ViewAllButton } from './category_columns'; - -const timelineId = 'test'; - -describe('getCategoryColumns', () => { - Object.keys(mockBrowserFields).forEach((categoryId) => { - test(`it renders the ${categoryId} category name (from filteredBrowserFields)`, () => { - const wrapper = mount( - - ); - - const fieldCount = Object.keys(mockBrowserFields[categoryId].fields ?? {}).length; - - expect( - wrapper.find(`.field-browser-category-pane-${categoryId}-${timelineId}`).first().text() - ).toEqual(`${categoryId}${fieldCount}`); - }); - }); - - Object.keys(mockBrowserFields).forEach((categoryId) => { - test(`it renders the correct field count for the ${categoryId} category (from filteredBrowserFields)`, () => { - const wrapper = mount( - - ); - - expect( - wrapper.find(`[data-test-subj="${categoryId}-category-count"]`).first().text() - ).toEqual(`${getFieldCount(mockBrowserFields[categoryId])}`); - }); - }); - - test('it renders the selected category with bold text', () => { - const selectedCategoryId = 'auditd'; - - const wrapper = mount( - - ); - - expect( - wrapper - .find(`.field-browser-category-pane-${selectedCategoryId}-${timelineId}`) - .find('[data-test-subj="categoryName"]') - .at(1) - ).toHaveStyleRule('font-weight', 'bold', { modifier: '.euiText' }); - }); - - test('it does NOT render an un-selected category with bold text', () => { - const selectedCategoryId = 'auditd'; - const notTheSelectedCategoryId = 'base'; - - const wrapper = mount( - - ); - - expect( - wrapper - .find(`.field-browser-category-pane-${notTheSelectedCategoryId}-${timelineId}`) - .find('[data-test-subj="categoryName"]') - .at(1) - ).toHaveStyleRule('font-weight', 'normal', { modifier: '.euiText' }); - }); - - test('it invokes onCategorySelected when a user clicks a category', () => { - const selectedCategoryId = 'auditd'; - const notTheSelectedCategoryId = 'base'; - - const onCategorySelected = jest.fn(); - - const wrapper = mount( - - ); - - wrapper - .find(`.field-browser-category-pane-${notTheSelectedCategoryId}-${timelineId}`) - .first() - .simulate('click'); - - expect(onCategorySelected).toHaveBeenCalledWith(notTheSelectedCategoryId); - }); -}); - -describe('ViewAllButton', () => { - it(`should update fields with the timestamp and category fields`, () => { - const onUpdateColumns = jest.fn(); - - const wrapper = mount( - - - - ); - - wrapper.find(`.${VIEW_ALL_BUTTON_CLASS_NAME}`).first().simulate('click'); - - expect(onUpdateColumns).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ id: '@timestamp' }), - expect.objectContaining({ id: 'agent.ephemeral_id' }), - expect.objectContaining({ id: 'agent.hostname' }), - expect.objectContaining({ id: 'agent.id' }), - expect.objectContaining({ id: 'agent.name' }), - ]) - ); - }); -}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category_columns.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category_columns.tsx deleted file mode 100644 index 0fdf71ff5ffe..000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category_columns.tsx +++ /dev/null @@ -1,157 +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 { - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiText, - EuiToolTip, -} from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import styled from 'styled-components'; - -import { useDeepEqualSelector } from '../../../../hooks/use_selector'; -import { - LoadingSpinner, - getCategoryPaneCategoryClassName, - getFieldCount, - VIEW_ALL_BUTTON_CLASS_NAME, - CountBadge, -} from './helpers'; -import * as i18n from './translations'; -import { tGridSelectors } from '../../../../store/t_grid'; -import { getColumnsWithTimestamp } from '../../../utils/helpers'; -import type { BrowserFields } from '../../../../../common/search_strategy'; -import type { OnUpdateColumns } from '../../../../../common/types'; - -const CategoryName = styled.span<{ bold: boolean }>` - .euiText { - font-weight: ${({ bold }) => (bold ? 'bold' : 'normal')}; - } -`; - -CategoryName.displayName = 'CategoryName'; - -const LinkContainer = styled.div` - width: 100%; - .euiLink { - width: 100%; - } -`; - -LinkContainer.displayName = 'LinkContainer'; - -const ViewAll = styled(EuiButtonIcon)` - margin-left: 2px; -`; - -ViewAll.displayName = 'ViewAll'; - -export interface CategoryItem { - categoryId: string; -} - -interface ViewAllButtonProps { - categoryId: string; - browserFields: BrowserFields; - onUpdateColumns: OnUpdateColumns; - timelineId: string; -} - -export const ViewAllButton = React.memo( - ({ categoryId, browserFields, onUpdateColumns, timelineId }) => { - const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []); - const { isLoading } = useDeepEqualSelector((state) => - getManageTimeline(state, timelineId ?? '') - ); - - const handleClick = useCallback(() => { - onUpdateColumns( - getColumnsWithTimestamp({ - browserFields, - category: categoryId, - }) - ); - }, [browserFields, categoryId, onUpdateColumns]); - - return ( - - {!isLoading ? ( - - ) : ( - - )} - - ); - } -); - -ViewAllButton.displayName = 'ViewAllButton'; - -/** - * Returns the column definition for the (single) column that displays all the - * category names in the field browser */ -export const getCategoryColumns = ({ - filteredBrowserFields, - onCategorySelected, - selectedCategoryId, - timelineId, -}: { - filteredBrowserFields: BrowserFields; - onCategorySelected: (categoryId: string) => void; - selectedCategoryId: string; - timelineId: string; -}) => [ - { - field: 'categoryId', - name: '', - sortable: true, - truncateText: false, - render: ( - categoryId: string, - { ariaRowindex }: { categoryId: string; ariaRowindex: number } - ) => ( - - onCategorySelected(categoryId)} - > - - - - {categoryId} - - - - - - {getFieldCount(filteredBrowserFields[categoryId])} - - - - - - ), - }, -]; diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category_title.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category_title.test.tsx deleted file mode 100644 index 746668491abb..000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category_title.test.tsx +++ /dev/null @@ -1,72 +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 { mount } from 'enzyme'; -import React from 'react'; - -import { mockBrowserFields, TestProviders } from '../../../../mock'; - -import { CategoryTitle } from './category_title'; -import { getFieldCount } from './helpers'; - -describe('CategoryTitle', () => { - const timelineId = 'test'; - - test('it renders the category id as the value of the title', () => { - const categoryId = 'client'; - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="selected-category-title"]').first().text()).toEqual( - categoryId - ); - }); - - test('when `categoryId` specifies a valid category in `filteredBrowserFields`, a count of the field is displayed in the badge', () => { - const validCategoryId = 'client'; - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="selected-category-count-badge"]`).first().text()).toEqual( - `${getFieldCount(mockBrowserFields[validCategoryId])}` - ); - }); - - test('when `categoryId` specifies an INVALID category in `filteredBrowserFields`, a count of zero is displayed in the badge', () => { - const invalidCategoryId = 'this.is.not.happening'; - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="selected-category-count-badge"]`).first().text()).toEqual( - '0' - ); - }); -}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category_title.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category_title.tsx deleted file mode 100644 index 0858f30a3524..000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/category_title.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiScreenReaderOnly, EuiTitle } from '@elastic/eui'; -import React from 'react'; - -import { CountBadge, getFieldBrowserCategoryTitleClassName, getFieldCount } from './helpers'; -import type { BrowserFields } from '../../../../../common/search_strategy'; -import type { OnUpdateColumns } from '../../../../../common/types'; - -import { ViewAllButton } from './category_columns'; -import * as i18n from './translations'; - -interface Props { - /** The title of the category */ - categoryId: string; - /** - * A map of categoryId -> metadata about the fields in that category, - * filtered such that the name of every field in the category includes - * the filter input (as a substring). - */ - filteredBrowserFields: BrowserFields; - onUpdateColumns: OnUpdateColumns; - - /** The timeline associated with this field browser */ - timelineId: string; -} - -export const CategoryTitle = React.memo( - ({ filteredBrowserFields, categoryId, onUpdateColumns, timelineId }) => ( - - - -

    {i18n.CATEGORY}

    -
    - -

    {categoryId}

    -
    -
    - - - - {getFieldCount(filteredBrowserFields[categoryId])} - - - - - - -
    - ) -); - -CategoryTitle.displayName = 'CategoryTitle'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx index dc9837007e15..ed665155ddcf 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx @@ -32,19 +32,22 @@ const testProps = { browserFields: mockBrowserFields, filteredBrowserFields: mockBrowserFields, searchInput: '', + appliedFilterInput: '', isSearching: false, - onCategorySelected: jest.fn(), + setSelectedCategoryIds: jest.fn(), onHide, onSearchInputChange: jest.fn(), restoreFocusTo: React.createRef(), - selectedCategoryId: '', + selectedCategoryIds: [], timelineId, }; const { storage } = createSecuritySolutionStorageMock(); + describe('FieldsBrowser', () => { beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); + test('it renders the Close button', () => { const wrapper = mount( @@ -79,19 +82,7 @@ describe('FieldsBrowser', () => { test('it invokes updateColumns action when the user clicks the Reset Fields button', () => { const wrapper = mount( - ()} - selectedCategoryId={''} - timelineId={timelineId} - /> + ); @@ -127,24 +118,24 @@ describe('FieldsBrowser', () => { expect(wrapper.find('[data-test-subj="field-search"]').exists()).toBe(true); }); - test('it renders the categories pane', () => { + test('it renders the categories selector', () => { const wrapper = mount( ); - expect(wrapper.find('[data-test-subj="left-categories-pane"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="categories-selector"]').exists()).toBe(true); }); - test('it renders the fields pane', () => { + test('it renders the fields table', () => { const wrapper = mount( ); - expect(wrapper.find('[data-test-subj="fields-pane"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="field-table"]').exists()).toBe(true); }); test('focuses the search input when the component mounts', () => { @@ -181,19 +172,24 @@ describe('FieldsBrowser', () => { expect(onSearchInputChange).toBeCalledWith(inputText); }); - test('does not render the CreateField button when createFieldComponent is provided without a dataViewId', () => { + test('does not render the CreateFieldButton when it is provided but does not have a dataViewId', () => { const MyTestComponent = () =>
    {'test'}
    ; const wrapper = mount( - + ); expect(wrapper.find(MyTestComponent).exists()).toBeFalsy(); }); - test('it renders the CreateField button when createFieldComponent is provided with a dataViewId', () => { + test('it renders the CreateFieldButton when it is provided and have a dataViewId', () => { const state: State = { ...mockGlobalState, timelineById: { @@ -210,7 +206,12 @@ describe('FieldsBrowser', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx index fea22e4efe77..5a01c820aa96 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx @@ -17,51 +17,27 @@ import { EuiButtonEmpty, EuiSpacer, } from '@elastic/eui'; -import React, { useEffect, useCallback, useRef, useMemo } from 'react'; -import styled from 'styled-components'; +import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import type { BrowserFields } from '../../../../../common/search_strategy'; -import type { ColumnHeaderOptions, CreateFieldComponentType } from '../../../../../common/types'; -import { - isEscape, - isTab, - stopPropagationAndPreventDefault, -} from '../../../../../common/utils/accessibility'; -import { CategoriesPane } from './categories_pane'; -import { FieldsPane } from './fields_pane'; +import type { FieldBrowserProps, ColumnHeaderOptions } from '../../../../../common/types'; import { Search } from './search'; -import { - CATEGORY_PANE_WIDTH, - CLOSE_BUTTON_CLASS_NAME, - FIELDS_PANE_WIDTH, - FIELD_BROWSER_WIDTH, - focusSearchInput, - onFieldsBrowserTabPressed, - PANES_FLEX_GROUP_WIDTH, - RESET_FIELDS_CLASS_NAME, - scrollCategoriesPane, -} from './helpers'; -import type { FieldBrowserProps } from './types'; +import { CLOSE_BUTTON_CLASS_NAME, FIELD_BROWSER_WIDTH, RESET_FIELDS_CLASS_NAME } from './helpers'; import { tGridActions, tGridSelectors } from '../../../../store/t_grid'; import * as i18n from './translations'; import { useDeepEqualSelector } from '../../../../hooks/use_selector'; +import { CategoriesSelector } from './categories_selector'; +import { FieldTable } from './field_table'; +import { CategoriesBadges } from './categories_badges'; -const PanesFlexGroup = styled(EuiFlexGroup)` - width: ${PANES_FLEX_GROUP_WIDTH}px; -`; -PanesFlexGroup.displayName = 'PanesFlexGroup'; - -type Props = Pick & { +type Props = Pick & { /** * The current timeline column headers */ columnHeaders: ColumnHeaderOptions[]; - - createFieldComponent?: CreateFieldComponentType; - /** * A map of categoryId -> metadata about the fields in that category, * filtered such that the name of every field in the category includes @@ -75,15 +51,17 @@ type Props = Pick & isSearching: boolean; /** The text displayed in the search input */ searchInput: string; + /** The text actually being applied to the result set, a debounced version of searchInput */ + appliedFilterInput: string; /** * The category selected on the left-hand side of the field browser */ - selectedCategoryId: string; + selectedCategoryIds: string[]; /** * Invoked when the user clicks on the name of a category in the left-hand * side of the field browser */ - onCategorySelected: (categoryId: string) => void; + setSelectedCategoryIds: (categoryIds: string[]) => void; /** * Hides the field browser when invoked */ @@ -108,22 +86,23 @@ type Props = Pick & const FieldsBrowserComponent: React.FC = ({ columnHeaders, filteredBrowserFields, - createFieldComponent: CreateField, isSearching, - onCategorySelected, + setSelectedCategoryIds, onSearchInputChange, onHide, + options, restoreFocusTo, searchInput, - selectedCategoryId, + appliedFilterInput, + selectedCategoryIds, timelineId, width = FIELD_BROWSER_WIDTH, }) => { const dispatch = useDispatch(); - const containerElement = useRef(null); const onUpdateColumns = useCallback( - (columns) => dispatch(tGridActions.updateColumns({ id: timelineId, columns })), + (columns: ColumnHeaderOptions[]) => + dispatch(tGridActions.updateColumns({ id: timelineId, columns })), [dispatch, timelineId] ); @@ -153,45 +132,14 @@ const FieldsBrowserComponent: React.FC = ({ [onSearchInputChange] ); - const scrollViewsAndFocusInput = useCallback(() => { - scrollCategoriesPane({ - containerElement: containerElement.current, - selectedCategoryId, - timelineId, - }); - - // always re-focus the input to enable additional filtering - focusSearchInput({ - containerElement: containerElement.current, - timelineId, - }); - }, [selectedCategoryId, timelineId]); - - useEffect(() => { - scrollViewsAndFocusInput(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedCategoryId, timelineId]); - - const onKeyDown = useCallback( - (keyboardEvent: React.KeyboardEvent) => { - if (isEscape(keyboardEvent)) { - stopPropagationAndPreventDefault(keyboardEvent); - closeAndRestoreFocus(); - } else if (isTab(keyboardEvent)) { - onFieldsBrowserTabPressed({ - containerElement: containerElement.current, - keyboardEvent, - selectedCategoryId, - timelineId, - }); - } - }, - [closeAndRestoreFocus, containerElement, selectedCategoryId, timelineId] - ); + const [CreateFieldButton, getFieldTableColumns] = [ + options?.createFieldButton, + options?.getFieldTableColumns, + ]; return ( -
    +

    {i18n.FIELDS_BROWSER}

    @@ -199,11 +147,10 @@ const FieldsBrowserComponent: React.FC = ({
    - + = ({ /> - {CreateField && dataViewId != null && dataViewId.length > 0 && ( - + + + + {CreateFieldButton && dataViewId != null && dataViewId.length > 0 && ( + )} + + - - - - - - - - + diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_items.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_items.test.tsx index a4c830c3d880..45b122354528 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_items.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_items.test.tsx @@ -5,21 +5,17 @@ * 2.0. */ -import { omit } from 'lodash/fp'; import React from 'react'; -import { waitFor } from '@testing-library/react'; -import { mockBrowserFields, TestProviders } from '../../../../mock'; +import { omit } from 'lodash/fp'; +import { render } from '@testing-library/react'; +import { EuiInMemoryTable } from '@elastic/eui'; +import { mockBrowserFields } from '../../../../mock'; import { defaultColumnHeaderType } from '../../body/column_headers/default_headers'; import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../body/constants'; -import { Category } from './category'; import { getFieldColumns, getFieldItems } from './field_items'; -import { FIELDS_PANE_WIDTH } from './helpers'; -import { useMountAppended } from '../../../utils/use_mount_appended'; import { ColumnHeaderOptions } from '../../../../../common/types'; -const selectedCategoryId = 'base'; -const selectedCategoryFields = mockBrowserFields[selectedCategoryId].fields; const timestampFieldId = '@timestamp'; const columnHeaders: ColumnHeaderOptions[] = [ { @@ -28,7 +24,7 @@ const columnHeaders: ColumnHeaderOptions[] = [ description: 'Date/time when the event originated.\nFor log events this is the date/time when the event was generated, and not when it was read.\nRequired field for all events.', example: '2016-05-23T08:05:34.853Z', - id: '@timestamp', + id: timestampFieldId, type: 'date', aggregatable: true, initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, @@ -36,295 +32,199 @@ const columnHeaders: ColumnHeaderOptions[] = [ ]; describe('field_items', () => { - const timelineId = 'test'; - const mount = useMountAppended(); - describe('getFieldItems', () => { - Object.keys(selectedCategoryFields!).forEach((fieldId) => { - test(`it renders the name of the ${fieldId} field`, () => { - const wrapper = mount( - - - - ); + const timestampField = mockBrowserFields.base.fields![timestampFieldId]; - expect(wrapper.find(`[data-test-subj="field-name-${fieldId}"]`).first().text()).toEqual( - fieldId - ); + it('should return browser field item format', () => { + const fieldItems = getFieldItems({ + selectedCategoryIds: ['base'], + browserFields: { base: { fields: { [timestampFieldId]: timestampField } } }, + columnHeaders: [], }); - }); - Object.keys(selectedCategoryFields!).forEach((fieldId) => { - test(`it renders a checkbox for the ${fieldId} field`, () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="field-${fieldId}-checkbox"]`).first().exists()).toBe( - true - ); + expect(fieldItems[0]).toEqual({ + name: timestampFieldId, + description: timestampField.description, + category: 'base', + selected: false, + type: timestampField.type, + example: timestampField.example, + isRuntime: false, }); }); - test('it renders a checkbox in the checked state when the field is selected to be displayed as a column in the timeline', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find(`[data-test-subj="field-${timestampFieldId}-checkbox"]`).first().props() - .checked - ).toBe(true); - }); - - test('it does NOT render a checkbox in the checked state when the field is NOT selected to be displayed as a column in the timeline', () => { - const wrapper = mount( - - header.id !== timestampFieldId), - highlight: '', - timelineId, - toggleColumn: jest.fn(), - })} - width={FIELDS_PANE_WIDTH} - onCategorySelected={jest.fn()} - onUpdateColumns={jest.fn()} - timelineId={timelineId} - /> - - ); - - expect( - wrapper.find(`[data-test-subj="field-${timestampFieldId}-checkbox"]`).first().props() - .checked - ).toBe(false); - }); - - test('it invokes `toggleColumn` when the user interacts with the checkbox', () => { - const toggleColumn = jest.fn(); - - const wrapper = mount( - - - - ); - - wrapper - .find('input[type="checkbox"]') - .first() - .simulate('change', { - target: { checked: true }, - }); - wrapper.update(); + it('should return selected item', () => { + const fieldItems = getFieldItems({ + selectedCategoryIds: ['base'], + browserFields: { base: { fields: { [timestampFieldId]: timestampField } } }, + columnHeaders, + }); - expect(toggleColumn).toBeCalledWith({ - columnHeaderType: 'not-filtered', - id: '@timestamp', - initialWidth: 180, + expect(fieldItems[0]).toMatchObject({ + selected: true, }); }); - test('it returns the expected signal column settings', async () => { - const mockSelectedCategoryId = 'signal'; - const mockBrowserFieldsWithSignal = { - ...mockBrowserFields, - signal: { - fields: { - 'signal.rule.name': { - aggregatable: true, - category: 'signal', - description: 'rule name', - example: '', - format: '', - indexes: ['auditbeat', 'filebeat', 'packetbeat'], - name: 'signal.rule.name', - searchable: true, - type: 'string', + it('should return isRuntime field', () => { + const fieldItems = getFieldItems({ + selectedCategoryIds: ['base'], + browserFields: { + base: { + fields: { + [timestampFieldId]: { + ...timestampField, + runtimeField: { type: 'keyword', script: { source: 'scripts are fun' } }, + }, }, }, }, - }; - const toggleColumn = jest.fn(); - const wrapper = mount( - - - - ); - wrapper - .find(`[data-test-subj="field-signal.rule.name-checkbox"]`) - .last() - .simulate('change', { - target: { checked: true }, - }); + columnHeaders, + }); - await waitFor(() => { - expect(toggleColumn).toBeCalledWith({ - columnHeaderType: 'not-filtered', - id: 'signal.rule.name', - initialWidth: 180, - }); + expect(fieldItems[0]).toMatchObject({ + isRuntime: true, }); }); - test('it renders the expected icon for a field', () => { - const wrapper = mount( - - - + it('should return all field items of all categories if no category selected', () => { + const fieldCount = Object.values(mockBrowserFields).reduce( + (total, { fields }) => total + Object.keys(fields ?? {}).length, + 0 ); - expect( - wrapper.find(`[data-test-subj="field-${timestampFieldId}-icon"]`).first().props().type - ).toEqual('clock'); + const fieldItems = getFieldItems({ + selectedCategoryIds: [], + browserFields: mockBrowserFields, + columnHeaders: [], + }); + + expect(fieldItems.length).toBe(fieldCount); }); - test('it renders the expected field description', () => { - const wrapper = mount( - - - + it('should return filtered field items of selected categories', () => { + const selectedCategoryIds = ['base', 'event']; + const fieldCount = selectedCategoryIds.reduce( + (total, selectedCategoryId) => + total + Object.keys(mockBrowserFields[selectedCategoryId].fields ?? {}).length, + 0 ); - expect( - wrapper.find(`[data-test-subj="field-${timestampFieldId}-description"]`).first().text() - ).toEqual( - 'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events. Example: 2016-05-23T08:05:34.853Z' - ); + const fieldItems = getFieldItems({ + selectedCategoryIds, + browserFields: mockBrowserFields, + columnHeaders: [], + }); + + expect(fieldItems.length).toBe(fieldCount); }); }); describe('getFieldColumns', () => { - test('it returns the expected column definitions', () => { - expect(getFieldColumns().map((column) => omit('render', column))).toEqual([ + const onToggleColumn = jest.fn(); + + beforeEach(() => { + onToggleColumn.mockClear(); + }); + + it('should return default field columns', () => { + expect(getFieldColumns({ onToggleColumn }).map((column) => omit('render', column))).toEqual([ { - field: 'checkbox', + field: 'selected', name: '', sortable: false, width: '25px', }, - { field: 'field', name: 'Field', sortable: false, width: '225px' }, + { + field: 'name', + name: 'Name', + sortable: true, + width: '225px', + }, { field: 'description', name: 'Description', + sortable: true, + width: '400px', + }, + { + field: 'category', + name: 'Category', + sortable: true, + width: '100px', + }, + ]); + }); + + it('should return custom field columns', () => { + const customColumns = [ + { + field: 'name', + name: 'customColumn1', sortable: false, - truncateText: true, + width: '225px', + }, + { + field: 'description', + name: 'customColumn2', + sortable: true, width: '400px', }, + ]; + + expect( + getFieldColumns({ + onToggleColumn, + getFieldTableColumns: () => customColumns, + }).map((column) => omit('render', column)) + ).toEqual([ + { + field: 'selected', + name: '', + sortable: false, + width: '25px', + }, + ...customColumns, ]); }); + + it('should render default columns', () => { + const timestampField = mockBrowserFields.base.fields![timestampFieldId]; + const fieldItems = getFieldItems({ + selectedCategoryIds: ['base'], + browserFields: { base: { fields: { [timestampFieldId]: timestampField } } }, + columnHeaders: [], + }); + + const columns = getFieldColumns({ onToggleColumn }); + const { getByTestId, getAllByText } = render( + + ); + + expect(getAllByText('Name').at(0)).toBeInTheDocument(); + expect(getAllByText('Description').at(0)).toBeInTheDocument(); + expect(getAllByText('Category').at(0)).toBeInTheDocument(); + + expect(getByTestId(`field-${timestampFieldId}-checkbox`)).toBeInTheDocument(); + expect(getByTestId(`field-${timestampFieldId}-name`)).toBeInTheDocument(); + expect(getByTestId(`field-${timestampFieldId}-description`)).toBeInTheDocument(); + expect(getByTestId(`field-${timestampFieldId}-category`)).toBeInTheDocument(); + }); + + it('should call call toggle callback on checkbox click', () => { + const timestampField = mockBrowserFields.base.fields![timestampFieldId]; + const fieldItems = getFieldItems({ + selectedCategoryIds: ['base'], + browserFields: { base: { fields: { [timestampFieldId]: timestampField } } }, + columnHeaders: [], + }); + + const columns = getFieldColumns({ onToggleColumn }); + const { getByTestId } = render( + + ); + + getByTestId(`field-${timestampFieldId}-checkbox`).click(); + expect(onToggleColumn).toHaveBeenCalledWith(timestampFieldId); + }); }); }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_items.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_items.tsx index a979e209bf64..1e066eb2174a 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_items.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_items.tsx @@ -13,14 +13,22 @@ import { EuiFlexGroup, EuiFlexItem, EuiScreenReaderOnly, + EuiBadge, + EuiBasicTableColumn, + EuiTableActionsColumnType, } from '@elastic/eui'; import { uniqBy } from 'lodash/fp'; import styled from 'styled-components'; import { getEmptyValue } from '../../../empty_value'; import { getExampleText, getIconFromType } from '../../../utils/helpers'; -import type { BrowserField } from '../../../../../common/search_strategy'; -import type { ColumnHeaderOptions } from '../../../../../common/types'; +import type { BrowserFields } from '../../../../../common/search_strategy'; +import type { + ColumnHeaderOptions, + BrowserFieldItem, + FieldTableColumns, + GetFieldTableColumns, +} from '../../../../../common/types'; import { defaultColumnHeaderType } from '../../body/column_headers/default_headers'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../../body/constants'; import { TruncatableText } from '../../../truncatable_text'; @@ -33,125 +41,155 @@ const TypeIcon = styled(EuiIcon)` position: relative; top: -1px; `; - TypeIcon.displayName = 'TypeIcon'; export const Description = styled.span` user-select: text; width: 400px; `; - Description.displayName = 'Description'; /** - * An item rendered in the table - */ -export interface FieldItem { - ariaRowindex?: number; - checkbox: React.ReactNode; - description: React.ReactNode; - field: React.ReactNode; - fieldId: string; -} - -/** - * Returns the fields items, values, and descriptions shown when a user expands an event + * Returns the field items of all categories selected */ export const getFieldItems = ({ - category, + browserFields, + selectedCategoryIds, columnHeaders, - highlight = '', - timelineId, - toggleColumn, }: { - category: Partial; + browserFields: BrowserFields; + selectedCategoryIds: string[]; columnHeaders: ColumnHeaderOptions[]; - highlight?: string; - timelineId: string; - toggleColumn: (column: ColumnHeaderOptions) => void; -}): FieldItem[] => - uniqBy('name', [ - ...Object.values(category != null && category.fields != null ? category.fields : {}), - ]).map((field) => ({ - checkbox: ( - - c.id === field.name) !== -1} - data-test-subj={`field-${field.name}-checkbox`} - data-colindex={1} - id={field.name ?? ''} - onChange={() => - toggleColumn({ - columnHeaderType: defaultColumnHeaderType, - id: field.name ?? '', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, - ...getAlertColumnHeader(timelineId, field.name ?? ''), - }) - } - /> - - ), - field: ( - - - - - - +}): BrowserFieldItem[] => { + const categoryIds = + selectedCategoryIds.length > 0 ? selectedCategoryIds : Object.keys(browserFields); + const selectedFieldIds = new Set(columnHeaders.map(({ id }) => id)); - - - - + return uniqBy( + 'name', + categoryIds.reduce((fieldItems, categoryId) => { + const categoryBrowserFields = Object.values(browserFields[categoryId]?.fields ?? {}); + if (categoryBrowserFields.length > 0) { + fieldItems.push( + ...categoryBrowserFields.map(({ name = '', ...field }) => ({ + name, + type: field.type, + description: field.description ?? '', + example: field.example?.toString(), + category: categoryId, + selected: selectedFieldIds.has(name), + isRuntime: !!field.runtimeField, + })) + ); + } + return fieldItems; + }, []) + ); +}; + +/** + * Returns the column header for a field + */ +export const getColumnHeader = (timelineId: string, fieldName: string): ColumnHeaderOptions => ({ + columnHeaderType: defaultColumnHeaderType, + id: fieldName, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + ...getAlertColumnHeader(timelineId, fieldName), +}); + +const getDefaultFieldTableColumns = (highlight: string): FieldTableColumns => [ + { + field: 'name', + name: i18n.NAME, + render: (name: string, { type }) => { + return ( + + + + + + + + + + + + ); + }, + sortable: true, + width: '225px', + }, + { + field: 'description', + name: i18n.DESCRIPTION, + render: (description: string, { name, example }) => ( + + <> + +

    {i18n.DESCRIPTION_FOR_FIELD(name)}

    +
    + + + {`${description ?? getEmptyValue()} ${getExampleText(example)}`} + + + +
    ), - description: ( -
    - - <> - -

    {i18n.DESCRIPTION_FOR_FIELD(field.name ?? '')}

    -
    - - - {`${field.description ?? getEmptyValue()} ${getExampleText(field.example)}`} - - - -
    -
    + sortable: true, + width: '400px', + }, + { + field: 'category', + name: i18n.CATEGORY, + render: (category: string, { name }) => ( + {category} ), - fieldId: field.name ?? '', - })); + sortable: true, + width: '100px', + }, +]; /** * Returns a table column template provided to the `EuiInMemoryTable`'s * `columns` prop */ -export const getFieldColumns = () => [ +export const getFieldColumns = ({ + onToggleColumn, + highlight = '', + getFieldTableColumns, +}: { + onToggleColumn: (id: string) => void; + highlight?: string; + getFieldTableColumns?: GetFieldTableColumns; +}): FieldTableColumns => [ { - field: 'checkbox', + field: 'selected', name: '', - render: (checkbox: React.ReactNode, _: FieldItem) => checkbox, + render: (selected: boolean, { name }) => ( + + onToggleColumn(name)} + /> + + ), sortable: false, width: '25px', }, - { - field: 'field', - name: i18n.FIELD, - render: (field: React.ReactNode, _: FieldItem) => field, - sortable: false, - width: '225px', - }, - { - field: 'description', - name: i18n.DESCRIPTION, - render: (description: React.ReactNode, _: FieldItem) => description, - sortable: false, - truncateText: true, - width: '400px', - }, + ...(getFieldTableColumns + ? getFieldTableColumns(highlight) + : getDefaultFieldTableColumns(highlight)), ]; + +/** Returns whether the table column has actions attached to it */ +export const isActionsColumn = (column: EuiBasicTableColumn): boolean => { + return !!(column as EuiTableActionsColumnType).actions?.length; +}; diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_name.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_name.test.tsx index 05f093eaf180..6bda5873edc2 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_name.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_name.test.tsx @@ -43,7 +43,7 @@ describe('FieldName', () => { ); expect( - wrapper.find(`[data-test-subj="field-name-${timestampFieldId}"]`).first().text() + wrapper.find(`[data-test-subj="field-${timestampFieldId}-name"]`).first().text() ).toEqual(timestampFieldId); }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_name.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_name.tsx index 5781211058d3..0ef0ce64c637 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_name.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_name.tsx @@ -15,7 +15,7 @@ export const FieldName = React.memo<{ }>(({ fieldId, highlight = '' }) => { return ( - + {fieldId} diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx new file mode 100644 index 000000000000..14f2151d2407 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx @@ -0,0 +1,225 @@ +/* + * Copyright 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 { render } from '@testing-library/react'; +import { mockBrowserFields, TestProviders } from '../../../../mock'; +import { tGridActions } from '../../../../store/t_grid'; +import { defaultColumnHeaderType } from '../../body/column_headers/default_headers'; +import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../body/constants'; + +import { ColumnHeaderOptions } from '../../../../../common'; +import { FieldTable } from './field_table'; + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +const timestampFieldId = '@timestamp'; + +const columnHeaders: ColumnHeaderOptions[] = [ + { + category: 'base', + columnHeaderType: defaultColumnHeaderType, + description: + 'Date/time when the event originated.\nFor log events this is the date/time when the event was generated, and not when it was read.\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + id: timestampFieldId, + type: 'date', + aggregatable: true, + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, + }, +]; + +describe('FieldTable', () => { + const timelineId = 'test'; + const timestampField = mockBrowserFields.base.fields![timestampFieldId]; + const defaultPageSize = 10; + const totalFields = Object.values(mockBrowserFields).reduce( + (total, { fields }) => total + Object.keys(fields ?? {}).length, + 0 + ); + + beforeEach(() => { + mockDispatch.mockClear(); + }); + + it('should render empty field table', () => { + const result = render( + + + + ); + + expect(result.getByText('No items found')).toBeInTheDocument(); + expect(result.getByTestId('fields-count').textContent).toContain('0'); + }); + + it('should render field table with fields of all categories', () => { + const result = render( + + + + ); + + expect(result.container.getElementsByClassName('euiTableRow').length).toBe(defaultPageSize); + expect(result.getByTestId('fields-count').textContent).toContain(totalFields); + }); + + it('should render field table with fields of categories selected', () => { + const selectedCategoryIds = ['client', 'event']; + + const fieldCount = selectedCategoryIds.reduce( + (total, selectedCategoryId) => + total + Object.keys(mockBrowserFields[selectedCategoryId].fields ?? {}).length, + 0 + ); + + const result = render( + + + + ); + + expect(result.container.getElementsByClassName('euiTableRow').length).toBe(fieldCount); + expect(result.getByTestId('fields-count').textContent).toContain(fieldCount); + }); + + it('should render field table with custom columns', () => { + const fieldTableColumns = [ + { + field: 'name', + name: 'Custom column', + render: () =>
    , + }, + ]; + + const result = render( + + fieldTableColumns} + selectedCategoryIds={[]} + columnHeaders={[]} + filteredBrowserFields={mockBrowserFields} + searchInput="" + timelineId={timelineId} + /> + + ); + + expect(result.getByTestId('fields-count').textContent).toContain(totalFields); + expect(result.getAllByText('Custom column').length).toBeGreaterThan(0); + expect(result.getAllByTestId('customColumn').length).toEqual(defaultPageSize); + }); + + it('should render field table with unchecked field', () => { + const result = render( + + + + ); + + const checkbox = result.getByTestId(`field-${timestampFieldId}-checkbox`); + expect(checkbox).not.toHaveAttribute('checked'); + }); + + it('should render field table with checked field', () => { + const result = render( + + + + ); + + const checkbox = result.getByTestId(`field-${timestampFieldId}-checkbox`); + expect(checkbox).toHaveAttribute('checked'); + }); + + it('should dispatch remove column action on field unchecked', () => { + const result = render( + + + + ); + + result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith( + tGridActions.removeColumn({ id: timelineId, columnId: timestampFieldId }) + ); + }); + + it('should dispatch upsert column action on field checked', () => { + const result = render( + + + + ); + + result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith( + tGridActions.upsertColumn({ + id: timelineId, + column: { + columnHeaderType: defaultColumnHeaderType, + id: timestampFieldId, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, + index: 1, + }) + ); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx new file mode 100644 index 000000000000..332422ed664f --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx @@ -0,0 +1,126 @@ +/* + * Copyright 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, { useCallback, useMemo } from 'react'; +import styled from 'styled-components'; +import { EuiInMemoryTable, EuiText } from '@elastic/eui'; +import { useDispatch } from 'react-redux'; +import { BrowserFields, ColumnHeaderOptions } from '../../../../../common'; +import * as i18n from './translations'; +import { getColumnHeader, getFieldColumns, getFieldItems, isActionsColumn } from './field_items'; +import { CATEGORY_TABLE_CLASS_NAME, TABLE_HEIGHT } from './helpers'; +import { tGridActions } from '../../../../store/t_grid'; +import type { GetFieldTableColumns } from '../../../../../common/types/fields_browser'; + +interface FieldTableProps { + timelineId: string; + columnHeaders: ColumnHeaderOptions[]; + /** + * A map of categoryId -> metadata about the fields in that category, + * filtered such that the name of every field in the category includes + * the filter input (as a substring). + */ + filteredBrowserFields: BrowserFields; + /** + * Optional function to customize field table columns + */ + getFieldTableColumns?: GetFieldTableColumns; + /** + * The category selected on the left-hand side of the field browser + */ + selectedCategoryIds: string[]; + /** The text displayed in the search input */ + /** Invoked when a user chooses to view a new set of columns in the timeline */ + searchInput: string; +} + +const TableContainer = styled.div<{ height: number }>` + margin-top: ${({ theme }) => theme.eui.euiSizeXS}; + border-top: ${({ theme }) => theme.eui.euiBorderThin}; + ${({ height }) => `height: ${height}px`}; + overflow: hidden; +`; +TableContainer.displayName = 'TableContainer'; + +const Count = styled.span` + font-weight: bold; +`; +Count.displayName = 'Count'; + +const FieldTableComponent: React.FC = ({ + columnHeaders, + filteredBrowserFields, + getFieldTableColumns, + searchInput, + selectedCategoryIds, + timelineId, +}) => { + const dispatch = useDispatch(); + + const fieldItems = useMemo( + () => + getFieldItems({ + browserFields: filteredBrowserFields, + selectedCategoryIds, + columnHeaders, + }), + [columnHeaders, filteredBrowserFields, selectedCategoryIds] + ); + + const onToggleColumn = useCallback( + (fieldId: string) => { + if (columnHeaders.some(({ id }) => id === fieldId)) { + dispatch( + tGridActions.removeColumn({ + columnId: fieldId, + id: timelineId, + }) + ); + } else { + dispatch( + tGridActions.upsertColumn({ + column: getColumnHeader(timelineId, fieldId), + id: timelineId, + index: 1, + }) + ); + } + }, + [columnHeaders, dispatch, timelineId] + ); + + const columns = useMemo( + () => getFieldColumns({ highlight: searchInput, onToggleColumn, getFieldTableColumns }), + [onToggleColumn, searchInput, getFieldTableColumns] + ); + const hasActions = useMemo(() => columns.some((column) => isActionsColumn(column)), [columns]); + + return ( + <> + + {i18n.FIELDS_SHOWING} + {fieldItems.length} + {i18n.FIELDS_COUNT(fieldItems.length)} + + + + + + + ); +}; + +export const FieldTable = React.memo(FieldTableComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.test.tsx deleted file mode 100644 index aec21b484713..000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.test.tsx +++ /dev/null @@ -1,112 +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 from 'react'; - -import { useMountAppended } from '../../../utils/use_mount_appended'; -import { mockBrowserFields, TestProviders } from '../../../../mock'; - -import { FIELDS_PANE_WIDTH } from './helpers'; -import { FieldsPane } from './fields_pane'; - -const timelineId = 'test'; - -describe('FieldsPane', () => { - const mount = useMountAppended(); - - test('it renders the selected category', () => { - const selectedCategory = 'auditd'; - - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="selected-category-title"]`).first().text()).toEqual( - selectedCategory - ); - }); - - test('it renders a unknown category that does not exist in filteredBrowserFields', () => { - const selectedCategory = 'unknown'; - - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="selected-category-title"]`).first().text()).toEqual( - selectedCategory - ); - }); - - test('it renders the expected message when `filteredBrowserFields` is empty and `searchInput` is empty', () => { - const searchInput = ''; - - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="no-fields-match"]`).first().text()).toEqual( - 'No fields match ' - ); - }); - - test('it renders the expected message when `filteredBrowserFields` is empty and `searchInput` is an unknown field name', () => { - const searchInput = 'thisFieldDoesNotExist'; - - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="no-fields-match"]`).first().text()).toEqual( - `No fields match ${searchInput}` - ); - }); -}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.tsx deleted file mode 100644 index 5345475a0250..000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.tsx +++ /dev/null @@ -1,134 +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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import styled from 'styled-components'; -import { useDispatch } from 'react-redux'; - -import { Category } from './category'; -import { getFieldItems } from './field_items'; -import { FIELDS_PANE_WIDTH, TABLE_HEIGHT } from './helpers'; - -import * as i18n from './translations'; -import type { BrowserFields } from '../../../../../common/search_strategy'; -import type { ColumnHeaderOptions, OnUpdateColumns } from '../../../../../common/types'; -import { tGridActions } from '../../../../store/t_grid'; - -const NoFieldsPanel = styled.div` - background-color: ${(props) => props.theme.eui.euiColorLightestShade}; - width: ${FIELDS_PANE_WIDTH}px; - height: ${TABLE_HEIGHT}px; -`; - -NoFieldsPanel.displayName = 'NoFieldsPanel'; - -const NoFieldsFlexGroup = styled(EuiFlexGroup)` - height: 100%; -`; - -NoFieldsFlexGroup.displayName = 'NoFieldsFlexGroup'; - -interface Props { - timelineId: string; - columnHeaders: ColumnHeaderOptions[]; - /** - * A map of categoryId -> metadata about the fields in that category, - * filtered such that the name of every field in the category includes - * the filter input (as a substring). - */ - filteredBrowserFields: BrowserFields; - /** - * Invoked when the user clicks on the name of a category in the left-hand - * side of the field browser - */ - onCategorySelected: (categoryId: string) => void; - /** The text displayed in the search input */ - /** Invoked when a user chooses to view a new set of columns in the timeline */ - onUpdateColumns: OnUpdateColumns; - searchInput: string; - /** - * The category selected on the left-hand side of the field browser - */ - selectedCategoryId: string; - /** The width field browser */ - width: number; -} -export const FieldsPane = React.memo( - ({ - columnHeaders, - filteredBrowserFields, - onCategorySelected, - onUpdateColumns, - searchInput, - selectedCategoryId, - timelineId, - width, - }) => { - const dispatch = useDispatch(); - - const toggleColumn = useCallback( - (column: ColumnHeaderOptions) => { - if (columnHeaders.some((c) => c.id === column.id)) { - dispatch( - tGridActions.removeColumn({ - columnId: column.id, - id: timelineId, - }) - ); - } else { - dispatch( - tGridActions.upsertColumn({ - column, - id: timelineId, - index: 1, - }) - ); - } - }, - [columnHeaders, dispatch, timelineId] - ); - - const filteredBrowserFieldsExists = useMemo( - () => Object.keys(filteredBrowserFields).length > 0, - [filteredBrowserFields] - ); - - if (filteredBrowserFieldsExists) { - return ( - - ); - } - - return ( - - - -

    {i18n.NO_FIELDS_MATCH_INPUT(searchInput)}

    -
    -
    -
    - ); - } -); - -FieldsPane.displayName = 'FieldsPane'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.test.tsx index 239d7c726e28..ad90956013e4 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.test.tsx @@ -10,45 +10,12 @@ import { mockBrowserFields } from '../../../../mock'; import { categoryHasFields, createVirtualCategory, - getCategoryPaneCategoryClassName, - getFieldBrowserCategoryTitleClassName, - getFieldBrowserSearchInputClassName, getFieldCount, filterBrowserFieldsByFieldName, } from './helpers'; import { BrowserFields } from '../../../../../common/search_strategy'; -const timelineId = 'test'; - describe('helpers', () => { - describe('getCategoryPaneCategoryClassName', () => { - test('it returns the expected class name', () => { - const categoryId = 'auditd'; - - expect(getCategoryPaneCategoryClassName({ categoryId, timelineId })).toEqual( - 'field-browser-category-pane-auditd-test' - ); - }); - }); - - describe('getFieldBrowserCategoryTitleClassName', () => { - test('it returns the expected class name', () => { - const categoryId = 'auditd'; - - expect(getFieldBrowserCategoryTitleClassName({ categoryId, timelineId })).toEqual( - 'field-browser-category-title-auditd-test' - ); - }); - }); - - describe('getFieldBrowserSearchInputClassName', () => { - test('it returns the expected class name', () => { - expect(getFieldBrowserSearchInputClassName(timelineId)).toEqual( - 'field-browser-search-input-test' - ); - }); - }); - describe('categoryHasFields', () => { test('it returns false if the category fields property is undefined', () => { expect(categoryHasFields({})).toBe(false); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.tsx index 5406940aab3e..21829bda265e 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.tsx @@ -9,11 +9,6 @@ import { EuiBadge, EuiLoadingSpinner } from '@elastic/eui'; import { filter, get, pickBy } from 'lodash/fp'; import styled from 'styled-components'; -import { - elementOrChildrenHasFocus, - skipFocusInContainerTo, - stopPropagationAndPreventDefault, -} from '../../../../../common/utils/accessibility'; import { TimelineId } from '../../../../../public/types'; import type { BrowserField, BrowserFields } from '../../../../../common/search_strategy'; import { defaultHeaders } from '../../../../store/t_grid/defaults'; @@ -27,44 +22,8 @@ export const LoadingSpinner = styled(EuiLoadingSpinner)` LoadingSpinner.displayName = 'LoadingSpinner'; -export const CATEGORY_PANE_WIDTH = 200; -export const DESCRIPTION_COLUMN_WIDTH = 300; -export const FIELD_COLUMN_WIDTH = 200; export const FIELD_BROWSER_WIDTH = 925; -export const FIELDS_PANE_WIDTH = 670; -export const HEADER_HEIGHT = 40; -export const PANES_FLEX_GROUP_WIDTH = CATEGORY_PANE_WIDTH + FIELDS_PANE_WIDTH + 10; -export const PANES_FLEX_GROUP_HEIGHT = 260; export const TABLE_HEIGHT = 260; -export const TYPE_COLUMN_WIDTH = 50; - -/** - * Returns the CSS class name for the title of a category shown in the left - * side field browser - */ -export const getCategoryPaneCategoryClassName = ({ - categoryId, - timelineId, -}: { - categoryId: string; - timelineId: string; -}): string => `field-browser-category-pane-${categoryId}-${timelineId}`; - -/** - * Returns the CSS class name for the title of a category shown in the right - * side of field browser - */ -export const getFieldBrowserCategoryTitleClassName = ({ - categoryId, - timelineId, -}: { - categoryId: string; - timelineId: string; -}): string => `field-browser-category-title-${categoryId}-${timelineId}`; - -/** Returns the class name for a field browser search input */ -export const getFieldBrowserSearchInputClassName = (timelineId: string): string => - `field-browser-search-input-${timelineId}`; /** Returns true if the specified category has at least one field */ export const categoryHasFields = (category: Partial): boolean => @@ -160,272 +119,22 @@ export const getAlertColumnHeader = (timelineId: string, fieldId: string) => ? defaultHeaders.find((c) => c.id === fieldId) ?? {} : {}; -export const CATEGORIES_PANE_CLASS_NAME = 'categories-pane'; export const CATEGORY_TABLE_CLASS_NAME = 'category-table'; export const CLOSE_BUTTON_CLASS_NAME = 'close-button'; export const RESET_FIELDS_CLASS_NAME = 'reset-fields'; -export const VIEW_ALL_BUTTON_CLASS_NAME = 'view-all'; - -export const categoriesPaneHasFocus = (containerElement: HTMLElement | null): boolean => - elementOrChildrenHasFocus( - containerElement?.querySelector(`.${CATEGORIES_PANE_CLASS_NAME}`) - ); - -export const categoryTableHasFocus = (containerElement: HTMLElement | null): boolean => - elementOrChildrenHasFocus( - containerElement?.querySelector(`.${CATEGORY_TABLE_CLASS_NAME}`) - ); - -export const closeButtonHasFocus = (containerElement: HTMLElement | null): boolean => - elementOrChildrenHasFocus( - containerElement?.querySelector(`.${CLOSE_BUTTON_CLASS_NAME}`) - ); - -export const searchInputHasFocus = ({ - containerElement, - timelineId, -}: { - containerElement: HTMLElement | null; - timelineId: string; -}): boolean => - elementOrChildrenHasFocus( - containerElement?.querySelector( - `.${getFieldBrowserSearchInputClassName(timelineId)}` - ) - ); - -export const viewAllHasFocus = (containerElement: HTMLElement | null): boolean => - elementOrChildrenHasFocus( - containerElement?.querySelector(`.${VIEW_ALL_BUTTON_CLASS_NAME}`) - ); - -export const resetButtonHasFocus = (containerElement: HTMLElement | null): boolean => - elementOrChildrenHasFocus( - containerElement?.querySelector(`.${RESET_FIELDS_CLASS_NAME}`) - ); - -export const scrollCategoriesPane = ({ - containerElement, - selectedCategoryId, - timelineId, -}: { - containerElement: HTMLElement | null; - selectedCategoryId: string; - timelineId: string; -}) => { - if (selectedCategoryId !== '') { - const selectedCategories = - containerElement?.getElementsByClassName( - getCategoryPaneCategoryClassName({ - categoryId: selectedCategoryId, - timelineId, - }) - ) ?? []; - - if (selectedCategories.length > 0) { - selectedCategories[0].scrollIntoView(); - } - } -}; - -export const focusCategoriesPane = ({ - containerElement, - selectedCategoryId, - timelineId, -}: { - containerElement: HTMLElement | null; - selectedCategoryId: string; - timelineId: string; -}) => { - if (selectedCategoryId !== '') { - const selectedCategories = - containerElement?.getElementsByClassName( - getCategoryPaneCategoryClassName({ - categoryId: selectedCategoryId, - timelineId, - }) - ) ?? []; - - if (selectedCategories.length > 0) { - (selectedCategories[0] as HTMLButtonElement).focus(); - } - } -}; - -export const focusCategoryTable = (containerElement: HTMLElement | null) => { - const firstEntry = containerElement?.querySelector( - `.${CATEGORY_TABLE_CLASS_NAME} [data-colindex="1"]` - ); - - if (firstEntry != null) { - firstEntry.focus(); - } else { - skipFocusInContainerTo({ - containerElement, - className: CATEGORY_TABLE_CLASS_NAME, - }); - } -}; - -export const focusCloseButton = (containerElement: HTMLElement | null) => - skipFocusInContainerTo({ - containerElement, - className: CLOSE_BUTTON_CLASS_NAME, - }); - -export const focusResetFieldsButton = (containerElement: HTMLElement | null) => - skipFocusInContainerTo({ containerElement, className: RESET_FIELDS_CLASS_NAME }); - -export const focusSearchInput = ({ - containerElement, - timelineId, -}: { - containerElement: HTMLElement | null; - timelineId: string; -}) => - skipFocusInContainerTo({ - containerElement, - className: getFieldBrowserSearchInputClassName(timelineId), - }); - -export const focusViewAllButton = (containerElement: HTMLElement | null) => - skipFocusInContainerTo({ containerElement, className: VIEW_ALL_BUTTON_CLASS_NAME }); - -export const onCategoriesPaneFocusChanging = ({ - containerElement, - shiftKey, - timelineId, -}: { - containerElement: HTMLElement | null; - shiftKey: boolean; - timelineId: string; -}) => - shiftKey - ? focusSearchInput({ - containerElement, - timelineId, - }) - : focusViewAllButton(containerElement); - -export const onCategoryTableFocusChanging = ({ - containerElement, - shiftKey, -}: { - containerElement: HTMLElement | null; - shiftKey: boolean; -}) => (shiftKey ? focusViewAllButton(containerElement) : focusResetFieldsButton(containerElement)); - -export const onCloseButtonFocusChanging = ({ - containerElement, - shiftKey, - timelineId, -}: { - containerElement: HTMLElement | null; - shiftKey: boolean; - timelineId: string; -}) => - shiftKey - ? focusResetFieldsButton(containerElement) - : focusSearchInput({ containerElement, timelineId }); - -export const onSearchInputFocusChanging = ({ - containerElement, - selectedCategoryId, - shiftKey, - timelineId, -}: { - containerElement: HTMLElement | null; - selectedCategoryId: string; - shiftKey: boolean; - timelineId: string; -}) => - shiftKey - ? focusCloseButton(containerElement) - : focusCategoriesPane({ containerElement, selectedCategoryId, timelineId }); - -export const onViewAllFocusChanging = ({ - containerElement, - selectedCategoryId, - shiftKey, - timelineId, -}: { - containerElement: HTMLElement | null; - selectedCategoryId: string; - shiftKey: boolean; - timelineId: string; -}) => - shiftKey - ? focusCategoriesPane({ containerElement, selectedCategoryId, timelineId }) - : focusCategoryTable(containerElement); - -export const onResetButtonFocusChanging = ({ - containerElement, - shiftKey, -}: { - containerElement: HTMLElement | null; - shiftKey: boolean; -}) => (shiftKey ? focusCategoryTable(containerElement) : focusCloseButton(containerElement)); - -export const onFieldsBrowserTabPressed = ({ - containerElement, - keyboardEvent, - selectedCategoryId, - timelineId, -}: { - containerElement: HTMLElement | null; - keyboardEvent: React.KeyboardEvent; - selectedCategoryId: string; - timelineId: string; -}) => { - const { shiftKey } = keyboardEvent; - - if (searchInputHasFocus({ containerElement, timelineId })) { - stopPropagationAndPreventDefault(keyboardEvent); - onSearchInputFocusChanging({ - containerElement, - selectedCategoryId, - shiftKey, - timelineId, - }); - } else if (categoriesPaneHasFocus(containerElement)) { - stopPropagationAndPreventDefault(keyboardEvent); - onCategoriesPaneFocusChanging({ - containerElement, - shiftKey, - timelineId, - }); - } else if (viewAllHasFocus(containerElement)) { - stopPropagationAndPreventDefault(keyboardEvent); - onViewAllFocusChanging({ - containerElement, - selectedCategoryId, - shiftKey, - timelineId, - }); - } else if (categoryTableHasFocus(containerElement)) { - stopPropagationAndPreventDefault(keyboardEvent); - onCategoryTableFocusChanging({ - containerElement, - shiftKey, - }); - } else if (resetButtonHasFocus(containerElement)) { - stopPropagationAndPreventDefault(keyboardEvent); - onResetButtonFocusChanging({ - containerElement, - shiftKey, - }); - } else if (closeButtonHasFocus(containerElement)) { - stopPropagationAndPreventDefault(keyboardEvent); - onCloseButtonFocusChanging({ - containerElement, - shiftKey, - timelineId, - }); - } -}; export const CountBadge = styled(EuiBadge)` margin-left: 5px; ` as unknown as typeof EuiBadge; CountBadge.displayName = 'CountBadge'; + +export const CategoryName = styled.span<{ bold: boolean }>` + font-weight: ${({ bold }) => (bold ? 'bold' : 'normal')}; +`; +CategoryName.displayName = 'CategoryName'; + +export const CategorySelectableContainer = styled.div` + width: 300px; +`; +CategorySelectableContainer.displayName = 'CategorySelectableContainer'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.test.tsx index b8bc2a12ffd6..7db742fd1130 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.test.tsx @@ -5,9 +5,8 @@ * 2.0. */ -import { mount } from 'enzyme'; import React from 'react'; -import { waitFor } from '@testing-library/react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { mockBrowserFields, TestProviders } from '../../../../mock'; @@ -18,12 +17,8 @@ import { StatefulFieldsBrowserComponent } from '.'; describe('StatefulFieldsBrowser', () => { const timelineId = 'test'; - beforeEach(() => { - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - }); - - test('it renders the Fields button, which displays the fields browser on click', () => { - const wrapper = mount( + it('should render the Fields button, which displays the fields browser on click', () => { + const result = render( { ); - expect(wrapper.find('[data-test-subj="show-field-browser"]').exists()).toBe(true); + expect(result.getByTestId('show-field-browser')).toBeInTheDocument(); }); describe('toggleShow', () => { - test('it does NOT render the fields browser until the Fields button is clicked', () => { - const wrapper = mount( + it('should NOT render the fields browser until the Fields button is clicked', () => { + const result = render( { ); - expect(wrapper.find('[data-test-subj="fields-browser-container"]').exists()).toBe(false); + expect(result.queryByTestId('fields-browser-container')).toBeNull(); }); - test('it renders the fields browser when the Fields button is clicked', () => { - const wrapper = mount( + it('should render the fields browser when the Fields button is clicked', async () => { + const result = render( { /> ); - - wrapper.find('[data-test-subj="show-field-browser"]').first().simulate('click'); - - expect(wrapper.find('[data-test-subj="fields-browser-container"]').exists()).toBe(true); + result.getByTestId('show-field-browser').click(); + await waitFor(() => { + expect(result.getByTestId('fields-browser-container')).toBeInTheDocument(); + }); }); }); - describe('updateSelectedCategoryId', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - test('it updates the selectedCategoryId state, which makes the category bold, when the user clicks a category name in the left hand side of the field browser', async () => { - const wrapper = mount( + describe('updateSelectedCategoryIds', () => { + it('should add a selected category, which creates the category badge', async () => { + const result = render( { ); - wrapper.find('[data-test-subj="show-field-browser"]').first().simulate('click'); + result.getByTestId('show-field-browser').click(); + await waitFor(() => { + expect(result.getByTestId('fields-browser-container')).toBeInTheDocument(); + }); + + await act(async () => { + result.getByTestId('categories-filter-button').click(); + }); + await act(async () => { + result.getByTestId('categories-selector-option-base').click(); + }); + + expect(result.getByTestId('category-badge-base')).toBeInTheDocument(); + }); + + it('should remove a selected category, which deletes the category badge', async () => { + const result = render( + + + + ); - wrapper.find(`.field-browser-category-pane-auditd-${timelineId}`).first().simulate('click'); + result.getByTestId('show-field-browser').click(); await waitFor(() => { - wrapper.update(); - expect( - wrapper - .find(`.field-browser-category-pane-auditd-${timelineId}`) - .find('[data-test-subj="categoryName"]') - .at(1) - ).toHaveStyleRule('font-weight', 'bold', { modifier: '.euiText' }); + expect(result.getByTestId('fields-browser-container')).toBeInTheDocument(); }); + + await act(async () => { + result.getByTestId('categories-filter-button').click(); + }); + await act(async () => { + result.getByTestId('categories-selector-option-base').click(); + }); + expect(result.getByTestId('category-badge-base')).toBeInTheDocument(); + + await act(async () => { + result.getByTestId('category-badge-unselect-base').click(); + }); + expect(result.queryByTestId('category-badge-base')).toBeNull(); }); - test('it updates the selectedCategoryId state according to most fields returned', async () => { - const wrapper = mount( + it('should update the available categories according to the search input', async () => { + const result = render( { ); + result.getByTestId('show-field-browser').click(); await waitFor(() => { - wrapper.find('[data-test-subj="show-field-browser"]').first().simulate('click'); - jest.runOnlyPendingTimers(); - wrapper.update(); - - expect( - wrapper - .find(`.field-browser-category-pane-cloud-${timelineId}`) - .find('[data-test-subj="categoryName"]') - .at(1) - ).toHaveStyleRule('font-weight', 'normal', { modifier: '.euiText' }); + expect(result.getByTestId('fields-browser-container')).toBeInTheDocument(); }); + result.getByTestId('categories-filter-button').click(); + expect(result.getByTestId('categories-selector-option-base')).toBeInTheDocument(); + + fireEvent.change(result.getByTestId('field-search'), { target: { value: 'client' } }); await waitFor(() => { - wrapper - .find('[data-test-subj="field-search"]') - .last() - .simulate('change', { target: { value: 'cloud' } }); - - jest.runOnlyPendingTimers(); - wrapper.update(); - expect( - wrapper - .find(`.field-browser-category-pane-cloud-${timelineId}`) - .find('[data-test-subj="categoryName"]') - .at(1) - ).toHaveStyleRule('font-weight', 'bold', { modifier: '.euiText' }); + expect(result.queryByTestId('categories-selector-option-base')).toBeNull(); }); + expect(result.queryByTestId('categories-selector-option-client')).toBeInTheDocument(); }); }); - test('it renders the Fields Browser button as a settings gear when the isEventViewer prop is true', () => { + it('should render the Fields Browser button as a settings gear when the isEventViewer prop is true', () => { const isEventViewer = true; - const wrapper = mount( + const result = render( { ); - expect(wrapper.find('[data-test-subj="show-field-browser"]').first().exists()).toBe(true); + expect(result.getByTestId('show-field-browser')).toBeInTheDocument(); }); - test('it renders the Fields Browser button as a settings gear when the isEventViewer prop is false', () => { - const isEventViewer = true; + it('should render the Fields Browser button as a settings gear when the isEventViewer prop is false', () => { + const isEventViewer = false; - const wrapper = mount( + const result = render( { ); - expect(wrapper.find('[data-test-subj="show-field-browser"]').first().exists()).toBe(true); + expect(result.getByTestId('show-field-browser')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.tsx index abe882d9a8b5..c5647c973b9d 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.tsx @@ -6,15 +6,15 @@ */ import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; +import { debounce } from 'lodash'; import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import styled from 'styled-components'; import type { BrowserFields } from '../../../../../common/search_strategy/index_fields'; -import { DEFAULT_CATEGORY_NAME } from '../../body/column_headers/default_headers'; +import type { FieldBrowserProps } from '../../../../../common/types/fields_browser'; import { FieldsBrowser } from './field_browser'; import { filterBrowserFieldsByFieldName, mergeBrowserFieldsWithDefaultCategory } from './helpers'; import * as i18n from './translations'; -import type { FieldBrowserProps } from './types'; const FIELDS_BUTTON_CLASS_NAME = 'fields-button'; @@ -34,32 +34,47 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ timelineId, columnHeaders, browserFields, - createFieldComponent, + options, width, }) => { const customizeColumnsButtonRef = useRef(null); - /** tracks the latest timeout id from `setTimeout`*/ - const inputTimeoutId = useRef(0); - /** all field names shown in the field browser must contain this string (when specified) */ const [filterInput, setFilterInput] = useState(''); + /** debounced filterInput, the one that is applied to the filteredBrowserFields */ + const [appliedFilterInput, setAppliedFilterInput] = useState(''); /** all fields in this collection have field names that match the filterInput */ const [filteredBrowserFields, setFilteredBrowserFields] = useState(null); /** when true, show a spinner in the input to indicate the field browser is searching for matching field names */ const [isSearching, setIsSearching] = useState(false); /** this category will be displayed in the right-hand pane of the field browser */ - const [selectedCategoryId, setSelectedCategoryId] = useState(DEFAULT_CATEGORY_NAME); + const [selectedCategoryIds, setSelectedCategoryIds] = useState([]); /** show the field browser */ const [show, setShow] = useState(false); + + // debounced function to apply the input filter + // will delay the call to setAppliedFilterInput by INPUT_TIMEOUT ms + // the parameter used will be the last one passed + const debouncedApplyFilterInput = useMemo( + () => + debounce((input: string) => { + setAppliedFilterInput(input); + }, INPUT_TIMEOUT), + [] + ); useEffect(() => { return () => { - if (inputTimeoutId.current !== 0) { - // ⚠️ mutation: cancel any remaining timers and zero-out the timer id: - clearTimeout(inputTimeoutId.current); - inputTimeoutId.current = 0; - } + debouncedApplyFilterInput.cancel(); }; - }, []); + }, [debouncedApplyFilterInput]); + + useEffect(() => { + const newFilteredBrowserFields = filterBrowserFieldsByFieldName({ + browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields), + substring: appliedFilterInput, + }); + setFilteredBrowserFields(newFilteredBrowserFields); + setIsSearching(false); + }, [appliedFilterInput, browserFields]); /** Shows / hides the field browser */ const onShow = useCallback(() => { @@ -69,51 +84,21 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ /** Invoked when the field browser should be hidden */ const onHide = useCallback(() => { setFilterInput(''); + setAppliedFilterInput(''); setFilteredBrowserFields(null); setIsSearching(false); - setSelectedCategoryId(DEFAULT_CATEGORY_NAME); + setSelectedCategoryIds([]); setShow(false); }, []); /** Invoked when the user types in the filter input */ const updateFilter = useCallback( (newFilterInput: string) => { - setFilterInput(newFilterInput); setIsSearching(true); - if (inputTimeoutId.current !== 0) { - clearTimeout(inputTimeoutId.current); // ⚠️ mutation: cancel any previous timers - } - // ⚠️ mutation: schedule a new timer that will apply the filter when it fires: - inputTimeoutId.current = window.setTimeout(() => { - const newFilteredBrowserFields = filterBrowserFieldsByFieldName({ - browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields), - substring: newFilterInput, - }); - setFilteredBrowserFields(newFilteredBrowserFields); - setIsSearching(false); - - const newSelectedCategoryId = - newFilterInput === '' || Object.keys(newFilteredBrowserFields).length === 0 - ? DEFAULT_CATEGORY_NAME - : Object.keys(newFilteredBrowserFields) - .sort() - .reduce( - (selected, category) => - newFilteredBrowserFields[category].fields != null && - newFilteredBrowserFields[selected].fields != null && - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - Object.keys(newFilteredBrowserFields[category].fields!).length > - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - Object.keys(newFilteredBrowserFields[selected].fields!).length - ? category - : selected, - Object.keys(newFilteredBrowserFields)[0] - ); - setSelectedCategoryId(newSelectedCategoryId); - }, INPUT_TIMEOUT); + setFilterInput(newFilterInput); + debouncedApplyFilterInput(newFilterInput); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [browserFields, filterInput, inputTimeoutId.current] + [debouncedApplyFilterInput] ); // only merge in the default category if the field browser is visible @@ -141,18 +126,19 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ {show && ( diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/search.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/search.test.tsx index f5668b1bdc08..fb6363e24445 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/search.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/search.test.tsx @@ -7,7 +7,7 @@ import { mount } from 'enzyme'; import React from 'react'; -import { mockBrowserFields, TestProviders } from '../../../../mock'; +import { TestProviders } from '../../../../mock'; import { Search } from './search'; const timelineId = 'test'; @@ -17,7 +17,6 @@ describe('Search', () => { const wrapper = mount( { const wrapper = mount( { const wrapper = mount( { const wrapper = mount( { expect(onSearchInputChange).toBeCalled(); }); - - test('it returns the expected categories count when filteredBrowserFields is empty', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="categories-count"]').first().text()).toEqual( - '0 categories' - ); - }); - - test('it returns the expected categories count when filteredBrowserFields is NOT empty', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="categories-count"]').first().text()).toEqual( - '12 categories' - ); - }); - - test('it returns the expected fields count when filteredBrowserFields is empty', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="fields-count"]').first().text()).toEqual('0 fields'); - }); - - test('it returns the expected fields count when filteredBrowserFields is NOT empty', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="fields-count"]').first().text()).toEqual('34 fields'); - }); }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/search.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/search.tsx index 935952fbf37e..037dcdc9033d 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/search.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/search.tsx @@ -6,75 +6,28 @@ */ import React from 'react'; -import { EuiFieldSearch, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import styled from 'styled-components'; -import type { BrowserFields } from '../../../../../common/search_strategy'; - -import { getFieldBrowserSearchInputClassName, getFieldCount } from './helpers'; - +import { EuiFieldSearch } from '@elastic/eui'; import * as i18n from './translations'; - -const CountsFlexGroup = styled(EuiFlexGroup)` - margin-top: ${({ theme }) => theme.eui.euiSizeXS}; - margin-left: ${({ theme }) => theme.eui.euiSizeXS}; -`; - -CountsFlexGroup.displayName = 'CountsFlexGroup'; - interface Props { - filteredBrowserFields: BrowserFields; isSearching: boolean; onSearchInputChange: (event: React.ChangeEvent) => void; searchInput: string; timelineId: string; } -const CountRow = React.memo>(({ filteredBrowserFields }) => ( - - - - {i18n.CATEGORIES_COUNT(Object.keys(filteredBrowserFields).length)} - - - - - - {i18n.FIELDS_COUNT( - Object.keys(filteredBrowserFields).reduce( - (fieldsCount, category) => getFieldCount(filteredBrowserFields[category]) + fieldsCount, - 0 - ) - )} - - - -)); - -CountRow.displayName = 'CountRow'; - const inputRef = (node: HTMLInputElement | null) => node?.focus(); export const Search = React.memo( - ({ isSearching, filteredBrowserFields, onSearchInputChange, searchInput, timelineId }) => ( - <> - - - + ({ isSearching, onSearchInputChange, searchInput, timelineId }) => ( + ) ); - Search.displayName = 'Search'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/translations.ts b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/translations.ts index ac0160fad6cd..eab412971c58 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/translations.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/translations.ts @@ -21,21 +21,6 @@ export const CATEGORIES_COUNT = (totalCount: number) => defaultMessage: '{totalCount} {totalCount, plural, =1 {category} other {categories}}', }); -export const CATEGORY_LINK = ({ category, totalCount }: { category: string; totalCount: number }) => - i18n.translate('xpack.timelines.fieldBrowser.categoryLinkAriaLabel', { - values: { category, totalCount }, - defaultMessage: - '{category} {totalCount} {totalCount, plural, =1 {field} other {fields}}. Click this button to select the {category} category.', - }); - -export const CATEGORY_FIELDS_TABLE_CAPTION = (categoryId: string) => - i18n.translate('xpack.timelines.fieldBrowser.categoryFieldsTableCaption', { - defaultMessage: 'category {categoryId} fields', - values: { - categoryId, - }, - }); - export const CLOSE = i18n.translate('xpack.timelines.fieldBrowser.closeButton', { defaultMessage: 'Close', }); @@ -56,6 +41,10 @@ export const DESCRIPTION_FOR_FIELD = (field: string) => defaultMessage: 'Description for field {field}:', }); +export const NAME = i18n.translate('xpack.timelines.fieldBrowser.fieldName', { + defaultMessage: 'Name', +}); + export const FIELD = i18n.translate('xpack.timelines.fieldBrowser.fieldLabel', { defaultMessage: 'Field', }); @@ -64,10 +53,14 @@ export const FIELDS = i18n.translate('xpack.timelines.fieldBrowser.fieldsTitle', defaultMessage: 'Fields', }); +export const FIELDS_SHOWING = i18n.translate('xpack.timelines.fieldBrowser.fieldsCountShowing', { + defaultMessage: 'Showing', +}); + export const FIELDS_COUNT = (totalCount: number) => i18n.translate('xpack.timelines.fieldBrowser.fieldsCountTitle', { values: { totalCount }, - defaultMessage: '{totalCount} {totalCount, plural, =1 {field} other {fields}}', + defaultMessage: '{totalCount, plural, =1 {field} other {fields}}', }); export const FILTER_PLACEHOLDER = i18n.translate('xpack.timelines.fieldBrowser.filterPlaceholder', { @@ -90,14 +83,6 @@ export const RESET_FIELDS = i18n.translate('xpack.timelines.fieldBrowser.resetFi defaultMessage: 'Reset Fields', }); -export const VIEW_ALL_CATEGORY_FIELDS = (categoryId: string) => - i18n.translate('xpack.timelines.fieldBrowser.viewCategoryTooltip', { - defaultMessage: 'View all {categoryId} fields', - values: { - categoryId, - }, - }); - export const VIEW_COLUMN = (field: string) => i18n.translate('xpack.timelines.fieldBrowser.viewColumnCheckboxAriaLabel', { values: { field }, diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/types.ts b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/types.ts deleted file mode 100644 index bcf728795062..000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/types.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 { CreateFieldComponentType } from '../../../../../common/types'; -import type { BrowserFields } from '../../../../../common/search_strategy/index_fields'; -import type { ColumnHeaderOptions } from '../../../../../common/types/timeline/columns'; - -export type OnFieldSelected = (fieldId: string) => void; - -export interface FieldBrowserProps { - /** The timeline associated with this field browser */ - timelineId: string; - /** The timeline's current column headers */ - columnHeaders: ColumnHeaderOptions[]; - /** A map of categoryId -> metadata about the fields in that category */ - browserFields: BrowserFields; - - createFieldComponent?: CreateFieldComponentType; - /** When true, this Fields Browser is being used as an "events viewer" */ - isEventViewer?: boolean; - /** The width of the field browser */ - width?: number; -} diff --git a/x-pack/plugins/timelines/public/container/use_update_alerts.ts b/x-pack/plugins/timelines/public/container/use_update_alerts.ts index 0638425564a7..a20bebe531d1 100644 --- a/x-pack/plugins/timelines/public/container/use_update_alerts.ts +++ b/x-pack/plugins/timelines/public/container/use_update_alerts.ts @@ -45,11 +45,11 @@ export const useUpdateAlertsStatus = ( body: JSON.stringify({ status, query }), }); } else { - const { body } = await http.post<{ body: estypes.UpdateByQueryResponse }>( + const response = await http.post( RAC_ALERTS_BULK_UPDATE_URL, { body: JSON.stringify({ index, status, query }) } ); - return body; + return response; } }, }; diff --git a/x-pack/plugins/timelines/public/hooks/use_add_to_case.test.ts b/x-pack/plugins/timelines/public/hooks/use_add_to_case.test.ts deleted file mode 100644 index 5b654f40deea..000000000000 --- a/x-pack/plugins/timelines/public/hooks/use_add_to_case.test.ts +++ /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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { normalizedEventFields } from './use_add_to_case'; -import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; -import { merge } from 'lodash'; - -const defaultArgs = { - _id: 'test-id', - data: [ - { field: '@timestamp', value: ['2018-11-05T19:03:25.937Z'] }, - { field: ALERT_RULE_UUID, value: ['data-rule-id'] }, - { field: ALERT_RULE_NAME, value: ['data-rule-name'] }, - ], - ecs: { - _id: 'test-id', - _index: 'test-index', - signal: { rule: { id: ['rule-id'], name: ['rule-name'], false_positives: [] } }, - }, -}; -describe('normalizedEventFields', () => { - it('uses rule data when provided', () => { - const result = normalizedEventFields(defaultArgs); - expect(result).toEqual({ - ruleId: 'data-rule-id', - ruleName: 'data-rule-name', - }); - }); - const makeObj = (s: string, v: string[]) => { - const keys = s.split('.'); - return keys - .reverse() - .reduce((prev, current, i) => (i === 0 ? { [current]: v } : { [current]: { ...prev } }), {}); - }; - it('uses rule/ecs combo Xavier thinks is a thing but Steph has yet to see', () => { - const args = { - ...defaultArgs, - data: [], - ecs: { - _id: 'string', - ...merge( - makeObj(ALERT_RULE_UUID, ['xavier-rule-id']), - makeObj(ALERT_RULE_NAME, ['xavier-rule-name']) - ), - }, - }; - const result = normalizedEventFields(args); - expect(result).toEqual({ - ruleId: 'xavier-rule-id', - ruleName: 'xavier-rule-name', - }); - }); - it('falls back to use ecs data', () => { - const result = normalizedEventFields({ ...defaultArgs, data: [] }); - expect(result).toEqual({ - ruleId: 'rule-id', - ruleName: 'rule-name', - }); - }); - it('returns null when all the data is bad', () => { - const result = normalizedEventFields({ ...defaultArgs, data: [], ecs: { _id: 'bad' } }); - expect(result).toEqual({ - ruleId: null, - ruleName: null, - }); - }); -}); diff --git a/x-pack/plugins/timelines/public/hooks/use_add_to_case.ts b/x-pack/plugins/timelines/public/hooks/use_add_to_case.ts deleted file mode 100644 index 63a7d07831c4..000000000000 --- a/x-pack/plugins/timelines/public/hooks/use_add_to_case.ts +++ /dev/null @@ -1,200 +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 { get, isEmpty } from 'lodash/fp'; -import { useState, useCallback, useMemo, SyntheticEvent } from 'react'; -import { useDispatch } from 'react-redux'; -import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; -import { useKibana } from '../../../../../src/plugins/kibana_react/public'; -import { Case, CommentType } from '../../../cases/common'; -import { TimelinesStartServices } from '../types'; -import { TimelineItem } from '../../common/search_strategy'; -import { tGridActions } from '../store/t_grid'; -import { useDeepEqualSelector } from './use_selector'; -import { createUpdateSuccessToaster } from '../components/actions/timeline/cases/helpers'; -import { AddToCaseActionProps } from '../components/actions'; -import { CaseAttachments, CasesDeepLinkId, generateCaseViewPath } from '../../../cases/public'; - -interface UseAddToCase { - addNewCaseClick: () => void; - addExistingCaseClick: () => void; - onCaseClicked: (theCase?: Case) => void; - onCaseSuccess: (theCase: Case) => Promise; - onCaseCreated: () => Promise; - isAllCaseModalOpen: boolean; - isDisabled: boolean; - userCanCrud: boolean; - isEventSupported: boolean; - openPopover: (event: SyntheticEvent) => void; - closePopover: () => void; - isPopoverOpen: boolean; - isCreateCaseFlyoutOpen: boolean; - caseAttachments?: CaseAttachments; -} - -export const useAddToCase = ({ - event, - casePermissions, - appId, - onClose, - owner, -}: AddToCaseActionProps): UseAddToCase => { - const eventId = event?.ecs._id ?? ''; - const dispatch = useDispatch(); - // TODO: use correct value in standalone or integrated. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const timelineById = useDeepEqualSelector((state: any) => { - if (state.timeline) { - return state.timeline.timelineById[eventId]; - } else { - return state.timelineById[eventId]; - } - }); - const isAllCaseModalOpen = useMemo(() => { - if (timelineById) { - return timelineById.isAddToExistingCaseOpen; - } else { - return false; - } - }, [timelineById]); - const isCreateCaseFlyoutOpen = useMemo(() => { - if (timelineById) { - return timelineById.isCreateNewCaseOpen; - } else { - return false; - } - }, [timelineById]); - const { - application: { navigateToApp }, - notifications: { toasts }, - } = useKibana().services; - - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const openPopover = useCallback(() => setIsPopoverOpen(true), []); - const closePopover = useCallback(() => setIsPopoverOpen(false), []); - - const isEventSupported = useMemo(() => { - if (event !== undefined) { - if (event.data.some(({ field }) => field === 'kibana.alert.rule.uuid')) { - return true; - } - return !isEmpty(event.ecs.signal?.rule?.id ?? event.ecs.kibana?.alert?.rule?.uuid); - } else { - return false; - } - }, [event]); - - const userCanCrud = casePermissions?.crud ?? false; - const isDisabled = !userCanCrud || !isEventSupported; - - const onViewCaseClick = useCallback( - (id) => { - navigateToApp(appId, { - deepLinkId: CasesDeepLinkId.cases, - path: generateCaseViewPath({ detailName: id }), - }); - }, - [navigateToApp, appId] - ); - - const onCaseCreated = useCallback(async () => { - dispatch(tGridActions.setOpenAddToNewCase({ id: eventId, isOpen: false })); - }, [eventId, dispatch]); - - const onCaseSuccess = useCallback( - async (theCase: Case) => { - dispatch(tGridActions.setOpenAddToExistingCase({ id: eventId, isOpen: false })); - createUpdateSuccessToaster(toasts, theCase, onViewCaseClick); - }, - [onViewCaseClick, toasts, dispatch, eventId] - ); - const caseAttachments: CaseAttachments = useMemo(() => { - const eventIndex = event?.ecs._index ?? ''; - const { ruleId, ruleName } = normalizedEventFields(event); - const attachments = [ - { - alertId: eventId, - index: eventIndex ?? '', - rule: { - id: ruleId, - name: ruleName, - }, - owner, - type: CommentType.alert as const, - }, - ]; - return attachments; - }, [event, eventId, owner]); - - const onCaseClicked = useCallback( - (theCase?: Case) => { - /** - * No cases listed on the table. - * The user pressed the add new case table's button. - * We gonna open the create case modal. - */ - if (theCase == null) { - dispatch(tGridActions.setOpenAddToNewCase({ id: eventId, isOpen: true })); - } - dispatch(tGridActions.setOpenAddToExistingCase({ id: eventId, isOpen: false })); - }, - [dispatch, eventId] - ); - const addNewCaseClick = useCallback(() => { - closePopover(); - dispatch(tGridActions.setOpenAddToNewCase({ id: eventId, isOpen: true })); - if (onClose) { - onClose(); - } - }, [onClose, closePopover, dispatch, eventId]); - - const addExistingCaseClick = useCallback(() => { - closePopover(); - dispatch(tGridActions.setOpenAddToExistingCase({ id: eventId, isOpen: true })); - if (onClose) { - onClose(); - } - }, [onClose, closePopover, dispatch, eventId]); - return { - caseAttachments, - addNewCaseClick, - addExistingCaseClick, - onCaseClicked, - onCaseSuccess, - onCaseCreated, - isAllCaseModalOpen, - isDisabled, - userCanCrud, - isEventSupported, - openPopover, - closePopover, - isPopoverOpen, - isCreateCaseFlyoutOpen, - }; -}; - -export function normalizedEventFields(event?: TimelineItem) { - const ruleUuidData = event && event.data.find(({ field }) => field === ALERT_RULE_UUID); - const ruleNameData = event && event.data.find(({ field }) => field === ALERT_RULE_NAME); - const ruleUuidValueData = ruleUuidData && ruleUuidData.value && ruleUuidData.value[0]; - const ruleNameValueData = ruleNameData && ruleNameData.value && ruleNameData.value[0]; - - const ruleUuid = - ruleUuidValueData ?? - get(`ecs.${ALERT_RULE_UUID}[0]`, event) ?? - get(`ecs.signal.rule.id[0]`, event) ?? - null; - const ruleName = - ruleNameValueData ?? - get(`ecs.${ALERT_RULE_NAME}[0]`, event) ?? - get(`ecs.signal.rule.name[0]`, event) ?? - null; - - return { - ruleId: ruleUuid, - ruleName, - }; -} diff --git a/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx b/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx index c6e0e13c4dcb..8fc81a57e2b8 100644 --- a/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx +++ b/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx @@ -12,6 +12,7 @@ import * as i18n from '../components/t_grid/translations'; import type { AlertStatus, StatusBulkActionsProps } from '../../common/types/timeline'; import { useUpdateAlertsStatus } from '../container/use_update_alerts'; import { useAppToasts } from './use_app_toasts'; +import { STANDALONE_ID } from '../components/t_grid/standalone'; export const getUpdateAlertsQuery = (eventIds: Readonly) => { return { bool: { filter: { terms: { _id: eventIds } } } }; @@ -28,7 +29,7 @@ export const useStatusBulkActionItems = ({ onUpdateFailure, timelineId, }: StatusBulkActionsProps) => { - const { updateAlertStatus } = useUpdateAlertsStatus(timelineId != null); + const { updateAlertStatus } = useUpdateAlertsStatus(timelineId !== STANDALONE_ID); const { addSuccess, addError, addWarning } = useAppToasts(); const onAlertStatusUpdateSuccess = useCallback( diff --git a/x-pack/plugins/timelines/public/methods/index.tsx b/x-pack/plugins/timelines/public/methods/index.tsx index c8b5e28c2d21..7d9ec4bcaab2 100644 --- a/x-pack/plugins/timelines/public/methods/index.tsx +++ b/x-pack/plugins/timelines/public/methods/index.tsx @@ -7,14 +7,11 @@ import React, { lazy, Suspense } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; -import { I18nProvider } from '@kbn/i18n-react'; import type { Store } from 'redux'; -import { Provider } from 'react-redux'; import type { Storage } from '../../../../../src/plugins/kibana_utils/public'; import type { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import type { TGridProps } from '../types'; import type { LastUpdatedAtProps, LoadingPanelProps, FieldBrowserProps } from '../components'; -import type { AddToCaseActionProps } from '../components/actions/timeline/cases/add_to_case_action'; import { initialTGridState } from '../store/t_grid/reducer'; import { createStore } from '../store/t_grid'; import { TGridLoading } from '../components/t_grid/shared'; @@ -84,76 +81,3 @@ export const getFieldsBrowserLazy = (props: FieldBrowserProps, { store }: { stor ); }; - -const AddToCaseLazy = lazy(() => import('../components/actions/timeline/cases/add_to_case_action')); -export const getAddToCaseLazy = ( - props: AddToCaseActionProps, - { store, storage, setStore }: { store: Store; storage: Storage; setStore: (store: Store) => void } -) => { - return ( - }> - - - - - - - ); -}; - -const AddToCasePopover = lazy( - () => import('../components/actions/timeline/cases/add_to_case_action_button') -); -export const getAddToCasePopoverLazy = ( - props: AddToCaseActionProps, - { store, storage, setStore }: { store: Store; storage: Storage; setStore: (store: Store) => void } -) => { - initializeStore({ store, storage, setStore }); - return ( - }> - - - - - - - ); -}; - -const AddToExistingButton = lazy( - () => import('../components/actions/timeline/cases/add_to_existing_case_button') -); -export const getAddToExistingCaseButtonLazy = ( - props: AddToCaseActionProps, - { store, storage, setStore }: { store: Store; storage: Storage; setStore: (store: Store) => void } -) => { - initializeStore({ store, storage, setStore }); - return ( - }> - - - - - - - ); -}; - -const AddToNewCaseButton = lazy( - () => import('../components/actions/timeline/cases/add_to_new_case_button') -); -export const getAddToNewCaseButtonLazy = ( - props: AddToCaseActionProps, - { store, storage, setStore }: { store: Store; storage: Storage; setStore: (store: Store) => void } -) => { - initializeStore({ store, storage, setStore }); - return ( - }> - - - - - - - ); -}; diff --git a/x-pack/plugins/timelines/public/mock/global_state.ts b/x-pack/plugins/timelines/public/mock/global_state.ts index fea8aa57b88d..955a612f89c1 100644 --- a/x-pack/plugins/timelines/public/mock/global_state.ts +++ b/x-pack/plugins/timelines/public/mock/global_state.ts @@ -34,8 +34,6 @@ export const mockGlobalState: TimelineState = { 'packetbeat-*', 'winlogbeat-*', ], - isAddToExistingCaseOpen: false, - isCreateNewCaseOpen: false, isLoading: false, isSelectAllChecked: false, itemsPerPage: 5, diff --git a/x-pack/plugins/timelines/public/mock/mock_timeline_data.ts b/x-pack/plugins/timelines/public/mock/mock_timeline_data.ts index 363a67a30b97..55ec6862309a 100644 --- a/x-pack/plugins/timelines/public/mock/mock_timeline_data.ts +++ b/x-pack/plugins/timelines/public/mock/mock_timeline_data.ts @@ -1566,8 +1566,6 @@ export const mockTgridModel: TGridModel = { selectAll: false, id: 'ef579e40-jibber-jabber', indexNames: [], - isAddToExistingCaseOpen: false, - isCreateNewCaseOpen: false, isLoading: false, isSelectAllChecked: false, kqlQuery: { diff --git a/x-pack/plugins/timelines/public/mock/plugin_mock.tsx b/x-pack/plugins/timelines/public/mock/plugin_mock.tsx index 066485319f91..be2adfe84a10 100644 --- a/x-pack/plugins/timelines/public/mock/plugin_mock.tsx +++ b/x-pack/plugins/timelines/public/mock/plugin_mock.tsx @@ -24,6 +24,4 @@ export const createTGridMocks = () => ({ getUseAddToTimeline: () => useAddToTimeline, getUseAddToTimelineSensor: () => useAddToTimelineSensor, getUseDraggableKeyboardWrapper: () => useDraggableKeyboardWrapper, - getAddToExistingCaseButton: () =>
    , - getAddToNewCaseButton: () =>
    , }); diff --git a/x-pack/plugins/timelines/public/plugin.ts b/x-pack/plugins/timelines/public/plugin.ts index 0ecb063445a4..8b4bdae43dfe 100644 --- a/x-pack/plugins/timelines/public/plugin.ts +++ b/x-pack/plugins/timelines/public/plugin.ts @@ -16,10 +16,6 @@ import { getLoadingPanelLazy, getTGridLazy, getFieldsBrowserLazy, - getAddToCaseLazy, - getAddToExistingCaseButtonLazy, - getAddToNewCaseButtonLazy, - getAddToCasePopoverLazy, } from './methods'; import type { TimelinesUIStart, TGridProps, TimelinesStartPlugins } from './types'; import { tGridReducer } from './store/t_grid/reducer'; @@ -88,38 +84,6 @@ export class TimelinesPlugin implements Plugin { setTGridEmbeddedStore: (store: Store) => { this.setStore(store); }, - getAddToCaseAction: (props) => { - return getAddToCaseLazy(props, { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - store: this._store!, - storage: this._storage, - setStore: this.setStore.bind(this), - }); - }, - getAddToCasePopover: (props) => { - return getAddToCasePopoverLazy(props, { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - store: this._store!, - storage: this._storage, - setStore: this.setStore.bind(this), - }); - }, - getAddToExistingCaseButton: (props) => { - return getAddToExistingCaseButtonLazy(props, { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - store: this._store!, - storage: this._storage, - setStore: this.setStore.bind(this), - }); - }, - getAddToNewCaseButton: (props) => { - return getAddToNewCaseButtonLazy(props, { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - store: this._store!, - storage: this._storage, - setStore: this.setStore.bind(this), - }); - }, }; } diff --git a/x-pack/plugins/timelines/public/store/t_grid/actions.ts b/x-pack/plugins/timelines/public/store/t_grid/actions.ts index feab12b616c7..00e207180b13 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/actions.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/actions.ts @@ -117,11 +117,3 @@ export const setTimelineUpdatedAt = export const addProviderToTimeline = actionCreator<{ id: string; dataProvider: DataProvider }>( 'ADD_PROVIDER_TO_TIMELINE' ); - -export const setOpenAddToExistingCase = actionCreator<{ id: string; isOpen: boolean }>( - 'SET_OPEN_ADD_TO_EXISTING_CASE' -); - -export const setOpenAddToNewCase = actionCreator<{ id: string; isOpen: boolean }>( - 'SET_OPEN_ADD_TO_NEW_CASE' -); diff --git a/x-pack/plugins/timelines/public/store/t_grid/model.ts b/x-pack/plugins/timelines/public/store/t_grid/model.ts index 0640cfb845d9..82a4c3e68fd0 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/model.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/model.ts @@ -66,8 +66,6 @@ export interface TGridModel extends TGridModelSettings { /** Uniquely identifies the timeline */ id: string; indexNames: string[]; - isAddToExistingCaseOpen: boolean; - isCreateNewCaseOpen: boolean; isLoading: boolean; /** If selectAll checkbox in header is checked **/ isSelectAllChecked: boolean; diff --git a/x-pack/plugins/timelines/public/store/t_grid/reducer.ts b/x-pack/plugins/timelines/public/store/t_grid/reducer.ts index d3af1dc4e9b3..ae3f9dc5c20b 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/reducer.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/reducer.ts @@ -18,8 +18,6 @@ import { setEventsDeleted, setEventsLoading, setTGridSelectAll, - setOpenAddToExistingCase, - setOpenAddToNewCase, setSelected, setTimelineUpdatedAt, toggleDetailPanel, @@ -239,26 +237,6 @@ export const tGridReducer = reducerWithInitialState(initialTGridState) ...state, timelineById: addProviderToTimelineHelper(id, dataProvider, state.timelineById), })) - .case(setOpenAddToExistingCase, (state, { id, isOpen }) => ({ - ...state, - timelineById: { - ...state.timelineById, - [id]: { - ...state.timelineById[id], - isAddToExistingCaseOpen: isOpen, - }, - }, - })) - .case(setOpenAddToNewCase, (state, { id, isOpen }) => ({ - ...state, - timelineById: { - ...state.timelineById, - [id]: { - ...state.timelineById[id], - isCreateNewCaseOpen: isOpen, - }, - }, - })) .case(setTimelineUpdatedAt, (state, { id, updated }) => ({ ...state, timelineById: { diff --git a/x-pack/plugins/timelines/public/store/t_grid/selectors.ts b/x-pack/plugins/timelines/public/store/t_grid/selectors.ts index 9077cac2b5dd..2db463e47cc8 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/selectors.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/selectors.ts @@ -6,26 +6,11 @@ */ import { getOr } from 'lodash/fp'; import { createSelector } from 'reselect'; -import { TGridModel, State } from '.'; +import { TGridModel } from '.'; import { tGridDefaults, getTGridManageDefaults } from './defaults'; -interface TGridById { - [id: string]: TGridModel; -} - const getDefaultTgrid = (id: string) => ({ ...tGridDefaults, ...getTGridManageDefaults(id) }); -const standaloneTGridById = (state: State): TGridById => state.timelineById; - -export const activeCaseFlowId = createSelector(standaloneTGridById, (tGrid) => { - return ( - tGrid && - Object.entries(tGrid) - .map(([id, data]) => (data.isAddToExistingCaseOpen || data.isCreateNewCaseOpen ? id : null)) - .find((id) => id) - ); -}); - export const selectTGridById = (state: unknown, timelineId: string): TGridModel => { return getOr( getOr(getDefaultTgrid(timelineId), ['timelineById', timelineId], state), diff --git a/x-pack/plugins/timelines/public/types.ts b/x-pack/plugins/timelines/public/types.ts index ddecac02be70..0c9e6aa3e325 100644 --- a/x-pack/plugins/timelines/public/types.ts +++ b/x-pack/plugins/timelines/public/types.ts @@ -23,7 +23,6 @@ import type { TGridIntegratedProps } from './components/t_grid/integrated'; import type { TGridStandaloneProps } from './components/t_grid/standalone'; import type { UseAddToTimelineProps, UseAddToTimeline } from './hooks/use_add_to_timeline'; import { HoverActionsConfig } from './components/hover_actions/index'; -import type { AddToCaseActionProps } from './components/actions/timeline/cases/add_to_case_action'; import { TimelineTabs } from '../common/types'; export * from './store/t_grid'; export interface TimelinesUIStart { @@ -42,10 +41,6 @@ export interface TimelinesUIStart { props: UseDraggableKeyboardWrapperProps ) => UseDraggableKeyboardWrapper; setTGridEmbeddedStore: (store: Store) => void; - getAddToCaseAction: (props: AddToCaseActionProps) => ReactElement; - getAddToCasePopover: (props: AddToCaseActionProps) => ReactElement; - getAddToExistingCaseButton: (props: AddToCaseActionProps) => ReactElement; - getAddToNewCaseButton: (props: AddToCaseActionProps) => ReactElement; } export interface TimelinesStartPlugins { diff --git a/x-pack/plugins/timelines/server/utils/beat_schema/fields.ts b/x-pack/plugins/timelines/server/utils/beat_schema/fields.ts index aee466d58c9a..55bb4fbabad0 100644 --- a/x-pack/plugins/timelines/server/utils/beat_schema/fields.ts +++ b/x-pack/plugins/timelines/server/utils/beat_schema/fields.ts @@ -7,7 +7,6 @@ import { BeatFields } from '../../../common/search_strategy/index_fields'; -/* eslint-disable @typescript-eslint/naming-convention */ export const fieldsBeat: BeatFields = { _id: { category: 'base', diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json new file mode 100644 index 000000000000..54404ab4917f --- /dev/null +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -0,0 +1,25633 @@ +{ + "formats": { + "number": { + "currency": { + "style": "currency" + }, + "percent": { + "style": "percent" + } + }, + "date": { + "short": { + "month": "numeric", + "day": "numeric", + "year": "2-digit" + }, + "medium": { + "month": "short", + "day": "numeric", + "year": "numeric" + }, + "long": { + "month": "long", + "day": "numeric", + "year": "numeric" + }, + "full": { + "weekday": "long", + "month": "long", + "day": "numeric", + "year": "numeric" + } + }, + "time": { + "short": { + "hour": "numeric", + "minute": "numeric" + }, + "medium": { + "hour": "numeric", + "minute": "numeric", + "second": "numeric" + }, + "long": { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short" + }, + "full": { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short" + } + }, + "relative": { + "years": { + "units": "year" + }, + "months": { + "units": "month" + }, + "days": { + "units": "day" + }, + "hours": { + "units": "hour" + }, + "minutes": { + "units": "minute" + }, + "seconds": { + "units": "second" + } + } + }, + "messages": { + "xpack.lens.formula.absFunction.markdown": "\nCalcule une valeur absolue. Une valeur négative est multipliée par -1, une valeur positive reste identique.\n\nExemple : calculer la distance moyenne par rapport au niveau de la mer \"abs(average(altitude))\"\n ", + "xpack.lens.formula.addFunction.markdown": "\nAjoute jusqu'à deux nombres.\nFonctionne également avec le symbole \"+\".\n\nExemple : calculer la somme de deux champs\n\n\"sum(price) + sum(tax)\"\n\nExemple : compenser le compte par une valeur statique\n\n\"add(count(), 5)\"\n ", + "xpack.lens.formula.cbrtFunction.markdown": "\nÉtablit la racine carrée de la valeur.\n\nExemple : calculer la longueur du côté à partir du volume\n`cbrt(last_value(volume))`\n ", + "xpack.lens.formula.ceilFunction.markdown": "\nArrondit le plafond de la valeur au chiffre supérieur.\n\nExemple : arrondir le prix au dollar supérieur\n`ceil(sum(price))`\n ", + "xpack.lens.formula.clampFunction.markdown": "\nÉtablit une limite minimale et maximale pour la valeur.\n\nExemple : s'assurer de repérer les valeurs aberrantes\n```\nclamp(\n average(bytes),\n percentile(bytes, percentile=5),\n percentile(bytes, percentile=95)\n)\n```\n", + "xpack.lens.formula.cubeFunction.markdown": "\nCalcule le cube d'un nombre.\n\nExemple : calculer le volume à partir de la longueur du côté\n`cube(last_value(length))`\n ", + "xpack.lens.formula.divideFunction.markdown": "\nDivise le premier nombre par le deuxième.\nFonctionne également avec le symbole \"/\".\n\nExemple : calculer la marge bénéficiaire\n\"sum(profit) / sum(revenue)\"\n\nExemple : \"divide(sum(bytes), 2)\"\n ", + "xpack.lens.formula.expFunction.markdown": "\nÉlève *e* à la puissance n.\n\nExemple : calculer la fonction exponentielle naturelle\n\n`exp(last_value(duration))`\n ", + "xpack.lens.formula.fixFunction.markdown": "\nPour les valeurs positives, part du bas. Pour les valeurs négatives, part du haut.\n\nExemple : arrondir à zéro\n\"fix(sum(profit))\"\n ", + "xpack.lens.formula.floorFunction.markdown": "\nArrondit à la valeur entière inférieure la plus proche.\n\nExemple : arrondir un prix au chiffre inférieur\n\"floor(sum(price))\"\n ", + "xpack.lens.formula.logFunction.markdown": "\nÉtablit un logarithme avec base optionnelle. La base naturelle *e* est utilisée par défaut.\n\nExemple : calculer le nombre de bits nécessaire au stockage de valeurs\n```\nlog(sum(bytes))\nlog(sum(bytes), 2)\n```\n ", + "xpack.lens.formula.modFunction.markdown": "\nÉtablit le reste après division de la fonction par un nombre.\n\nExemple : calculer les trois derniers chiffres d'une valeur\n\"mod(sum(price), 1000)\"\n ", + "xpack.lens.formula.multiplyFunction.markdown": "\nMultiplie deux nombres.\nFonctionne également avec le symbole \"*\".\n\nExemple : calculer le prix après application du taux d'imposition courant\n`sum(bytes) * last_value(tax_rate)`\n\nExemple : calculer le prix après application du taux d'imposition constant\n\"multiply(sum(price), 1.2)\"\n ", + "xpack.lens.formula.powFunction.markdown": "\nÉlève la valeur à une puissance spécifique. Le deuxième argument est obligatoire.\n\nExemple : calculer le volume en fonction de la longueur du côté\n\"pow(last_value(length), 3)\"\n ", + "xpack.lens.formula.roundFunction.markdown": "\nArrondit à un nombre donné de décimales, 0 étant la valeur par défaut.\n\nExemples : arrondir au centième\n```\nround(sum(bytes))\nround(sum(bytes), 2)\n```\n ", + "xpack.lens.formula.sqrtFunction.markdown": "\nÉtablit la racine carrée d'une valeur positive uniquement.\n\nExemple : calculer la longueur du côté en fonction de la surface\n`sqrt(last_value(area))`\n ", + "xpack.lens.formula.squareFunction.markdown": "\nÉlève la valeur à la puissance 2.\n\nExemple : calculer l’aire en fonction de la longueur du côté\n`square(last_value(length))`\n ", + "xpack.lens.formula.subtractFunction.markdown": "\nSoustrait le premier nombre du deuxième.\nFonctionne également avec le symbole \"-\".\n\nExemple : calculer la plage d'un champ\n\"subtract(max(bytes), min(bytes))\"\n ", + "xpack.lens.formulaDocumentation.filterRatioDescription.markdown": "### Rapport de filtre :\n\nUtilisez \"kql=''\" pour filtrer un ensemble de documents et le comparer à d'autres documents du même regroupement.\nPar exemple, pour consulter l'évolution du taux d'erreur au fil du temps :\n\n```\ncount(kql='response.status_code > 400') / count()\n```\n ", + "xpack.lens.formulaDocumentation.percentOfTotalDescription.markdown": "### Pourcentage du total\n\nLes formules peuvent calculer \"overall_sum\" pour tous les regroupements,\nce qui permet de convertir chaque regroupement en un pourcentage du total :\n\n```\nsum(products.base_price) / overall_sum(sum(products.base_price))\n```\n ", + "xpack.lens.formulaDocumentation.weekOverWeekDescription.markdown": "### Semaine après semaine :\n\nUtilisez \"shift='1w'\" pour obtenir la valeur de chaque regroupement\nde la semaine précédente. Le décalage ne doit pas être utilisé avec la fonction *Valeurs les plus élevées*.\n\n```\npercentile(system.network.in.bytes, percentile=99) /\npercentile(system.network.in.bytes, percentile=99, shift='1w')\n```\n ", + "xpack.lens.indexPattern.cardinality.documentation.markdown": "\nCalcule le nombre de valeurs uniques d'un champ donné. Fonctionne pour les nombres, les chaînes, les dates et les valeurs booléennes.\n\nExemple : calculer le nombre de produits différents :\n`unique_count(product.name)`\n\nExemple : calculer le nombre de produits différents du groupe \"clothes\" :\n\"unique_count(product.name, kql='product.group=clothes')\"\n ", + "xpack.lens.indexPattern.count.documentation.markdown": "\nCalcule le nombre de documents.\n\nExemple : calculer le nombre de documents :\n\"count()\"\n\nExemple : calculer le nombre de documents correspondant à un filtre spécifique :\n\"count(kql='price > 500')\"\n ", + "xpack.lens.indexPattern.counterRate.documentation.markdown": "\nCalcule le taux d'un compteur toujours croissant. Cette fonction renvoie uniquement des résultats utiles inhérents aux champs d'indicateurs de compteur qui contiennent une mesure quelconque à croissance régulière.\nSi la valeur diminue, elle est interprétée comme une mesure de réinitialisation de compteur. Pour obtenir des résultats plus précis, \"counter_rate\" doit être calculé d’après la valeur \"max\" du champ.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\nIl utilise l'intervalle en cours utilisé dans la formule.\n\nExemple : visualiser le taux d'octets reçus au fil du temps par un serveur Memcached :\n`counter_rate(max(memcached.stats.read.bytes))`\n ", + "xpack.lens.indexPattern.cumulativeSum.documentation.markdown": "\nCalcule la somme cumulée d'un indicateur au fil du temps, en ajoutant toutes les valeurs précédentes d'une série à chaque valeur. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\n\nExemple : visualiser les octets reçus cumulés au fil du temps :\n`cumulative_sum(sum(bytes))`\n ", + "xpack.lens.indexPattern.differences.documentation.markdown": "\nCalcule la différence par rapport à la dernière valeur d'un indicateur au fil du temps. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates.\nLes données doivent être séquentielles pour les différences. Si vos données sont vides lorsque vous utilisez des différences, essayez d'augmenter l'intervalle de l'histogramme de dates.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\n\nExemple : visualiser la modification des octets reçus au fil du temps :\n`differences(sum(bytes))`\n ", + "xpack.lens.indexPattern.lastValue.documentation.markdown": "\nRenvoie la valeur d'un champ du dernier document, triée par le champ d'heure par défaut du modèle d'indexation.\n\nCette fonction permet de récupérer le dernier état d'une entité.\n\nExemple : obtenir le statut actuel du serveur A :\n`last_value(server.status, kql='server.name=\"A\"')`\n ", + "xpack.lens.indexPattern.metric.documentation.markdown": "\nRenvoie l'indicateur {metric} d'un champ. Cette fonction fonctionne uniquement pour les champs numériques.\n\nExemple : obtenir l'indicateur {metric} d'un prix :\n\"{metric}(price)\"\n\nExemple : obtenir l'indicateur {metric} d'un prix pour des commandes du Royaume-Uni :\n\"{metric}(price, kql='location:UK')\"\n ", + "xpack.lens.indexPattern.movingAverage.documentation.markdown": "\nCalcule la moyenne mobile d'un indicateur au fil du temps, en prenant la moyenne des n dernières valeurs pour calculer la valeur actuelle. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates.\nLa valeur de fenêtre par défaut est {defaultValue}.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\n\nPrend un paramètre nommé \"window\" qui spécifie le nombre de dernières valeurs à inclure dans le calcul de la moyenne de la valeur actuelle.\n\nExemple : lisser une ligne de mesures :\n`moving_average(sum(bytes), window=5)`\n ", + "xpack.lens.indexPattern.overall_average.documentation.markdown": "\nCalcule la moyenne d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, \"overall_average\" calcule la moyenne pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : écart par rapport à la moyenne :\n\"sum(bytes) - overall_average(sum(bytes))\"\n ", + "xpack.lens.indexPattern.overall_max.documentation.markdown": "\nCalcule la valeur maximale d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, \"overall_max\" calcule la valeur maximale pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : pourcentage de plage\n\"(sum(bytes) - overall_min(sum(bytes))) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))\"\n ", + "xpack.lens.indexPattern.overall_min.documentation.markdown": "\nCalcule la valeur minimale d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, \"overall_min\" calcule la valeur minimale pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : pourcentage de plage\n\"(sum(bytes) - overall_min(sum(bytes)) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))\"\n ", + "xpack.lens.indexPattern.overall_sum.documentation.markdown": "\nCalcule la somme d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, \"overall_sum\" calcule la somme pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : pourcentage de total\n\"sum(bytes) / overall_sum(sum(bytes))\"\n ", + "xpack.lens.indexPattern.percentile.documentation.markdown": "\nRenvoie le centile spécifié des valeurs d'un champ. Il s'agit de la valeur de n pour cent des valeurs présentes dans les documents.\n\nExemple : obtenir le nombre d'octets supérieurs à 95 % des valeurs :\n`percentile(bytes, percentile=95)`\n ", + "xpack.lens.app.addToLibrary": "Enregistrer dans la bibliothèque", + "xpack.lens.app.cancel": "Annuler", + "xpack.lens.app.cancelButtonAriaLabel": "Retour à la dernière application sans enregistrer les modifications", + "xpack.lens.app.docLoadingError": "Erreur lors du chargement du document enregistré", + "xpack.lens.app.downloadButtonAriaLabel": "Télécharger les données en fichier CSV", + "xpack.lens.app.downloadButtonFormulasWarning": "Votre fichier CSV contient des caractères que les applications de feuilles de calcul pourraient considérer comme des formules.", + "xpack.lens.app.downloadCSV": "Télécharger en tant que CSV", + "xpack.lens.app.save": "Enregistrer", + "xpack.lens.app.saveAndReturn": "Enregistrer et revenir", + "xpack.lens.app.saveAndReturnButtonAriaLabel": "Enregistrer la visualisation Lens en cours et revenir à l'application précédente", + "xpack.lens.app.saveAs": "Enregistrer sous", + "xpack.lens.app.saveButtonAriaLabel": "Enregistrer la visualisation Lens en cours", + "xpack.lens.app.saveModalType": "Visualisation Lens", + "xpack.lens.app.saveVisualization.successNotificationText": "\"{visTitle}\" enregistré", + "xpack.lens.app.unsavedFilename": "non enregistré", + "xpack.lens.app.unsavedWorkMessage": "Quitter Lens avec un travail non enregistré ?", + "xpack.lens.app.unsavedWorkTitle": "Modifications non enregistrées", + "xpack.lens.app.updatePanel": "Mettre à jour le panneau sur {originatingAppName}", + "xpack.lens.app404": "404 Page introuvable", + "xpack.lens.breadcrumbsByValue": "Modifier la visualisation", + "xpack.lens.breadcrumbsCreate": "Créer", + "xpack.lens.breadcrumbsTitle": "Visualiser la bibliothèque", + "xpack.lens.chartSwitch.dataLossDescription": "La sélection de ce type de visualisation entraînera une perte partielle des sélections de configuration actuellement appliquées.", + "xpack.lens.chartSwitch.dataLossLabel": "Avertissement", + "xpack.lens.chartSwitch.experimentalLabel": "Expérimental", + "xpack.lens.chartSwitch.noResults": "Résultats introuvables pour {term}.", + "xpack.lens.chartTitle.unsaved": "Visualisation non enregistrée", + "xpack.lens.chartWarnings.number": "{warningsCount} {warningsCount, plural, one {avertissement} other {avertissements}}", + "xpack.lens.configPanel.addLayerButton": "Ajouter un calque", + "xpack.lens.configPanel.color.tooltip.auto": "Lens choisit automatiquement des couleurs à votre place sauf si vous spécifiez une couleur personnalisée.", + "xpack.lens.configPanel.color.tooltip.custom": "Effacez la couleur personnalisée pour revenir au mode \"Auto\".", + "xpack.lens.configPanel.color.tooltip.disabled": "Les séries individuelles n'acceptent pas les couleurs personnalisées lorsque le calque inclut l'option \"Répartir par\".", + "xpack.lens.configPanel.selectVisualization": "Sélectionner une visualisation", + "xpack.lens.configPanel.visualizationType": "Type de visualisation", + "xpack.lens.configure.configurePanelTitle": "{groupLabel}", + "xpack.lens.configure.editConfig": "Modifier la configuration {label}", + "xpack.lens.configure.emptyConfig": "Ajouter ou glisser-déposer un champ", + "xpack.lens.configure.invalidConfigTooltip": "Configuration non valide.", + "xpack.lens.configure.invalidConfigTooltipClick": "Cliquez pour en savoir plus.", + "xpack.lens.customBucketContainer.dragToReorder": "Faire glisser pour réorganiser", + "xpack.lens.dataPanelWrapper.switchDatasource": "Basculer vers la source de données", + "xpack.lens.datatable.addLayer": "Ajouter un calque de visualisation", + "xpack.lens.datatable.breakdownColumns": "Colonnes", + "xpack.lens.datatable.breakdownColumns.description": "Divisez les colonnes d'indicateurs par champ. Il est recommandé de conserver un faible nombre de colonnes pour éviter le défilement horizontal.", + "xpack.lens.datatable.breakdownRows": "Lignes", + "xpack.lens.datatable.breakdownRows.description": "Divisez le tableau par champ. Cette opération est recommandée pour les répartitions à cardinalité élevée.", + "xpack.lens.datatable.conjunctionSign": " & ", + "xpack.lens.datatable.expressionHelpLabel": "Outil de rendu de tableaux de données", + "xpack.lens.datatable.groupLabel": "Valeur tabulaire et unique", + "xpack.lens.datatable.label": "Tableau", + "xpack.lens.datatable.metrics": "Indicateurs", + "xpack.lens.datatable.suggestionLabel": "En tant que tableau", + "xpack.lens.datatable.titleLabel": "Titre", + "xpack.lens.datatable.visualizationName": "Tableau de données", + "xpack.lens.datatable.visualizationOf": "Tableau {operations}", + "xpack.lens.datatypes.boolean": "booléen", + "xpack.lens.datatypes.date": "date", + "xpack.lens.datatypes.geoPoint": "geo_point", + "xpack.lens.datatypes.geoShape": "geo_shape", + "xpack.lens.datatypes.histogram": "histogramme", + "xpack.lens.datatypes.ipAddress": "IP", + "xpack.lens.datatypes.number": "numéro", + "xpack.lens.datatypes.record": "enregistrement", + "xpack.lens.datatypes.string": "chaîne", + "xpack.lens.deleteLayerAriaLabel": "Supprimer le calque {index}", + "xpack.lens.dimensionContainer.close": "Fermer", + "xpack.lens.dimensionContainer.closeConfiguration": "Fermer la configuration", + "xpack.lens.discover.visualizeFieldLegend": "Visualiser le champ", + "xpack.lens.dragDrop.altOption": "Alt/Option", + "xpack.lens.dragDrop.announce.cancelled": "Mouvement annulé. {label} revenu à sa position initiale", + "xpack.lens.dragDrop.announce.cancelledItem": "Mouvement annulé. {label} revenu au groupe {groupLabel} à la position {position}", + "xpack.lens.dragDrop.announce.dropped.duplicated": "{label} dupliqué dans le groupe {groupLabel} à la position {position}", + "xpack.lens.dragDrop.announce.dropped.duplicateIncompatible": "Copie de {label} convertie en {nextLabel} et ajoutée au groupe {groupLabel} à la position {position}", + "xpack.lens.dragDrop.announce.dropped.moveCompatible": "{label} déplacé dans le groupe {groupLabel} à la position {position}", + "xpack.lens.dragDrop.announce.dropped.moveIncompatible": "{label} converti en {nextLabel} et déplacé dans le groupe {groupLabel} à la position {position}", + "xpack.lens.dragDrop.announce.dropped.reordered": "{label} réorganisé dans le groupe {groupLabel} de la position {prevPosition} à la position {position}", + "xpack.lens.dragDrop.announce.dropped.replaceDuplicateIncompatible": "Copie de {label} convertie en {nextLabel} et {dropLabel} remplacé dans le groupe {groupLabel} à la position {position}", + "xpack.lens.dragDrop.announce.dropped.replaceIncompatible": "{label} converti en {nextLabel} et {dropLabel} remplacé dans le groupe {groupLabel} à la position {position}", + "xpack.lens.dragDrop.announce.dropped.swapCompatible": "{label} déplacé dans {dropGroupLabel} à la position {dropPosition} et {dropLabel} dans {groupLabel} à la position {position}", + "xpack.lens.dragDrop.announce.dropped.swapIncompatible": "{label} converti en {nextLabel} dans le groupe {groupLabel} à la position {position} et permuté avec {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition}", + "xpack.lens.dragDrop.announce.droppedDefault": "{label} ajouté dans le groupe {dropGroupLabel} à la position {position}", + "xpack.lens.dragDrop.announce.droppedNoPosition": "{label} ajouté à {dropLabel}", + "xpack.lens.dragDrop.announce.duplicate.short": " Maintenez la touche Alt ou Option enfoncée pour dupliquer.", + "xpack.lens.dragDrop.announce.duplicated.replace": "{dropLabel} remplacé par {label} dans {groupLabel} à la position {position}", + "xpack.lens.dragDrop.announce.duplicated.replaceDuplicateCompatible": "{dropLabel} remplacé par une copie de {label} dans {groupLabel} à la position {position}", + "xpack.lens.dragDrop.announce.lifted": "{label} levé", + "xpack.lens.dragDrop.announce.selectedTarget.default": "Ajoutez {label} au groupe {dropGroupLabel} à la position {position}. Appuyer sur la barre d'espace ou sur Entrée pour ajouter", + "xpack.lens.dragDrop.announce.selectedTarget.defaultNoPosition": "Ajoutez {label} à {dropLabel}. Appuyer sur la barre d'espace ou sur Entrée pour ajouter", + "xpack.lens.dragDrop.announce.selectedTarget.duplicated": "Dupliquez {label} dans le groupe {dropGroupLabel} à la position {position}. Maintenir la touche Alt ou Option enfoncée et appuyer sur la barre d'espace ou sur Entrée pour dupliquer", + "xpack.lens.dragDrop.announce.selectedTarget.duplicatedInGroup": "Dupliquez {label} dans le groupe {dropGroupLabel} à la position {position}. Appuyer sur la barre d'espace ou sur Entrée pour dupliquer", + "xpack.lens.dragDrop.announce.selectedTarget.duplicateIncompatible": "Convertissez la copie de {label} en {nextLabel} et ajoutez-la au groupe {groupLabel} à la position {position}. Maintenir la touche Alt ou Option enfoncée et appuyer sur la barre d'espace ou sur Entrée pour dupliquer", + "xpack.lens.dragDrop.announce.selectedTarget.moveCompatible": "Déplacez {label} dans le groupe {dropGroupLabel} à la position {dropPosition}. Appuyer sur la barre d'espace ou sur Entrée pour déplacer", + "xpack.lens.dragDrop.announce.selectedTarget.moveCompatibleMain": "Vous faites glisser {label} de {groupLabel} à la position {position} vers la position {dropPosition} dans le groupe {dropGroupLabel}. Appuyez sur la barre d'espace ou sur Entrée pour déplacer.{duplicateCopy}{swapCopy}", + "xpack.lens.dragDrop.announce.selectedTarget.moveIncompatible": "Convertissez {label} en {nextLabel} et déplacez-le dans le groupe {dropGroupLabel} à la position {dropPosition}. Appuyer sur la barre d'espace ou sur Entrée pour déplacer", + "xpack.lens.dragDrop.announce.selectedTarget.moveIncompatibleMain": "Vous faites glisser {label} de {groupLabel} à la position {position} vers la position {dropPosition} dans le groupe {dropGroupLabel}. Appuyez sur la barre d'espace ou sur Entrée pour convertir {label} en {nextLabel} et déplacer.{duplicateCopy}{swapCopy}", + "xpack.lens.dragDrop.announce.selectedTarget.noSelected": "Aucune cible sélectionnée. Utiliser les touches fléchées pour sélectionner une cible", + "xpack.lens.dragDrop.announce.selectedTarget.reordered": "Réorganisez {label} dans le groupe {groupLabel} de la position {prevPosition} à la position {position}. Appuyer sur la barre d'espace ou sur Entrée pour réorganiser", + "xpack.lens.dragDrop.announce.selectedTarget.reorderedBack": "{label} revenu à sa position initiale {prevPosition}", + "xpack.lens.dragDrop.announce.selectedTarget.replace": "Remplacez {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition} avec {label}. Appuyez sur la barre d'espace ou sur Entrée pour remplacer.", + "xpack.lens.dragDrop.announce.selectedTarget.replaceDuplicateCompatible": "Dupliquez {label} et remplacez {dropLabel} dans {groupLabel} à la position {position}. Maintenir la touche Alt ou Option enfoncée et appuyer sur la barre d'espace ou sur Entrée pour dupliquer et remplacer", + "xpack.lens.dragDrop.announce.selectedTarget.replaceDuplicateIncompatible": "Convertissez la copie de {label} en {nextLabel} et remplacez {dropLabel} dans le groupe {groupLabel} à la position {position}. Maintenir la touche Alt ou Option enfoncée et appuyer sur la barre d'espace ou sur Entrée pour dupliquer et remplacer", + "xpack.lens.dragDrop.announce.selectedTarget.replaceIncompatible": "Convertissez {label} en {nextLabel} et remplacez {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition}. Appuyer sur la barre d'espace ou sur Entrée pour remplacer", + "xpack.lens.dragDrop.announce.selectedTarget.replaceIncompatibleMain": "Vous faites glisser {label} à partir de {groupLabel} à la position {position} sur {dropLabel} à partir du groupe {dropGroupLabel} à la position {dropPosition}. Appuyer sur la barre d'espace ou sur Entrée pour convertir {label} en {nextLabel} et remplacer {dropLabel}.{duplicateCopy}{swapCopy}", + "xpack.lens.dragDrop.announce.selectedTarget.replaceMain": "Vous faites glisser {label} à partir de {groupLabel} à la position {position} sur {dropLabel} à partir du groupe {dropGroupLabel} à la position {dropPosition}. Appuyer sur la barre d'espace ou sur Entrée pour remplacer {dropLabel} par {label}.{duplicateCopy}{swapCopy}", + "xpack.lens.dragDrop.announce.selectedTarget.swapCompatible": "Permutez {label} dans le groupe {groupLabel} à la position {position} avec {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition}. Maintenir la touche Maj enfoncée tout en appuyant sur la barre d'espace ou sur Entrée pour permuter", + "xpack.lens.dragDrop.announce.selectedTarget.swapIncompatible": "Convertir {label} en {nextLabel} dans le groupe {groupLabel} à la position {position} et permutez avec {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition}. Maintenir la touche Maj enfoncée tout en appuyant sur la barre d'espace ou sur Entrée pour permuter", + "xpack.lens.dragDrop.announce.swap.short": " Maintenez la touche Maj enfoncée pour permuter.", + "xpack.lens.dragDrop.duplicate": "Dupliquer", + "xpack.lens.dragDrop.keyboardInstructions": "Appuyez sur la barre d'espace ou sur Entrée pour commencer à faire glisser. Lors du glissement, utilisez les touches fléchées gauche/droite pour vous déplacer entre les cibles de dépôt. Appuyez à nouveau sur la barre d'espace ou sur Entrée pour terminer.", + "xpack.lens.dragDrop.keyboardInstructionsReorder": "Appuyez sur la barre d'espace ou sur Entrée pour commencer à faire glisser. Lors du glissement, utilisez les touches fléchées haut/bas pour réorganiser les éléments dans le groupe et les touches gauche/droite pour choisir les cibles de dépôt à l'extérieur du groupe. Appuyez à nouveau sur la barre d'espace ou sur Entrée pour terminer.", + "xpack.lens.dragDrop.shift": "Déplacer", + "xpack.lens.dragDrop.swap": "Permuter", + "xpack.lens.dynamicColoring.customPalette.deleteButtonAriaLabel": "Supprimer", + "xpack.lens.editorFrame.buildExpressionError": "Une erreur inattendue s'est produite lors de la préparation du graphique", + "xpack.lens.editorFrame.colorIndicatorLabel": "Couleur de cette dimension : {hex}", + "xpack.lens.editorFrame.configurationFailureMoreErrors": " +{errors} {errors, plural, one {erreur} other {erreurs}}", + "xpack.lens.editorFrame.dataFailure": "Une erreur s'est produite lors du chargement des données.", + "xpack.lens.editorFrame.emptyWorkspace": "Déposer quelques champs ici pour commencer", + "xpack.lens.editorFrame.emptyWorkspaceHeading": "Lens est un nouvel outil permettant de créer des visualisations", + "xpack.lens.editorFrame.emptyWorkspaceSimple": "Déposer le champ ici", + "xpack.lens.editorFrame.expandRenderingErrorButton": "Afficher les détails de l'erreur", + "xpack.lens.editorFrame.expressionFailure": "Une erreur s'est produite dans l'expression", + "xpack.lens.editorFrame.expressionFailureMessage": "Erreur de requête : {type}, {reason}", + "xpack.lens.editorFrame.expressionFailureMessageWithContext": "Erreur de requête : {type}, {reason} dans {context}", + "xpack.lens.editorFrame.expressionMissingDatasource": "Impossible de trouver la source de données pour la visualisation", + "xpack.lens.editorFrame.expressionMissingVisualizationType": "Type de visualisation non trouvé.", + "xpack.lens.editorFrame.goToForums": "Formuler des requêtes et donner un retour", + "xpack.lens.editorFrame.invisibleIndicatorLabel": "Cette dimension n'est pas visible actuellement dans le graphique", + "xpack.lens.editorFrame.networkErrorMessage": "Erreur réseau, réessayez plus tard ou contactez votre administrateur.", + "xpack.lens.editorFrame.noColorIndicatorLabel": "Cette dimension n'a pas de couleur individuelle", + "xpack.lens.editorFrame.paletteColorIndicatorLabel": "Cette dimension utilise une palette", + "xpack.lens.editorFrame.previewErrorLabel": "L'aperçu du rendu a échoué", + "xpack.lens.editorFrame.suggestionPanelTitle": "Suggestions", + "xpack.lens.editorFrame.workspaceLabel": "Espace de travail", + "xpack.lens.embeddable.failure": "Impossible d'afficher la visualisation", + "xpack.lens.embeddable.fixErrors": "Effectuez des modifications dans l'éditeur Lens pour corriger l'erreur", + "xpack.lens.embeddable.moreErrors": "Effectuez des modifications dans l'éditeur Lens pour afficher plus d'erreurs", + "xpack.lens.embeddableDisplayName": "lens", + "xpack.lens.fieldFormats.longSuffix.d": "par jour", + "xpack.lens.fieldFormats.longSuffix.h": "par heure", + "xpack.lens.fieldFormats.longSuffix.m": "par minute", + "xpack.lens.fieldFormats.longSuffix.s": "par seconde", + "xpack.lens.fieldFormats.suffix.d": "/d", + "xpack.lens.fieldFormats.suffix.h": "/h", + "xpack.lens.fieldFormats.suffix.m": "/m", + "xpack.lens.fieldFormats.suffix.s": "/s", + "xpack.lens.fieldFormats.suffix.title": "Suffixe", + "xpack.lens.filterBy.removeLabel": "Supprimer le filtre", + "xpack.lens.fittingFunctionsDescription.carry": "Remplit les blancs avec la dernière valeur", + "xpack.lens.fittingFunctionsDescription.linear": "Remplit les blancs avec une ligne", + "xpack.lens.fittingFunctionsDescription.lookahead": "Remplit les blancs avec la valeur suivante", + "xpack.lens.fittingFunctionsDescription.none": "Ne remplit pas les blancs", + "xpack.lens.fittingFunctionsDescription.zero": "Remplit les blancs avec des zéros", + "xpack.lens.fittingFunctionsTitle.carry": "Dernier", + "xpack.lens.fittingFunctionsTitle.linear": "Linéaire", + "xpack.lens.fittingFunctionsTitle.lookahead": "Suivant", + "xpack.lens.fittingFunctionsTitle.none": "Masquer", + "xpack.lens.fittingFunctionsTitle.zero": "Zéro", + "xpack.lens.formula.base": "base", + "xpack.lens.formula.decimals": "décimales", + "xpack.lens.formula.disableWordWrapLabel": "Désactiver le renvoi à la ligne des mots", + "xpack.lens.formula.editorHelpInlineHideLabel": "Masquer la référence des fonctions", + "xpack.lens.formula.editorHelpInlineHideToolTip": "Masquer la référence des fonctions", + "xpack.lens.formula.editorHelpInlineShowToolTip": "Afficher la référence des fonctions", + "xpack.lens.formula.editorHelpOverlayToolTip": "Référence des fonctions", + "xpack.lens.formula.fullScreenEnterLabel": "Développer", + "xpack.lens.formula.fullScreenExitLabel": "Réduire", + "xpack.lens.formula.kqlExtraArguments": "[kql]?: string, [lucene]?: string", + "xpack.lens.formula.left": "gauche", + "xpack.lens.formula.max": "max", + "xpack.lens.formula.min": "min", + "xpack.lens.formula.number": "numéro", + "xpack.lens.formula.optionalArgument": "Facultatif. La valeur par défaut est {defaultValue}", + "xpack.lens.formula.requiredArgument": "Requis", + "xpack.lens.formula.right": "droite", + "xpack.lens.formula.shiftExtraArguments": "[shift]?: string", + "xpack.lens.formula.string": "chaîne", + "xpack.lens.formula.value": "valeur", + "xpack.lens.formulaCommonFormulaDocumentation": "Les formules les plus courantes divisent deux valeurs pour produire un pourcentage. Pour obtenir un affichage correct, définissez \"Format de valeur\" sur \"pourcent\".", + "xpack.lens.formulaDocumentation.columnCalculationSection": "Calculs de colonnes", + "xpack.lens.formulaDocumentation.columnCalculationSectionDescription": "Ces fonctions sont exécutées pour chaque ligne, mais elles sont fournies avec la colonne entière comme contexte. Elles sont également appelées fonctions de fenêtre.", + "xpack.lens.formulaDocumentation.elasticsearchSection": "Elasticsearch", + "xpack.lens.formulaDocumentation.elasticsearchSectionDescription": "Ces fonctions seront exécutées sur les documents bruts pour chaque ligne du tableau résultant, en agrégeant tous les documents correspondant aux dimensions de répartition en une seule valeur.", + "xpack.lens.formulaDocumentation.filterRatio": "Rapport de filtre", + "xpack.lens.formulaDocumentation.header": "Référence de formule", + "xpack.lens.formulaDocumentation.mathSection": "Mathématique", + "xpack.lens.formulaDocumentation.mathSectionDescription": "Ces fonctions seront exécutées pour chaque ligne du tableau résultant en utilisant des valeurs uniques de la même ligne calculées à l'aide d'autres fonctions.", + "xpack.lens.formulaDocumentation.percentOfTotal": "Pourcentage du total", + "xpack.lens.formulaDocumentation.weekOverWeek": "Semaine après semaine", + "xpack.lens.formulaDocumentationHeading": "Fonctionnement", + "xpack.lens.formulaEnableWordWrapLabel": "Activer le renvoi à la ligne des mots", + "xpack.lens.formulaErrorCount": "{count} {count, plural, one {erreur} other {erreurs}}", + "xpack.lens.formulaExampleMarkdown": "Exemples", + "xpack.lens.formulaFrequentlyUsedHeading": "Formules courantes", + "xpack.lens.formulaPlaceholderText": "Saisissez une formule en combinant des fonctions avec la fonction mathématique, telle que :", + "xpack.lens.formulaSearchPlaceholder": "Rechercher des fonctions", + "xpack.lens.formulaWarningCount": "{count} {count, plural, one {avertissement} other {avertissements}}", + "xpack.lens.functions.counterRate.args.byHelpText": "Colonne selon laquelle le calcul du taux de compteur sera divisé", + "xpack.lens.functions.counterRate.args.inputColumnIdHelpText": "Colonne pour laquelle le taux de compteur sera calculé", + "xpack.lens.functions.counterRate.args.outputColumnIdHelpText": "Colonne dans laquelle le taux de compteur résultant sera stocké", + "xpack.lens.functions.counterRate.args.outputColumnNameHelpText": "Nom de la colonne dans laquelle le taux de compteur résultant sera stocké", + "xpack.lens.functions.counterRate.help": "Calcule le taux de compteur d'une colonne dans un tableau de données", + "xpack.lens.functions.lastValue.missingSortField": "Ce modèle d'indexation ne contient aucun champ de date", + "xpack.lens.functions.mergeTables.help": "Aide pour fusionner n'importe quel nombre de tableaux Kibana en un tableau unique et l'exposer via un adaptateur d'inspecteur", + "xpack.lens.functions.renameColumns.help": "Aide pour renommer les colonnes d'un tableau de données", + "xpack.lens.functions.renameColumns.idMap.help": "Un objet encodé JSON dans lequel les clés sont les anciens ID de colonne et les valeurs sont les nouveaux ID correspondants. Tous les autres ID de colonne sont conservés.", + "xpack.lens.functions.timeScale.dateColumnMissingMessage": "L'ID de colonne de date {columnId} n'existe pas.", + "xpack.lens.functions.timeScale.timeInfoMissingMessage": "Impossible de récupérer les informations d'histogramme des dates", + "xpack.lens.geoFieldWorkspace.dropMessage": "Déposer le champ ici pour l'ouvrir dans Maps", + "xpack.lens.geoFieldWorkspace.dropZoneLabel": "zone de dépôt pour ouvrir dans Maps", + "xpack.lens.heatmap.addLayer": "Ajouter un calque de visualisation", + "xpack.lens.heatmap.cellValueLabel": "Valeur de cellule", + "xpack.lens.heatmap.groupLabel": "Carte thermique", + "xpack.lens.heatmap.heatmapLabel": "Carte thermique", + "xpack.lens.heatmap.horizontalAxisLabel": "Axe horizontal", + "xpack.lens.heatmap.verticalAxisLabel": "Axe vertical", + "xpack.lens.heatmapChart.legendVisibility.hide": "Masquer", + "xpack.lens.heatmapChart.legendVisibility.show": "Afficher", + "xpack.lens.heatmapVisualization.arrayValuesWarningMessage": "{label} contient des valeurs de tableau. Le rendu de votre visualisation peut ne pas se présenter comme attendu.", + "xpack.lens.heatmapVisualization.heatmapGroupLabel": "Carte thermique", + "xpack.lens.heatmapVisualization.heatmapLabel": "Carte thermique", + "xpack.lens.heatmapVisualization.missingXAccessorLongMessage": "La configuration de l'axe horizontal est manquante.", + "xpack.lens.heatmapVisualization.missingXAccessorShortMessage": "Axe horizontal manquant.", + "xpack.lens.indexPattern.advancedSettings": "Ajouter des options avancées", + "xpack.lens.indexPattern.allFieldsLabel": "Tous les champs", + "xpack.lens.indexPattern.allFieldsLabelHelp": "Les champs disponibles ont des données dans les 500 premiers documents correspondant à vos filtres. Pour afficher tous les filtres, développez les champs vides. Certains types de champ ne peuvent pas être visualisés dans Lens, y compris les champ de texte intégral et champs géographiques.", + "xpack.lens.indexPattern.availableFieldsLabel": "Champs disponibles", + "xpack.lens.indexPattern.avg": "Moyenne", + "xpack.lens.indexPattern.avg.description": "Agrégation d'indicateurs à valeur unique qui calcule la moyenne des valeurs numériques extraites des documents agrégés", + "xpack.lens.indexPattern.avgOf": "Moyenne de {name}", + "xpack.lens.indexPattern.bytesFormatLabel": "Octets (1024)", + "xpack.lens.indexPattern.calculations.dateHistogramErrorMessage": "{name} requiert un histogramme des dates pour fonctionner. Ajoutez un histogramme des dates ou sélectionnez une autre fonction.", + "xpack.lens.indexPattern.calculations.layerDataType": "{name} est désactivé pour ce type de calque.", + "xpack.lens.indexPattern.cardinality": "Compte unique", + "xpack.lens.indexPattern.cardinality.signature": "champ : chaîne", + "xpack.lens.indexPattern.cardinalityOf": "Compte unique de {name}", + "xpack.lens.indexPattern.chooseField": "Sélectionner un champ", + "xpack.lens.indexPattern.chooseFieldLabel": "Pour utiliser cette fonction, sélectionnez un champ.", + "xpack.lens.indexPattern.chooseSubFunction": "Choisir une sous-fonction", + "xpack.lens.indexPattern.columnFormatLabel": "Format de valeur", + "xpack.lens.indexPattern.columnLabel": "Afficher le nom", + "xpack.lens.indexPattern.count": "Compte", + "xpack.lens.indexPattern.counterRate": "Taux de compteur", + "xpack.lens.indexPattern.counterRate.signature": "indicateur : nombre", + "xpack.lens.indexPattern.CounterRateOf": "Taux de compteur de {name}", + "xpack.lens.indexPattern.countOf": "Nombre d'enregistrements", + "xpack.lens.indexPattern.cumulative_sum.signature": "indicateur : nombre", + "xpack.lens.indexPattern.cumulativeSum": "Somme cumulée", + "xpack.lens.indexPattern.cumulativeSumOf": "Somme cumulée de {name}", + "xpack.lens.indexPattern.dateHistogram": "Histogramme des dates", + "xpack.lens.indexPattern.dateHistogram.autoAdvancedExplanation": "L'intervalle suit cette logique :", + "xpack.lens.indexPattern.dateHistogram.autoBasicExplanation": "L'histogramme des dates automatique divise un champ de données en groupes par intervalle.", + "xpack.lens.indexPattern.dateHistogram.autoBoundHeader": "Intervalle cible mesuré", + "xpack.lens.indexPattern.dateHistogram.autoHelpText": "Fonctionnement", + "xpack.lens.indexPattern.dateHistogram.autoInterval": "Personnaliser l'intervalle de temps", + "xpack.lens.indexPattern.dateHistogram.autoIntervalHeader": "Intervalle utilisé", + "xpack.lens.indexPattern.dateHistogram.autoLongerExplanation": "Pour choisir l'intervalle, Lens divise la plage temporelle spécifiée par le paramètre {targetBarSetting}. Lens calcule le meilleur intervalle pour vos données. Par exemple 30m, 1h et 12. Le nombre maximal de barres est défini par la valeur {maxBarSetting}.", + "xpack.lens.indexPattern.dateHistogram.days": "jours", + "xpack.lens.indexPattern.dateHistogram.hours": "heures", + "xpack.lens.indexPattern.dateHistogram.milliseconds": "millisecondes", + "xpack.lens.indexPattern.dateHistogram.minimumInterval": "Intervalle minimal", + "xpack.lens.indexPattern.dateHistogram.minutes": "minutes", + "xpack.lens.indexPattern.dateHistogram.month": "mois", + "xpack.lens.indexPattern.dateHistogram.moreThanYear": "Plus d'un an", + "xpack.lens.indexPattern.dateHistogram.restrictedInterval": "Intervalle fixé à {intervalValue} en raison de restrictions d'agrégation.", + "xpack.lens.indexPattern.dateHistogram.seconds": "secondes", + "xpack.lens.indexPattern.dateHistogram.titleHelp": "Fonctionnement de l'histogramme des dates automatique", + "xpack.lens.indexPattern.dateHistogram.upTo": "Jusqu'à", + "xpack.lens.indexPattern.dateHistogram.week": "semaine", + "xpack.lens.indexPattern.dateHistogram.year": "an", + "xpack.lens.indexPattern.decimalPlacesLabel": "Décimales", + "xpack.lens.indexPattern.defaultFormatLabel": "Par défaut", + "xpack.lens.indexPattern.derivative": "Différences", + "xpack.lens.indexPattern.derivativeOf": "Différences de {name}", + "xpack.lens.indexPattern.differences.signature": "indicateur : nombre", + "xpack.lens.indexPattern.editFieldLabel": "Modifier le champ de modèle d'indexation", + "xpack.lens.indexPattern.emptyDimensionButton": "Dimension vide", + "xpack.lens.indexPattern.emptyFieldsLabel": "Champs vides", + "xpack.lens.indexPattern.emptyFieldsLabelHelp": "Les champs vides ne contenaient aucune valeur dans les 500 premiers documents basés sur vos filtres.", + "xpack.lens.indexPattern.existenceErrorAriaLabel": "La récupération de l'existence a échoué", + "xpack.lens.indexPattern.existenceErrorLabel": "Impossible de charger les informations de champ", + "xpack.lens.indexPattern.existenceTimeoutAriaLabel": "La récupération de l'existence a expiré", + "xpack.lens.indexPattern.existenceTimeoutLabel": "Les informations de champ ont pris trop de temps", + "xpack.lens.indexPattern.fieldDistributionLabel": "Distribution", + "xpack.lens.indexPattern.fieldItem.visualizeGeoFieldLinkText": "Visualiser dans Maps", + "xpack.lens.indexPattern.fieldItemTooltip": "Effectuez un glisser-déposer pour visualiser.", + "xpack.lens.indexPattern.fieldNoOperation": "Le champ {field} ne peut pas être utilisé sans opération", + "xpack.lens.indexPattern.fieldPanelEmptyStringValue": "Chaîne vide", + "xpack.lens.indexPattern.fieldPlaceholder": "Champ", + "xpack.lens.indexPattern.fieldStatsButtonAriaLabel": "Prévisualiser {fieldName} : {fieldType}", + "xpack.lens.indexPattern.fieldStatsButtonEmptyLabel": "Ce champ ne comporte aucune donnée mais vous pouvez toujours effectuer un glisser-déposer pour visualiser.", + "xpack.lens.indexPattern.fieldStatsButtonLabel": "Cliquez pour obtenir un aperçu du champ, ou effectuez un glisser-déposer pour visualiser.", + "xpack.lens.indexPattern.fieldStatsCountLabel": "Compte", + "xpack.lens.indexPattern.fieldStatsDisplayToggle": "Basculer soit", + "xpack.lens.indexPattern.fieldStatsLimited": "Le résumé des informations n'est pas disponible pour les champs de type de gamme.", + "xpack.lens.indexPattern.fieldStatsNoData": "Ce champ est vide car il n'existe pas dans les 500 documents échantillonnés. L'ajout de ce champ à la configuration peut générer un graphique vide.", + "xpack.lens.indexPattern.fieldTimeDistributionLabel": "Répartition du temps", + "xpack.lens.indexPattern.fieldTopValuesLabel": "Valeurs les plus élevées", + "xpack.lens.indexPattern.filterBy.clickToEdit": "Cliquer pour modifier", + "xpack.lens.indexPattern.filterBy.emptyFilterQuery": "(vide)", + "xpack.lens.indexPattern.filterBy.label": "Filtrer par", + "xpack.lens.indexPattern.filters": "Filtres", + "xpack.lens.indexPattern.filters.addaFilter": "Ajouter un filtre", + "xpack.lens.indexPattern.filters.clickToEdit": "Cliquer pour modifier", + "xpack.lens.indexPattern.filters.isInvalid": "Cette requête n'est pas valide", + "xpack.lens.indexPattern.filters.label.placeholder": "Tous les enregistrements", + "xpack.lens.indexPattern.filters.queryPlaceholderKql": "{example}", + "xpack.lens.indexPattern.filters.queryPlaceholderLucene": "{example}", + "xpack.lens.indexPattern.filters.removeFilter": "Retirer un filtre", + "xpack.lens.indexPattern.formulaExpressionNotHandled": "L'opération {operation} dans la formule ne comprend pas les paramètres suivants : {params}", + "xpack.lens.indexPattern.formulaExpressionParseError": "La formule {expression} ne peut pas être analysée", + "xpack.lens.indexPattern.formulaExpressionWrongType": "Les paramètres de l'opération {operation} dans la formule ont un type incorrect : {params}", + "xpack.lens.indexPattern.formulaFieldNotFound": "{variablesLength, plural, one {Champ} other {Champs}} {variablesList} introuvable(s)", + "xpack.lens.indexPattern.formulaFieldNotRequired": "L'opération {operation} n'accepte aucun champ comme argument", + "xpack.lens.indexPattern.formulaFieldValue": "champ", + "xpack.lens.indexPattern.formulaLabel": "Formule", + "xpack.lens.indexPattern.formulaMathMissingArgument": "L'opération {operation} dans la formule ne comprend pas les arguments {count} : {params}", + "xpack.lens.indexPattern.formulaMetricValue": "indicateur", + "xpack.lens.indexPattern.formulaNoFieldForOperation": "aucun champ", + "xpack.lens.indexPattern.formulaNoOperation": "aucune opération", + "xpack.lens.indexPattern.formulaOperationDoubleQueryError": "Utilisez uniquement kql= ou lucene=, mais pas les deux", + "xpack.lens.indexPattern.formulaOperationDuplicateParams": "Les paramètres de l'opération {operation} ont été déclarés plusieurs fois : {params}", + "xpack.lens.indexPattern.formulaOperationQueryError": "Des guillemets simples sont requis pour {language}='' à {rawQuery}", + "xpack.lens.indexPattern.formulaOperationTooManyFirstArguments": "L'opération {operation} dans la formule requiert un {type} {supported, plural, one {unique} other {pris en charge}}, trouvé : {text}", + "xpack.lens.indexPattern.formulaOperationValue": "opération", + "xpack.lens.indexPattern.formulaOperationwrongArgument": "L'opération {operation} dans la formule ne prend pas en charge les paramètres {type}, trouvé : {text}", + "xpack.lens.indexPattern.formulaOperationWrongFirstArgument": "Le premier argument pour {operation} doit être un nom {type}. Trouvé {argument}", + "xpack.lens.indexPattern.formulaParameterNotRequired": "L'opération {operation} n'accepte aucun paramètre", + "xpack.lens.indexPattern.formulaPartLabel": "Partie de {label}", + "xpack.lens.indexPattern.formulaWarning": "Formule actuellement appliquée", + "xpack.lens.indexPattern.formulaWarningText": "Pour écraser votre formule, sélectionnez une fonction rapide", + "xpack.lens.indexPattern.formulaWithTooManyArguments": "L'opération {operation} a trop d'arguments", + "xpack.lens.indexPattern.functionsLabel": "Sélectionner une fonction", + "xpack.lens.indexPattern.groupByDropdown": "Regrouper par", + "xpack.lens.indexPattern.incompleteOperation": "(incomplet)", + "xpack.lens.indexPattern.intervals": "Intervalles", + "xpack.lens.indexPattern.invalidFieldLabel": "Champ non valide. Vérifiez votre modèle d'indexation ou choisissez un autre champ.", + "xpack.lens.indexPattern.invalidInterval": "Valeur d'intervalle non valide", + "xpack.lens.indexPattern.invalidOperationLabel": "Ce champ ne fonctionne pas avec la fonction sélectionnée.", + "xpack.lens.indexPattern.invalidReferenceConfiguration": "La dimension \"{dimensionLabel}\" n'est pas configurée correctement", + "xpack.lens.indexPattern.invalidTimeShift": "Décalage non valide. Entrez un entier positif suivi par l'une des unités suivantes : s, m, h, d, w, M, y. Par exemple, 3h pour 3 heures", + "xpack.lens.indexPattern.lastValue": "Dernière valeur", + "xpack.lens.indexPattern.lastValue.disabled": "Cette fonction requiert la présence d'un champ de date dans votre index", + "xpack.lens.indexPattern.lastValue.invalidTypeSortField": "Le champ {invalidField} n'est pas un champ de date et ne peut pas être utilisé pour le tri", + "xpack.lens.indexPattern.lastValue.signature": "champ : chaîne", + "xpack.lens.indexPattern.lastValue.sortField": "Trier par le champ de date", + "xpack.lens.indexPattern.lastValue.sortFieldNotFound": "Champ {invalidField} introuvable", + "xpack.lens.indexPattern.lastValue.sortFieldPlaceholder": "Champ de tri", + "xpack.lens.indexPattern.lastValueOf": "Dernière valeur de {name}", + "xpack.lens.indexPattern.layerErrorWrapper": "Erreur de {position} pour le calque : {wrappedMessage}", + "xpack.lens.indexPattern.max": "Maximum", + "xpack.lens.indexPattern.max.description": "Agrégation d'indicateurs à valeur unique qui renvoie la valeur maximale des valeurs numériques extraites des documents agrégés.", + "xpack.lens.indexPattern.maxOf": "Maximum de {name}", + "xpack.lens.indexPattern.median": "Médiane", + "xpack.lens.indexPattern.median.description": "Agrégation d'indicateurs à valeur unique qui calcule la valeur médiane des valeurs numériques extraites des documents agrégés.", + "xpack.lens.indexPattern.medianOf": "Médiane de {name}", + "xpack.lens.indexPattern.metaFieldsLabel": "Champs méta", + "xpack.lens.indexPattern.metric.signature": "champ : chaîne", + "xpack.lens.indexPattern.min": "Minimum", + "xpack.lens.indexPattern.min.description": "Agrégation d'indicateurs à valeur unique qui renvoie la valeur minimale des valeurs numériques extraites des documents agrégés.", + "xpack.lens.indexPattern.minOf": "Minimum de {name}", + "xpack.lens.indexPattern.missingFieldLabel": "Champ manquant", + "xpack.lens.indexPattern.missingReferenceError": "\"{dimensionLabel}\" n'est pas entièrement configuré", + "xpack.lens.indexPattern.moveToWorkspace": "Ajouter {field} à l'espace de travail", + "xpack.lens.indexPattern.moveToWorkspaceDisabled": "Ce champ ne peut pas être ajouté automatiquement à l'espace de travail. Vous pouvez toujours l'utiliser directement dans le panneau de configuration.", + "xpack.lens.indexPattern.moving_average.signature": "indicateur : nombre, [window] : nombre", + "xpack.lens.indexPattern.movingAverage": "Moyenne mobile", + "xpack.lens.indexPattern.movingAverage.basicExplanation": "La moyenne mobile fait glisser une fenêtre sur les données et affiche la valeur moyenne. La moyenne mobile est prise en charge uniquement par les histogrammes des dates.", + "xpack.lens.indexPattern.movingAverage.helpText": "Fonctionnement", + "xpack.lens.indexPattern.movingAverage.limitations": "La première valeur de moyenne mobile commence au deuxième élément.", + "xpack.lens.indexPattern.movingAverage.longerExplanation": "Pour calculer la moyenne mobile, Lens utilise la moyenne de la fenêtre et applique une politique d'omission pour les blancs. Pour les valeurs manquantes, le groupe est ignoré, et le calcul est effectué sur la valeur suivante.", + "xpack.lens.indexPattern.movingAverage.tableExplanation": "Par exemple, avec les données [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], vous pouvez calculer une moyenne mobile simple avec une taille de fenêtre de 5 :", + "xpack.lens.indexPattern.movingAverage.titleHelp": "Fonctionnement de la moyenne mobile", + "xpack.lens.indexPattern.movingAverage.window": "Taille de fenêtre", + "xpack.lens.indexPattern.movingAverage.windowInitialPartial": "La fenêtre est partielle jusqu'à ce qu'elle atteigne le nombre demandé d'éléments. Par exemple, avec une taille de fenêtre de 5 :", + "xpack.lens.indexPattern.movingAverage.windowLimitations": "La fenêtre n'inclut pas la valeur actuelle.", + "xpack.lens.indexPattern.movingAverageOf": "Moyenne mobile de {name}", + "xpack.lens.indexPattern.multipleDateHistogramsError": "\"{dimensionLabel}\" n'est pas le seul histogramme des dates. Lorsque vous utilisez des décalages, veillez à n'utiliser qu'un seul histogramme des dates.", + "xpack.lens.indexPattern.numberFormatLabel": "Numéro", + "xpack.lens.indexPattern.ofDocumentsLabel": "documents", + "xpack.lens.indexPattern.operationsNotFound": "{operationLength, plural, one {Opération} other {Opérations}} {operationsList} non trouvée(s)", + "xpack.lens.indexPattern.otherDocsLabel": "Autre", + "xpack.lens.indexPattern.overall_metric": "indicateur : nombre", + "xpack.lens.indexPattern.overallAverageOf": "Moyenne générale de {name}", + "xpack.lens.indexPattern.overallMax": "Max général", + "xpack.lens.indexPattern.overallMaxOf": "Max général de {name}", + "xpack.lens.indexPattern.overallMin": "Min général", + "xpack.lens.indexPattern.overallMinOf": "Min général de {name}", + "xpack.lens.indexPattern.overallSum": "Somme générale", + "xpack.lens.indexPattern.overallSumOf": "Somme générale de {name}", + "xpack.lens.indexPattern.percentageOfLabel": "{percentage} % de", + "xpack.lens.indexPattern.percentFormatLabel": "Pour cent", + "xpack.lens.indexPattern.percentile": "Centile", + "xpack.lens.indexPattern.percentile.errorMessage": "Le centile doit être un entier compris entre 1 et 99", + "xpack.lens.indexPattern.percentile.percentileValue": "Centile", + "xpack.lens.indexPattern.percentile.signature": "champ : chaîne, [percentile] : nombre", + "xpack.lens.indexPattern.percentileOf": "{percentile, selectordinal, one {#er} two {#e} few {#e} other {#e}} centile de {name}", + "xpack.lens.indexPattern.pinnedTopValuesLabel": "Filtres de {field}", + "xpack.lens.indexPattern.quickFunctionsLabel": "Fonctions rapides", + "xpack.lens.indexPattern.range.isInvalid": "Cette plage n'est pas valide", + "xpack.lens.indexPattern.ranges.addRange": "Ajouter une plage", + "xpack.lens.indexPattern.ranges.customIntervalsToggle": "Créer des plages personnalisées", + "xpack.lens.indexPattern.ranges.customRangeLabelPlaceholder": "Étiquette personnalisée", + "xpack.lens.indexPattern.ranges.customRanges": "Plages", + "xpack.lens.indexPattern.ranges.customRangesRemoval": "Retirer les plages personnalisées", + "xpack.lens.indexPattern.ranges.decreaseButtonLabel": "Diminuer la granularité", + "xpack.lens.indexPattern.ranges.deleteRange": "Supprimer la plage", + "xpack.lens.indexPattern.ranges.granularity": "Granularité des intervalles", + "xpack.lens.indexPattern.ranges.granularityHelpText": "Fonctionnement", + "xpack.lens.indexPattern.ranges.granularityPopoverAdvancedExplanation": "Les intervalles sont incrémentés par 10, 5 ou 2. Par exemple, un intervalle peut être 100 ou 0,2 .", + "xpack.lens.indexPattern.ranges.granularityPopoverBasicExplanation": "La granularité des intervalles divise le champ en intervalles régulièrement espacés sur la base des valeurs minimales et maximales du champ.", + "xpack.lens.indexPattern.ranges.granularityPopoverExplanation": "La taille de l'intervalle est une valeur de \"gentillesse\". Lorsque la granularité du curseur change, l'intervalle reste le même lorsque l'intervalle de \"gentillesse\" est le même. La granularité minimale est 1, et la valeur maximale est {setting}. Pour modifier la granularité maximale, accédez aux Paramètres avancés.", + "xpack.lens.indexPattern.ranges.granularityPopoverTitle": "Fonctionnement de la granularité des intervalles", + "xpack.lens.indexPattern.ranges.increaseButtonLabel": "Augmenter la granularité", + "xpack.lens.indexPattern.ranges.lessThanOrEqualAppend": "≤", + "xpack.lens.indexPattern.ranges.lessThanOrEqualTooltip": "Inférieur ou égal à", + "xpack.lens.indexPattern.ranges.lessThanPrepend": "<", + "xpack.lens.indexPattern.ranges.lessThanTooltip": "Inférieur à", + "xpack.lens.indexPattern.records": "Enregistrements", + "xpack.lens.indexPattern.referenceFunctionPlaceholder": "Sous-fonction", + "xpack.lens.indexPattern.removeColumnAriaLabel": "Ajouter ou glisser-déposer un champ dans {groupLabel}", + "xpack.lens.indexPattern.removeColumnLabel": "Retirer la configuration de \"{groupLabel}\"", + "xpack.lens.indexPattern.removeFieldLabel": "Retirer le champ du modèle d'indexation", + "xpack.lens.indexPattern.sortField.invalid": "Champ non valide. Vérifiez votre modèle d'indexation ou choisissez un autre champ.", + "xpack.lens.indexpattern.suggestions.nestingChangeLabel": "{innerOperation} pour chaque {outerOperation}", + "xpack.lens.indexpattern.suggestions.overallLabel": "{operation} générale", + "xpack.lens.indexpattern.suggestions.overTimeLabel": "Sur la durée", + "xpack.lens.indexPattern.sum": "Somme", + "xpack.lens.indexPattern.sum.description": "Agrégation d'indicateurs à valeur unique qui récapitule les valeurs numériques extraites des documents agrégés.", + "xpack.lens.indexPattern.sumOf": "Somme de {name}", + "xpack.lens.indexPattern.terms": "Valeurs les plus élevées", + "xpack.lens.indexPattern.terms.advancedSettings": "Avancé", + "xpack.lens.indexPattern.terms.missingBucketDescription": "Inclure les documents sans ce champ", + "xpack.lens.indexPattern.terms.missingLabel": "(valeur manquante)", + "xpack.lens.indexPattern.terms.orderAlphabetical": "Alphabétique", + "xpack.lens.indexPattern.terms.orderAscending": "Croissant", + "xpack.lens.indexPattern.terms.orderBy": "Classer par", + "xpack.lens.indexPattern.terms.orderByHelp": "Spécifie la dimension selon laquelle les valeurs les plus élevées sont classées.", + "xpack.lens.indexPattern.terms.orderDescending": "Décroissant", + "xpack.lens.indexPattern.terms.orderDirection": "Sens de classement", + "xpack.lens.indexPattern.terms.otherBucketDescription": "Regrouper les autres valeurs sous \"Autre\"", + "xpack.lens.indexPattern.terms.otherLabel": "Autre", + "xpack.lens.indexPattern.terms.size": "Nombre de valeurs", + "xpack.lens.indexPattern.termsOf": "Valeurs les plus élevées de {name}", + "xpack.lens.indexPattern.termsWithMultipleShifts": "Dans un seul calque, il est impossible de combiner des indicateurs avec des décalages temporels différents et des valeurs dynamiques les plus élevées. Utilisez la même valeur de décalage pour tous les indicateurs, ou utilisez des filtres à la place des valeurs les plus élevées.", + "xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel": "Utiliser des filtres", + "xpack.lens.indexPattern.timeScale.enableTimeScale": "Normaliser par unité", + "xpack.lens.indexPattern.timeScale.label": "Normaliser par unité", + "xpack.lens.indexPattern.timeScale.tooltip": "Normalisez les valeurs pour qu'elles soient toujours affichées en tant que taux par unité de temps spécifiée, indépendamment de l'intervalle de dates sous-jacent.", + "xpack.lens.indexPattern.timeShift.12hours": "Il y a 12 heures (12h)", + "xpack.lens.indexPattern.timeShift.3hours": "Il y a 3 heures (3h)", + "xpack.lens.indexPattern.timeShift.3months": "Il y a 3 mois (3M)", + "xpack.lens.indexPattern.timeShift.6hours": "Il y a 6 heures (6h)", + "xpack.lens.indexPattern.timeShift.6months": "Il y a 6 mois (6M)", + "xpack.lens.indexPattern.timeShift.day": "Il y a 1 jour (1d)", + "xpack.lens.indexPattern.timeShift.help": "Entrer le nombre et l'unité du décalage temporel", + "xpack.lens.indexPattern.timeShift.hour": "Il y a 1 heure (1h)", + "xpack.lens.indexPattern.timeShift.label": "Décalage temporel", + "xpack.lens.indexPattern.timeShift.month": "Il y a 1 mois (1M)", + "xpack.lens.indexPattern.timeShift.noMultipleHelp": "Le décalage temporel doit être un multiple de l'intervalle de l'histogramme des dates. Ajustez le décalage ou l'intervalle de l'histogramme des dates", + "xpack.lens.indexPattern.timeShift.tooSmallHelp": "Le décalage temporel doit être supérieur à l'intervalle de l'histogramme des dates. Augmentez le décalage ou spécifiez un intervalle plus petit dans l'histogramme des dates", + "xpack.lens.indexPattern.timeShift.week": "Il y a 1 semaine (1w)", + "xpack.lens.indexPattern.timeShift.year": "Il y a 1 an (1y)", + "xpack.lens.indexPattern.timeShiftMultipleWarning": "{label} utilise un décalage temporel de {columnTimeShift} qui n'est pas un multiple de l'intervalle de l'histogramme des dates de {interval}. Pour éviter une non-correspondance des données, utilisez un multiple de {interval} comme décalage.", + "xpack.lens.indexPattern.timeShiftPlaceholder": "Saisissez des valeurs personnalisées (par ex. 8w)", + "xpack.lens.indexPattern.timeShiftSmallWarning": "{label} utilise un décalage temporel de {columnTimeShift} qui est inférieur à l'intervalle de l'histogramme des dates de {interval}. Pour éviter une non-correspondance des données, utilisez un multiple de {interval} comme décalage.", + "xpack.lens.indexPattern.uniqueLabel": "{label} [{num}]", + "xpack.lens.indexPattern.useAsTopLevelAgg": "Regrouper d'abord en fonction de ce champ", + "xpack.lens.indexPatterns.actionsPopoverLabel": "Paramètres du modèle d'indexation", + "xpack.lens.indexPatterns.addFieldButton": "Ajouter un champ au modèle d'indexation", + "xpack.lens.indexPatterns.clearFiltersLabel": "Effacer le nom et saisissez les filtres", + "xpack.lens.indexPatterns.fieldFiltersLabel": "Filtrer par type", + "xpack.lens.indexPatterns.fieldSearchLiveRegion": "{availableFields} {availableFields, plural, one {champ} other {champs}} disponible(s). {emptyFields} {emptyFields, plural, one {champ} other {champs}} vide(s). {metaFields} {metaFields, plural, one {champ} other {champs}} méta.", + "xpack.lens.indexPatterns.filterByNameLabel": "Rechercher les noms des champs", + "xpack.lens.indexPatterns.manageFieldButton": "Gérer les champs du modèle d'indexation", + "xpack.lens.indexPatterns.noAvailableDataLabel": "Aucun champ disponible ne contient de données.", + "xpack.lens.indexPatterns.noDataLabel": "Aucun champ.", + "xpack.lens.indexPatterns.noEmptyDataLabel": "Aucun champ vide.", + "xpack.lens.indexPatterns.noFields.extendTimeBullet": "Extension de la plage temporelle", + "xpack.lens.indexPatterns.noFields.fieldTypeFilterBullet": "Utilisation de différents filtres de champ", + "xpack.lens.indexPatterns.noFields.globalFiltersBullet": "Modification des filtres globaux", + "xpack.lens.indexPatterns.noFields.tryText": "Essayer :", + "xpack.lens.indexPatterns.noFieldsLabel": "Aucun champ n'existe dans ce modèle d'indexation.", + "xpack.lens.indexPatterns.noFilteredFieldsLabel": "Aucun champ ne correspond aux filtres sélectionnés.", + "xpack.lens.indexPatterns.noMetaDataLabel": "Aucun champ méta.", + "xpack.lens.indexPatternSuggestion.removeLayerLabel": "Afficher uniquement {indexPatternTitle}", + "xpack.lens.indexPatternSuggestion.removeLayerPositionLabel": "Afficher uniquement le calque {layerNumber}", + "xpack.lens.labelInput.label": "Étiquette", + "xpack.lens.layerPanel.layerVisualizationType": "Type de visualisation du calque", + "xpack.lens.lensSavedObjectLabel": "Visualisation Lens", + "xpack.lens.metric.addLayer": "Ajouter un calque de visualisation", + "xpack.lens.metric.groupLabel": "Valeur tabulaire et unique", + "xpack.lens.metric.label": "Indicateur", + "xpack.lens.pageTitle": "Lens", + "xpack.lens.paletteHeatmapGradient.customize": "Modifier", + "xpack.lens.paletteHeatmapGradient.customizeLong": "Modifier la palette", + "xpack.lens.paletteHeatmapGradient.label": "Couleur", + "xpack.lens.palettePicker.label": "Palette de couleurs", + "xpack.lens.paletteTableGradient.customize": "Modifier", + "xpack.lens.paletteTableGradient.label": "Couleur", + "xpack.lens.pie.addLayer": "Ajouter un calque de visualisation", + "xpack.lens.pie.arrayValues": "{label} contient des valeurs de tableau. Le rendu de votre visualisation peut ne pas se présenter comme attendu.", + "xpack.lens.pie.donutLabel": "Graphique en anneau", + "xpack.lens.pie.groupLabel": "Proportion", + "xpack.lens.pie.groupsizeLabel": "Taille par", + "xpack.lens.pie.pielabel": "Camembert", + "xpack.lens.pie.sliceGroupLabel": "Section par", + "xpack.lens.pie.suggestionLabel": "Comme {chartName}", + "xpack.lens.pie.treemapGroupLabel": "Regrouper par", + "xpack.lens.pie.treemaplabel": "Compartimentage", + "xpack.lens.pie.treemapSuggestionLabel": "Comme compartimentage", + "xpack.lens.pieChart.categoriesInLegendLabel": "Masquer les étiquettes", + "xpack.lens.pieChart.fitInsideOnlyLabel": "À l'intérieur uniquement", + "xpack.lens.pieChart.hiddenNumbersLabel": "Masquer dans le graphique", + "xpack.lens.pieChart.labelPositionLabel": "Position", + "xpack.lens.pieChart.legendVisibility.auto": "Auto", + "xpack.lens.pieChart.legendVisibility.hide": "Masquer", + "xpack.lens.pieChart.legendVisibility.show": "Afficher", + "xpack.lens.pieChart.nestedLegendLabel": "Imbriqué", + "xpack.lens.pieChart.numberLabels": "Valeurs", + "xpack.lens.pieChart.percentDecimalsLabel": "Nombre maximal de décimales pour les pourcentages", + "xpack.lens.pieChart.showCategoriesLabel": "Intérieur ou extérieur", + "xpack.lens.pieChart.showFormatterValuesLabel": "Afficher la valeur", + "xpack.lens.pieChart.showPercentValuesLabel": "Afficher le pourcentage", + "xpack.lens.pieChart.showTreemapCategoriesLabel": "Afficher les étiquettes", + "xpack.lens.pieChart.valuesLabel": "Étiquettes", + "xpack.lens.resetLayerAriaLabel": "Réinitialiser le calque {index}", + "xpack.lens.resetVisualizationAriaLabel": "Réinitialiser la visualisation", + "xpack.lens.searchTitle": "Lens : créer des visualisations", + "xpack.lens.section.configPanelLabel": "Panneau de configuration", + "xpack.lens.section.dataPanelLabel": "Panneau de données", + "xpack.lens.section.workspaceLabel": "Espace de travail de visualisation", + "xpack.lens.shared.chartValueLabelVisibilityLabel": "Étiquettes", + "xpack.lens.shared.curveLabel": "Options visuelles", + "xpack.lens.shared.legend.filterForValueButtonAriaLabel": "Filtre pour la valeur", + "xpack.lens.shared.legend.filterOptionsLegend": "{legendDataLabel}, options de filtre", + "xpack.lens.shared.legend.filterOutValueButtonAriaLabel": "Filtrer la valeur", + "xpack.lens.shared.legendAlignmentLabel": "Alignement", + "xpack.lens.shared.legendInsideAlignmentLabel": "Alignement", + "xpack.lens.shared.legendInsideColumnsLabel": "Nombre de colonnes", + "xpack.lens.shared.legendInsideLocationAlignmentLabel": "Alignement", + "xpack.lens.shared.legendInsideTooltip": "Requiert que la légende soit placée dans la visualisation", + "xpack.lens.shared.legendIsTruncated": "Requiert que le texte soit tronqué", + "xpack.lens.shared.legendLabel": "Légende", + "xpack.lens.shared.legendLocationBottomLeft": "En bas à gauche", + "xpack.lens.shared.legendLocationBottomRight": "En bas à droite", + "xpack.lens.shared.legendLocationLabel": "Emplacement", + "xpack.lens.shared.legendLocationTopLeft": "En haut à gauche", + "xpack.lens.shared.legendLocationTopRight": "En haut à droite", + "xpack.lens.shared.legendPositionBottom": "Bas", + "xpack.lens.shared.legendPositionLeft": "Gauche", + "xpack.lens.shared.legendPositionRight": "Droite", + "xpack.lens.shared.legendPositionTop": "Haut", + "xpack.lens.shared.legendVisibilityLabel": "Affichage", + "xpack.lens.shared.legendVisibleTooltip": "Requiert que la légende soit affichée", + "xpack.lens.shared.maxLinesLabel": "Nombre maximal de lignes", + "xpack.lens.shared.nestedLegendLabel": "Imbriqué", + "xpack.lens.shared.truncateLegend": "Tronquer le texte", + "xpack.lens.shared.valueInLegendLabel": "Afficher la valeur", + "xpack.lens.sugegstion.refreshSuggestionLabel": "Actualiser", + "xpack.lens.suggestion.refreshSuggestionTooltip": "Actualisez les suggestions en fonction de la visualisation sélectionnée.", + "xpack.lens.suggestions.currentVisLabel": "Visualisation en cours", + "xpack.lens.table.actionsLabel": "Afficher les actions", + "xpack.lens.table.alignment.center": "Centre", + "xpack.lens.table.alignment.label": "Alignement du texte", + "xpack.lens.table.alignment.left": "Gauche", + "xpack.lens.table.alignment.right": "Droite", + "xpack.lens.table.columnFilter.filterForValueText": "Filtre pour la colonne", + "xpack.lens.table.columnFilter.filterOutValueText": "Filtrer la colonne", + "xpack.lens.table.columnVisibilityLabel": "Masquer la colonne", + "xpack.lens.table.defaultAriaLabel": "Visualisation du tableau de données", + "xpack.lens.table.dynamicColoring.cell": "Cellule", + "xpack.lens.table.dynamicColoring.customPalette.colorStopsHelpPercentage": "Les types de valeurs en pourcentage sont relatifs à la plage complète des valeurs de données disponibles.", + "xpack.lens.table.dynamicColoring.label": "Couleur par valeur", + "xpack.lens.table.dynamicColoring.none": "Aucune", + "xpack.lens.table.dynamicColoring.rangeType.label": "Type de valeur", + "xpack.lens.table.dynamicColoring.rangeType.number": "Numéro", + "xpack.lens.table.dynamicColoring.rangeType.percent": "Pour cent", + "xpack.lens.table.dynamicColoring.text": "Texte", + "xpack.lens.table.hide.hideLabel": "Masquer", + "xpack.lens.table.palettePanelContainer.back": "Retour", + "xpack.lens.table.palettePanelTitle": "Modifier la couleur", + "xpack.lens.table.resize.reset": "Réinitialiser la largeur", + "xpack.lens.table.sort.ascLabel": "Trier dans l'ordre croissant", + "xpack.lens.table.sort.descLabel": "Trier dans l'ordre décroissant", + "xpack.lens.table.summaryRow.average": "Moyenne", + "xpack.lens.table.summaryRow.count": "Compte de valeurs", + "xpack.lens.table.summaryRow.customlabel": "Étiquette de résumé", + "xpack.lens.table.summaryRow.label": "Ligne de résumé", + "xpack.lens.table.summaryRow.maximum": "Maximum", + "xpack.lens.table.summaryRow.minimum": "Minimum", + "xpack.lens.table.summaryRow.none": "Aucune", + "xpack.lens.table.summaryRow.sum": "Somme", + "xpack.lens.table.tableCellFilter.filterForValueAriaLabel": "Filtre pour la valeur : {cellContent}", + "xpack.lens.table.tableCellFilter.filterForValueText": "Filtre pour la valeur", + "xpack.lens.table.tableCellFilter.filterOutValueAriaLabel": "Filtrer la valeur : {cellContent}", + "xpack.lens.table.tableCellFilter.filterOutValueText": "Filtrer la valeur", + "xpack.lens.timeScale.removeLabel": "Retirer la normalisation par unité de temps", + "xpack.lens.timeShift.removeLabel": "Retirer le décalage temporel", + "xpack.lens.visTypeAlias.description": "Créez des visualisations avec notre éditeur de glisser-déposer. Basculez entre les différents types de visualisation à tout moment.", + "xpack.lens.visTypeAlias.note": "Recommandé pour la plupart des utilisateurs.", + "xpack.lens.visTypeAlias.title": "Lens", + "xpack.lens.visTypeAlias.type": "Lens", + "xpack.lens.visualizeGeoFieldMessage": "Lens ne peut pas visualiser les champs {fieldType}", + "xpack.lens.xyChart.addDataLayerLabel": "Ajouter un calque de visualisation", + "xpack.lens.xyChart.addLayer": "Ajouter un calque", + "xpack.lens.xyChart.addLayerTooltip": "Utilisez plusieurs calques pour combiner les types de visualisation ou pour visualiser différents modèles d'indexation.", + "xpack.lens.xyChart.axisExtent.custom": "Personnalisé", + "xpack.lens.xyChart.axisExtent.dataBounds": "Limites de données", + "xpack.lens.xyChart.axisExtent.disabledDataBoundsMessage": "Seuls les graphiques linéaires peuvent être adaptés aux limites de données", + "xpack.lens.xyChart.axisExtent.full": "Plein", + "xpack.lens.xyChart.axisExtent.label": "Limites", + "xpack.lens.xyChart.axisOrientation.angled": "En angle", + "xpack.lens.xyChart.axisOrientation.horizontal": "Horizontal", + "xpack.lens.xyChart.axisOrientation.label": "Orientation", + "xpack.lens.xyChart.axisOrientation.vertical": "Vertical", + "xpack.lens.xyChart.axisSide.auto": "Auto", + "xpack.lens.xyChart.axisSide.bottom": "Bas", + "xpack.lens.xyChart.axisSide.label": "Côté de l'axe", + "xpack.lens.xyChart.axisSide.left": "Gauche", + "xpack.lens.xyChart.axisSide.right": "Droite", + "xpack.lens.xyChart.axisSide.top": "Haut", + "xpack.lens.xyChart.axisTitlesSettings.help": "Afficher les titres des axes X et Y", + "xpack.lens.xyChart.bottomAxisDisabledHelpText": "Ce paramètre s'applique uniquement lorsque l'axe du bas est activé.", + "xpack.lens.xyChart.bottomAxisLabel": "Axe du bas", + "xpack.lens.xyChart.boundaryError": "La limite inférieure doit être plus grande que la limite supérieure", + "xpack.lens.xyChart.curveStyleLabel": "Courbes", + "xpack.lens.xyChart.curveType.help": "Définir de quelle façon le type de courbe est rendu pour un graphique linéaire", + "xpack.lens.xyChart.emptyXLabel": "(vide)", + "xpack.lens.xyChart.extentMode.help": "Mode d'extension", + "xpack.lens.xyChart.fillOpacity.help": "Définir l'opacité du remplissage du graphique en aires", + "xpack.lens.xyChart.fillOpacityLabel": "Opacité de remplissage", + "xpack.lens.xyChart.fittingFunction.help": "Définir le mode de traitement des valeurs manquantes", + "xpack.lens.xyChart.floatingColumns.help": "Spécifie le nombre de colonnes lorsque la légende est affichée à l'intérieur du graphique.", + "xpack.lens.xyChart.Gridlines": "Quadrillage", + "xpack.lens.xyChart.gridlinesSettings.help": "Afficher le quadrillage des axes X et Y", + "xpack.lens.xyChart.help": "Graphique X/Y", + "xpack.lens.xyChart.hideEndzones.help": "Masquer les marqueurs de zone de fin pour les données partielles", + "xpack.lens.xyChart.horizontalAlignment.help": "Spécifie l'alignement horizontal de la légende lorsqu'elle est affichée à l'intérieur du graphique.", + "xpack.lens.xyChart.horizontalAxisLabel": "Axe horizontal", + "xpack.lens.xyChart.inclusiveZero": "Les limites doivent inclure zéro.", + "xpack.lens.xyChart.isInside.help": "Spécifie si une légende se trouve à l'intérieur d'un graphique", + "xpack.lens.xyChart.isVisible.help": "Spécifie si la légende est visible ou non.", + "xpack.lens.xyChart.labelsOrientation.help": "Définit la rotation des étiquettes des axes", + "xpack.lens.xyChart.leftAxisDisabledHelpText": "Ce paramètre s'applique uniquement lorsque l'axe de gauche est activé.", + "xpack.lens.xyChart.leftAxisLabel": "Axe de gauche", + "xpack.lens.xyChart.legend.help": "Configurez la légende du graphique.", + "xpack.lens.xyChart.legendLocation.inside": "Intérieur", + "xpack.lens.xyChart.legendLocation.outside": "Extérieur", + "xpack.lens.xyChart.legendVisibility.auto": "Auto", + "xpack.lens.xyChart.legendVisibility.hide": "Masquer", + "xpack.lens.xyChart.legendVisibility.show": "Afficher", + "xpack.lens.xyChart.lowerBoundLabel": "Limite inférieure", + "xpack.lens.xyChart.maxLines.help": "Spécifie le nombre de lignes par élément de légende.", + "xpack.lens.xyChart.missingValuesLabel": "Valeurs manquantes", + "xpack.lens.xyChart.missingValuesLabelHelpText": "Par défaut, Lens masque les blancs dans les données. Pour remplir le blanc, effectuez une sélection.", + "xpack.lens.xyChart.nestUnderRoot": "Ensemble de données entier", + "xpack.lens.xyChart.position.help": "Spécifie la position de la légende.", + "xpack.lens.xyChart.renderer.help": "Outil de rendu de graphique X/Y", + "xpack.lens.xyChart.rightAxisDisabledHelpText": "Ce paramètre s'applique uniquement lorsque l'axe de droite est activé.", + "xpack.lens.xyChart.rightAxisLabel": "Axe de droite", + "xpack.lens.xyChart.seriesColor.auto": "Auto", + "xpack.lens.xyChart.seriesColor.label": "Couleur de la série", + "xpack.lens.xyChart.shouldTruncate.help": "Spécifie si les éléments de légende seront tronqués ou non", + "xpack.lens.xyChart.showEnzones": "Afficher les marqueurs de données partielles", + "xpack.lens.xyChart.showSingleSeries.help": "Spécifie si une légende comportant une seule entrée doit être affichée", + "xpack.lens.xyChart.splitSeries": "Répartir par", + "xpack.lens.xyChart.tickLabels": "Étiquettes de graduation", + "xpack.lens.xyChart.tickLabelsSettings.help": "Afficher les étiquettes de graduation des axes X et Y", + "xpack.lens.xyChart.title.help": "Titre de l'axe", + "xpack.lens.xyChart.topAxisDisabledHelpText": "Ce paramètre s'applique uniquement lorsque l'axe du haut est activé.", + "xpack.lens.xyChart.topAxisLabel": "Axe du haut", + "xpack.lens.xyChart.upperBoundLabel": "Limite supérieure", + "xpack.lens.xyChart.valuesHistogramDisabledHelpText": "Ce paramètre ne peut pas être modifié dans les histogrammes.", + "xpack.lens.xyChart.valuesInLegend.help": "Afficher les valeurs dans la légende", + "xpack.lens.xyChart.valuesPercentageDisabledHelpText": "Ce paramètre ne peut pas être modifié dans les graphiques en aires à pourcentages.", + "xpack.lens.xyChart.valuesStackedDisabledHelpText": "Ce paramètre ne peut pas être modifié dans les graphiques empilés ou les graphiques à barres à pourcentages", + "xpack.lens.xyChart.verticalAlignment.help": "Spécifie l'alignement vertical de la légende lorsqu'elle est affichée à l'intérieur du graphique.", + "xpack.lens.xyChart.verticalAxisLabel": "Axe vertical", + "xpack.lens.xyChart.xAxisGridlines.help": "Spécifie si le quadrillage de l'axe X est visible ou non.", + "xpack.lens.xyChart.xAxisLabelsOrientation.help": "Spécifie l'orientation des étiquettes de l'axe X.", + "xpack.lens.xyChart.xAxisTickLabels.help": "Spécifie si les étiquettes de graduation de l'axe X sont visibles ou non.", + "xpack.lens.xyChart.xAxisTitle.help": "Spécifie si le titre de l'axe X est visible ou non.", + "xpack.lens.xyChart.xTitle.help": "Titre de l'axe X", + "xpack.lens.xyChart.yLeftAxisgridlines.help": "Spécifie si le quadrillage de l'axe Y de gauche est visible ou non.", + "xpack.lens.xyChart.yLeftAxisLabelsOrientation.help": "Spécifie l'orientation des étiquettes de l'axe Y de gauche.", + "xpack.lens.xyChart.yLeftAxisTickLabels.help": "Spécifie si les étiquettes de graduation de l'axe Y de gauche sont visibles ou non.", + "xpack.lens.xyChart.yLeftAxisTitle.help": "Spécifie si le titre de l'axe Y de gauche est visible ou non.", + "xpack.lens.xyChart.yLeftExtent.help": "Portée de l'axe Y de gauche", + "xpack.lens.xyChart.yLeftTitle.help": "Titre de l'axe Y de gauche", + "xpack.lens.xyChart.yRightAxisgridlines.help": "Spécifie si le quadrillage de l'axe Y de droite est visible ou non.", + "xpack.lens.xyChart.yRightAxisLabelsOrientation.help": "Spécifie l'orientation des étiquettes de l'axe Y de droite.", + "xpack.lens.xyChart.yRightAxisTickLabels.help": "Spécifie si les étiquettes de graduation de l'axe Y de droite sont visibles ou non.", + "xpack.lens.xyChart.yRightAxisTitle.help": "Spécifie si le titre de l'axe Y de droite est visible ou non.", + "xpack.lens.xyChart.yRightExtent.help": "Portée de l'axe Y de droite", + "xpack.lens.xyChart.yRightTitle.help": "Titre de l'axe Y de droite", + "xpack.lens.xySuggestions.asPercentageTitle": "Pourcentage", + "xpack.lens.xySuggestions.barChartTitle": "Graphique à barres", + "xpack.lens.xySuggestions.dateSuggestion": "{yTitle} sur {xTitle}", + "xpack.lens.xySuggestions.emptyAxisTitle": "(vide)", + "xpack.lens.xySuggestions.flipTitle": "Retourner", + "xpack.lens.xySuggestions.lineChartTitle": "Graphique linéaire", + "xpack.lens.xySuggestions.nonDateSuggestion": "{yTitle} de {xTitle}", + "xpack.lens.xySuggestions.stackedChartTitle": "Empilé", + "xpack.lens.xySuggestions.unstackedChartTitle": "Non empilé", + "xpack.lens.xySuggestions.yAxixConjunctionSign": " & ", + "xpack.lens.xyVisualization.areaLabel": "Zone", + "xpack.lens.xyVisualization.arrayValues": "{label} contient des valeurs de tableau. Le rendu de votre visualisation peut ne pas se présenter comme attendu.", + "xpack.lens.xyVisualization.barGroupLabel": "Barre", + "xpack.lens.xyVisualization.barHorizontalFullLabel": "Horizontal à barres", + "xpack.lens.xyVisualization.barHorizontalLabel": "H. Barres", + "xpack.lens.xyVisualization.barLabel": "Vertical à barres", + "xpack.lens.xyVisualization.dataFailureSplitLong": "{layers, plural, one {Le calque} other {Les calques}} {layersList} {layers, plural, one {requiert} other {requièrent}} un champ pour {axis}.", + "xpack.lens.xyVisualization.dataFailureSplitShort": "{axis} manquant.", + "xpack.lens.xyVisualization.dataFailureYLong": "{layers, plural, one {Le calque} other {Les calques}} {layersList} {layers, plural, one {requiert} other {requièrent}} un champ pour {axis}.", + "xpack.lens.xyVisualization.dataFailureYShort": "{axis} manquant.", + "xpack.lens.xyVisualization.dataTypeFailureXLong": "Non-correspondance des types de données pour {axis}. Impossible de mélanger les types d'intervalle date et nombre.", + "xpack.lens.xyVisualization.dataTypeFailureXOrdinalLong": "Non-correspondance de type de données pour {axis}, utilisez une autre fonction.", + "xpack.lens.xyVisualization.dataTypeFailureXShort": "Type de données incorrect pour {axis}.", + "xpack.lens.xyVisualization.dataTypeFailureYLong": "La dimension {label} fournie pour {axis} possède un type de données incorrect. Nombre attendu mais possède {dataType}", + "xpack.lens.xyVisualization.dataTypeFailureYShort": "Type de données incorrect pour {axis}.", + "xpack.lens.xyVisualization.lineGroupLabel": "Linéaire et en aires", + "xpack.lens.xyVisualization.lineLabel": "Ligne", + "xpack.lens.xyVisualization.mixedBarHorizontalLabel": "Horizontal à barres mixte", + "xpack.lens.xyVisualization.mixedLabel": "XY mixte", + "xpack.lens.xyVisualization.stackedAreaLabel": "En aires empilées", + "xpack.lens.xyVisualization.stackedBarHorizontalFullLabel": "Horizontal à barres empilées", + "xpack.lens.xyVisualization.stackedBarHorizontalLabel": "H. À barres empilées", + "xpack.lens.xyVisualization.stackedBarLabel": "Vertical à barres empilées", + "xpack.lens.xyVisualization.stackedPercentageAreaLabel": "En aires à pourcentages", + "xpack.lens.xyVisualization.stackedPercentageBarHorizontalFullLabel": "Horizontal à barres à pourcentages", + "xpack.lens.xyVisualization.stackedPercentageBarHorizontalLabel": "H. À barres à pourcentages", + "xpack.lens.xyVisualization.stackedPercentageBarLabel": "Vertical à barres à pourcentages", + "xpack.lens.xyVisualization.xyLabel": "XY", + "advancedSettings.advancedSettingsLabel": "Paramètres avancés", + "advancedSettings.badge.readOnly.text": "Lecture seule", + "advancedSettings.badge.readOnly.tooltip": "Impossible d’enregistrer les paramètres avancés", + "advancedSettings.callOutCautionDescription": "Soyez prudent, ces paramètres sont destinés aux utilisateurs très avancés uniquement. Toute modification est susceptible d’entraîner des dommages importants à Kibana. Certains de ces paramètres peuvent être non documentés, non pris en charge ou expérimentaux. Lorsqu’un champ dispose d’une valeur par défaut, le laisser vide entraîne l’application de cette valeur par défaut, ce qui peut ne pas être acceptable compte tenu d’autres directives de configuration. Toute suppression d'un paramètre personnalisé de la configuration de Kibana est définitive.", + "advancedSettings.callOutCautionTitle": "Attention : toute action est susceptible de provoquer des dommages.", + "advancedSettings.categoryNames.dashboardLabel": "Tableau de bord", + "advancedSettings.categoryNames.discoverLabel": "Discover", + "advancedSettings.categoryNames.generalLabel": "Général", + "advancedSettings.categoryNames.machineLearningLabel": "Machine Learning", + "advancedSettings.categoryNames.notificationsLabel": "Notifications", + "advancedSettings.categoryNames.observabilityLabel": "Observabilité", + "advancedSettings.categoryNames.reportingLabel": "Reporting", + "advancedSettings.categoryNames.searchLabel": "Recherche", + "advancedSettings.categoryNames.securitySolutionLabel": "Solution de sécurité", + "advancedSettings.categoryNames.timelionLabel": "Timelion", + "advancedSettings.categoryNames.visualizationsLabel": "Visualisations", + "advancedSettings.categorySearchLabel": "Catégorie", + "advancedSettings.featureCatalogueTitle": "Personnalisez votre expérience Kibana : modifiez le format de date, activez le mode sombre, et bien plus encore.", + "advancedSettings.field.changeImageLinkAriaLabel": "Modifier {ariaName}", + "advancedSettings.field.changeImageLinkText": "Modifier l'image", + "advancedSettings.field.codeEditorSyntaxErrorMessage": "Syntaxe JSON non valide", + "advancedSettings.field.customSettingAriaLabel": "Paramètre personnalisé", + "advancedSettings.field.customSettingTooltip": "Paramètre personnalisé", + "advancedSettings.field.defaultValueText": "Valeur par défaut : {value}", + "advancedSettings.field.defaultValueTypeJsonText": "Valeur par défaut : {value}", + "advancedSettings.field.deprecationClickAreaLabel": "Cliquez ici pour afficher la documentation de déclassement pour {settingName}.", + "advancedSettings.field.helpText": "Ce paramètre est défini par le serveur Kibana et ne peut pas être modifié.", + "advancedSettings.field.imageChangeErrorMessage": "Impossible d’enregistrer l'image", + "advancedSettings.field.invalidIconLabel": "Non valide", + "advancedSettings.field.offLabel": "Désactivé", + "advancedSettings.field.onLabel": "Activé", + "advancedSettings.field.resetToDefaultLinkAriaLabel": "Réinitialiser {ariaName} à la valeur par défaut", + "advancedSettings.field.resetToDefaultLinkText": "Réinitialiser à la valeur par défaut", + "advancedSettings.field.settingIsUnsaved": "Le paramètre n'est actuellement pas enregistré.", + "advancedSettings.field.unsavedIconLabel": "Non enregistré", + "advancedSettings.form.cancelButtonLabel": "Annuler les modifications", + "advancedSettings.form.clearNoSearchResultText": "(effacer la recherche)", + "advancedSettings.form.clearSearchResultText": "(effacer la recherche)", + "advancedSettings.form.countOfSettingsChanged": "{unsavedCount} {unsavedCount, plural, one {paramètre non enregistré} other {paramètres non enregistrés} }{hiddenCount, plural, =0 {masqué} other {, # masqués} }.", + "advancedSettings.form.noSearchResultText": "Aucun paramètre trouvé pour {queryText}. {clearSearch}", + "advancedSettings.form.requiresPageReloadToastButtonLabel": "Actualiser la page", + "advancedSettings.form.requiresPageReloadToastDescription": "Un ou plusieurs paramètres nécessitent d’actualiser la page pour pouvoir prendre effet.", + "advancedSettings.form.saveButtonLabel": "Enregistrer les modifications", + "advancedSettings.form.saveButtonTooltipWithInvalidChanges": "Corrigez les paramètres non valides avant d'enregistrer.", + "advancedSettings.form.saveErrorMessage": "Enregistrement impossible", + "advancedSettings.form.searchResultText": "Les termes de la recherche masquent {settingsCount} paramètres {clearSearch}", + "advancedSettings.pageTitle": "Paramètres", + "advancedSettings.searchBar.unableToParseQueryErrorMessage": "Impossible d'analyser la requête", + "advancedSettings.searchBarAriaLabel": "Rechercher dans les paramètres avancés", + "advancedSettings.voiceAnnouncement.ariaLabel": "Informations de résultat des paramètres avancés", + "advancedSettings.voiceAnnouncement.noSearchResultScreenReaderMessage": "Il existe {optionLenght, plural, one {# option} other {# options}} dans {sectionLenght, plural, one {# section} other {# sections}}.", + "advancedSettings.voiceAnnouncement.searchResultScreenReaderMessage": "Vous avez recherché {query}. Il existe {optionLenght, plural, one {# option} other {# options}} dans {sectionLenght, plural, one {# section} other {# sections}}.", + "alerts.documentationTitle": "Afficher la documentation", + "alerts.noPermissionsMessage": "Pour consulter les alertes, vous devez disposer de privilèges pour la fonctionnalité Alertes dans l'espace Kibana. Pour en savoir plus, contactez votre administrateur Kibana.", + "alerts.noPermissionsTitle": "Privilèges de fonctionnalité Kibana requis", + "autocomplete.fieldRequiredError": "Ce champ ne peut pas être vide.", + "autocomplete.invalidDateError": "Date non valide", + "autocomplete.invalidNumberError": "Nombre non valide", + "autocomplete.loadingDescription": "Chargement...", + "autocomplete.selectField": "Veuillez d'abord sélectionner un champ...", + "bfetch.disableBfetchCompression": "Désactiver la compression par lots", + "bfetch.disableBfetchCompressionDesc": "Vous pouvez désactiver la compression par lots. Cela permet de déboguer des requêtes individuelles, mais augmente la taille des réponses.", + "charts.advancedSettings.visualization.colorMappingText": "Mappe des valeurs à des couleurs spécifiques dans les graphiques avec la palette Compatibilité.", + "charts.advancedSettings.visualization.colorMappingTextDeprecation": "Ce paramètre est déclassé et ne sera plus pris en charge à partir de la version 8.0.", + "charts.advancedSettings.visualization.colorMappingTitle": "Mapping des couleurs", + "charts.colormaps.bluesText": "Bleus", + "charts.colormaps.greensText": "Verts", + "charts.colormaps.greenToRedText": "Vert à rouge", + "charts.colormaps.greysText": "Gris", + "charts.colormaps.redsText": "Rouges", + "charts.colormaps.yellowToRedText": "Jaune à rouge", + "charts.colorPicker.clearColor": "Réinitialiser la couleur", + "charts.colorPicker.setColor.screenReaderDescription": "Définir la couleur pour la valeur {legendDataLabel}", + "charts.countText": "Décompte", + "charts.functions.palette.args.colorHelpText": "Les couleurs de la palette. Accepte un nom de couleur {html}, {hex}, {hsl}, {hsla}, {rgb} ou {rgba}.", + "charts.functions.palette.args.gradientHelpText": "Concevoir une palette de dégradés lorsque c'est possible ?", + "charts.functions.palette.args.reverseHelpText": "Inverser la palette ?", + "charts.functions.palette.args.stopHelpText": "La couleur à laquelle s’arrête la palette. Si utilisé, doit être associé à chaque couleur.", + "charts.functions.paletteHelpText": "Crée une palette de couleurs.", + "charts.functions.systemPalette.args.nameHelpText": "Nom de la palette dans la liste des palettes", + "charts.functions.systemPaletteHelpText": "Crée une palette de couleurs dynamique.", + "charts.legend.toggleLegendButtonAriaLabel": "Afficher/Masquer la légende", + "charts.legend.toggleLegendButtonTitle": "Afficher/Masquer la légende", + "charts.palettes.complimentaryLabel": "Gratuite", + "charts.palettes.coolLabel": "Froide", + "charts.palettes.customLabel": "Personnalisée", + "charts.palettes.defaultPaletteLabel": "Par défaut", + "charts.palettes.grayLabel": "Gris", + "charts.palettes.kibanaPaletteLabel": "Compatibilité", + "charts.palettes.negativeLabel": "Négative", + "charts.palettes.positiveLabel": "Positive", + "charts.palettes.statusLabel": "Statut", + "charts.palettes.temperatureLabel": "Température", + "charts.palettes.warmLabel": "Chaude", + "charts.partialData.bucketTooltipText": "La plage temporelle sélectionnée n'inclut pas ce compartiment en entier. Il se peut qu'elle contienne des données partielles.", + "console.autocomplete.addMethodMetaText": "méthode", + "console.consoleDisplayName": "Console", + "console.consoleMenu.copyAsCurlFailedMessage": "Impossible de copier la requête en tant que cURL", + "console.consoleMenu.copyAsCurlMessage": "Requête copiée en tant que cURL", + "console.devToolsDescription": "Plutôt que l’interface cURL, utilisez une interface JSON pour exploiter vos données dans la console.", + "console.devToolsTitle": "Interagir avec l'API Elasticsearch", + "console.exampleOutputTextarea": "Outils de développement de la console - Exemple d’éditeur", + "console.helpPage.keyboardCommands.autoIndentDescription": "Appliquer un retrait automatique à la requête en cours", + "console.helpPage.keyboardCommands.closeAutoCompleteMenuDescription": "Fermer le menu de saisie semi-automatique", + "console.helpPage.keyboardCommands.collapseAllScopesDescription": "Réduire tout sauf l’élément actif. Ajouter un décalage pour développer.", + "console.helpPage.keyboardCommands.collapseExpandCurrentScopeDescription": "Réduire/développer l’élément actif", + "console.helpPage.keyboardCommands.jumpToPreviousNextRequestDescription": "Aller au début ou à la fin de la requête précédente/suivante", + "console.helpPage.keyboardCommands.openAutoCompleteDescription": "Ouvrir la saisie semi-automatique (même sans saisie)", + "console.helpPage.keyboardCommands.openDocumentationDescription": "Ouvrir la documentation pour la requête en cours", + "console.helpPage.keyboardCommands.selectCurrentlySelectedInAutoCompleteMenuDescription": "Sélectionner le terme en surbrillance ou le premier terme du menu de saisie semi-automatique", + "console.helpPage.keyboardCommands.submitRequestDescription": "Envoyer la requête", + "console.helpPage.keyboardCommands.switchFocusToAutoCompleteMenuDescription": "Permet d’accéder au menu de saisie semi-automatique. Utilisez les flèches pour sélectionner un terme.", + "console.helpPage.keyboardCommandsTitle": "Commandes du clavier", + "console.helpPage.pageTitle": "Aide", + "console.helpPage.requestFormatDescription": "Vous pouvez saisir une ou plusieurs requêtes dans l'éditeur blanc. La console prend en charge les requêtes dans un format compact :", + "console.helpPage.requestFormatTitle": "Format de la requête", + "console.historyPage.applyHistoryButtonLabel": "Appliquer", + "console.historyPage.clearHistoryButtonLabel": "Effacer", + "console.historyPage.closehistoryButtonLabel": "Fermer", + "console.historyPage.itemOfRequestListAriaLabel": "Requête : {historyItem}", + "console.historyPage.noHistoryTextMessage": "Aucun historique disponible", + "console.historyPage.pageTitle": "Historique", + "console.historyPage.requestListAriaLabel": "Historique des requêtes envoyées", + "console.inputTextarea": "Outils de développement de la console", + "console.loadingError.buttonLabel": "Recharger la console", + "console.loadingError.message": "Essayez de recharger pour obtenir les données les plus récentes.", + "console.loadingError.title": "Impossible de charger la console", + "console.notification.error.couldNotSaveRequestTitle": "Impossible d'enregistrer la requête dans l'historique de la console.", + "console.notification.error.historyQuotaReachedMessage": "L'historique des requêtes est arrivé à saturation. Effacez l'historique de la console pour pouvoir enregistrer de nouvelles requêtes.", + "console.notification.error.noRequestSelectedTitle": "Aucune requête sélectionnée. Sélectionnez une requête en positionnant le curseur dessus.", + "console.notification.error.unknownErrorTitle": "Erreur de requête inconnue", + "console.outputTextarea": "Outils de développement de la console - Sortie", + "console.pageHeading": "Console", + "console.requestInProgressBadgeText": "Requête en cours", + "console.requestOptions.autoIndentButtonLabel": "Retrait automatique", + "console.requestOptions.copyAsUrlButtonLabel": "Copier en tant que cURL", + "console.requestOptions.openDocumentationButtonLabel": "Ouvrir la documentation", + "console.requestOptionsButtonAriaLabel": "Options de requête", + "console.requestTimeElapasedBadgeTooltipContent": "Temps écoulé", + "console.sendRequestButtonTooltip": "Cliquer pour envoyer la requête", + "console.settingsPage.autocompleteLabel": "Saisie semi-automatique", + "console.settingsPage.cancelButtonLabel": "Annuler", + "console.settingsPage.fieldsLabelText": "Champs", + "console.settingsPage.fontSizeLabel": "Taille de la police", + "console.settingsPage.indicesAndAliasesLabelText": "Index et alias", + "console.settingsPage.jsonSyntaxLabel": "Syntaxe JSON", + "console.settingsPage.pageTitle": "Paramètres de la console", + "console.settingsPage.refreshButtonLabel": "Actualiser les suggestions de saisie semi-automatique", + "console.settingsPage.refreshingDataDescription": "La console actualise les suggestions de saisie semi-automatique en interrogeant Elasticsearch. L’actualisation automatique peut être un problème en cas de cluster volumineux ou de réseau limité.", + "console.settingsPage.refreshingDataLabel": "Actualisation des suggestions de saisie semi-automatique", + "console.settingsPage.saveButtonLabel": "Enregistrer", + "console.settingsPage.templatesLabelText": "Modèles", + "console.settingsPage.tripleQuotesMessage": "Utiliser des guillemets triples dans le volet de sortie", + "console.settingsPage.wrapLongLinesLabelText": "Renvoyer automatiquement à la ligne", + "console.topNav.helpTabDescription": "Aide", + "console.topNav.helpTabLabel": "Aide", + "console.topNav.historyTabDescription": "Historique", + "console.topNav.historyTabLabel": "Historique", + "console.topNav.settingsTabDescription": "Paramètres", + "console.topNav.settingsTabLabel": "Paramètres", + "console.welcomePage.closeButtonLabel": "Rejeter", + "console.welcomePage.pageTitle": "Bienvenue dans la console", + "console.welcomePage.quickIntroDescription": "L'interface utilisateur de la console est divisée en deux volets : un volet éditeur (à gauche) et un volet de réponse (à droite). L'éditeur permet de saisir des requêtes et de les envoyer à Elasticsearch, tandis que le volet de réponse affiche les résultats.", + "console.welcomePage.quickIntroTitle": "Introduction rapide à l'interface utilisateur", + "console.welcomePage.quickTips.cUrlFormatForRequestsDescription": "Vous pouvez coller des requêtes au format cURL ; elles seront automatiquement traduites dans la syntaxe de la console.", + "console.welcomePage.quickTips.keyboardShortcutsDescription": "N’hésitez pas à jeter un œil aux raccourcis clavier sous le bouton Aide. Vous pourriez y trouver des choses utiles.", + "console.welcomePage.quickTips.resizeEditorDescription": "Vous pouvez redimensionner les volets de l'éditeur et de réponse en faisant glisser le séparateur situé entre les deux.", + "console.welcomePage.quickTips.submitRequestDescription": "Utilisez l’icône de triangle vert pour envoyer vos requêtes à ES.", + "console.welcomePage.quickTips.useWrenchMenuDescription": "Cliquez sur l’icône en forme de clé pour découvrir d'autres éléments utiles.", + "console.welcomePage.quickTipsTitle": "Quelques brèves astuces, pendant que j'ai toute votre attention :", + "console.welcomePage.supportedRequestFormatDescription": "Lors de la saisie d'une requête, la console fera des suggestions que vous pourrez accepter en appuyant sur Entrée/Tab. Ces suggestions sont faites en fonction de la structure de la requête, des index et des types.", + "console.welcomePage.supportedRequestFormatTitle": "La console prend en charge les requêtes dans un format compact, tel que le format cURL :", + "core.application.appContainer.loadingAriaLabel": "Chargement de l'application", + "core.application.appNotFound.pageDescription": "Aucune application détectée pour cette URL. Revenez en arrière ou sélectionnez une application dans le menu.", + "core.application.appNotFound.title": "Application introuvable", + "core.application.appRenderError.defaultTitle": "Erreur d'application", + "core.chrome.browserDeprecationLink": "la matrice de prise en charge sur notre site web", + "core.chrome.browserDeprecationWarning": "La prise en charge d'Internet Explorer sera abandonnée dans les futures versions de ce logiciel. Veuillez consulter le site {link}.", + "core.chrome.legacyBrowserWarning": "Votre navigateur ne satisfait pas aux exigences de sécurité de Kibana.", + "core.euiAccordion.isLoading": "Chargement", + "core.euiBasicTable.selectAllRows": "Sélectionner toutes les lignes", + "core.euiBasicTable.selectThisRow": "Sélectionner cette ligne", + "core.euiBasicTable.tableAutoCaptionWithoutPagination": "Ce tableau contient {itemCount} lignes.", + "core.euiBasicTable.tableAutoCaptionWithPagination": "Ce tableau contient {itemCount} lignes sur {totalItemCount} lignes au total ; page {page} sur {pageCount}.", + "core.euiBasicTable.tableCaptionWithPagination": "{tableCaption} ; page {page} sur {pageCount}.", + "core.euiBasicTable.tablePagination": "Pagination pour le tableau précédent : {tableCaption}", + "core.euiBasicTable.tableSimpleAutoCaptionWithPagination": "Ce tableau contient {itemCount} lignes ; page {page} sur {pageCount}.", + "core.euiBottomBar.customScreenReaderAnnouncement": "Il y a un nouveau repère de région nommé {landmarkHeading} avec des commandes de niveau de page à la fin du document.", + "core.euiBottomBar.screenReaderAnnouncement": "Il y a un nouveau repère de région avec des commandes de niveau de page à la fin du document.", + "core.euiBottomBar.screenReaderHeading": "Commandes de niveau de page", + "core.euiBreadcrumbs.collapsedBadge.ariaLabel": "Voir le fil d’Ariane réduit", + "core.euiBreadcrumbs.nav.ariaLabel": "Fil d’Ariane", + "core.euiCardSelect.select": "Sélectionner", + "core.euiCardSelect.selected": "Sélectionné", + "core.euiCardSelect.unavailable": "Indisponible", + "core.euiCodeBlock.copyButton": "Copier", + "core.euiCodeBlock.fullscreenCollapse": "Réduire", + "core.euiCodeBlock.fullscreenExpand": "Développer", + "core.euiCollapsedItemActions.allActions": "Toutes les actions", + "core.euiColorPicker.alphaLabel": "Valeur (opacité) du canal Alpha", + "core.euiColorPicker.closeLabel": "Appuyez sur la flèche du bas pour ouvrir la fenêtre contextuelle des options de couleur.", + "core.euiColorPicker.colorErrorMessage": "Valeur de couleur non valide", + "core.euiColorPicker.colorLabel": "Valeur de couleur", + "core.euiColorPicker.openLabel": "Appuyez sur Échap pour fermer la fenêtre contextuelle.", + "core.euiColorPicker.popoverLabel": "Boîte de dialogue de sélection de couleur", + "core.euiColorPicker.transparent": "Transparent", + "core.euiColorPickerSwatch.ariaLabel": "Sélection de la couleur {color}", + "core.euiColorStops.screenReaderAnnouncement": "{label} : {readOnly} {disabled} Sélecteur d'arrêt de couleur. Chaque arrêt consiste en un nombre et en une valeur de couleur correspondante. Utilisez les flèches haut et bas pour sélectionner les arrêts. Appuyez sur Entrée pour créer un nouvel arrêt.", + "core.euiColorStopThumb.buttonAriaLabel": "Appuyez sur Entrée pour modifier cet arrêt. Appuyez sur Échap pour revenir au groupe.", + "core.euiColorStopThumb.buttonTitle": "Cliquez pour modifier, faites glisser pour repositionner.", + "core.euiColorStopThumb.removeLabel": "Supprimer cet arrêt", + "core.euiColorStopThumb.screenReaderAnnouncement": "La fenêtre contextuelle qui vient de s’ouvrir contient un formulaire de modification d'arrêt de couleur. Appuyez sur Tab pour parcourir les commandes du formulaire ou sur Échap pour fermer la fenêtre.", + "core.euiColorStopThumb.stopErrorMessage": "Valeur hors limites", + "core.euiColorStopThumb.stopLabel": "Valeur d'arrêt", + "core.euiColumnActions.hideColumn": "Masquer la colonne", + "core.euiColumnActions.moveLeft": "Déplacer vers la gauche", + "core.euiColumnActions.moveRight": "Déplacer vers la droite", + "core.euiColumnActions.sort": "Trier {schemaLabel}", + "core.euiColumnSelector.button": "Colonnes", + "core.euiColumnSelector.buttonActivePlural": "{numberOfHiddenFields} colonnes masquées", + "core.euiColumnSelector.buttonActiveSingular": "{numberOfHiddenFields} colonne masquée", + "core.euiColumnSelector.hideAll": "Tout masquer", + "core.euiColumnSelector.search": "Recherche", + "core.euiColumnSelector.searchcolumns": "Rechercher dans les colonnes", + "core.euiColumnSelector.selectAll": "Afficher tout", + "core.euiColumnSorting.button": "Trier les champs", + "core.euiColumnSorting.clearAll": "Annuler le tri", + "core.euiColumnSorting.emptySorting": "Aucun champ n'est trié actuellement.", + "core.euiColumnSorting.pickFields": "Sélectionner les champs de tri", + "core.euiColumnSorting.sortFieldAriaLabel": "Trier par :", + "core.euiColumnSortingDraggable.defaultSortAsc": "A-Z", + "core.euiColumnSortingDraggable.defaultSortDesc": "Z-A", + "core.euiComboBoxOptionsList.allOptionsSelected": "Vous avez sélectionné toutes les options disponibles.", + "core.euiComboBoxOptionsList.alreadyAdded": "{label} a déjà été ajouté.", + "core.euiComboBoxOptionsList.createCustomOption": "Ajouter {searchValue} en tant qu'option personnalisée", + "core.euiComboBoxOptionsList.delimiterMessage": "Ajouter chaque élément en séparant par {delimiter}", + "core.euiComboBoxOptionsList.loadingOptions": "Options de chargement", + "core.euiComboBoxOptionsList.noAvailableOptions": "Aucune option n’est disponible.", + "core.euiComboBoxOptionsList.noMatchingOptions": "{searchValue} ne correspond à aucune option.", + "core.euiComboBoxPill.removeSelection": "Supprimer {children} de la sélection de ce groupe", + "core.euiCommonlyUsedTimeRanges.legend": "Couramment utilisées", + "core.euiControlBar.customScreenReaderAnnouncement": "Il y a un nouveau repère de région nommé {landmarkHeading} avec des commandes de niveau de page à la fin du document.", + "core.euiControlBar.screenReaderAnnouncement": "Il y a un nouveau repère de région avec des commandes de niveau de page à la fin du document.", + "core.euiControlBar.screenReaderHeading": "Commandes de niveau de page", + "core.euiDataGrid.ariaLabel": "{label} ; page {page} sur {pageCount}.", + "core.euiDataGrid.ariaLabelledBy": "Page {page} sur {pageCount}.", + "core.euiDataGrid.screenReaderNotice": "Cette cellule contient du contenu interactif.", + "core.euiDataGridHeaderCell.headerActions": "Actions d'en-tête", + "core.euiDataGridSchema.booleanSortTextAsc": "Faux-Vrai", + "core.euiDataGridSchema.booleanSortTextDesc": "Vrai-Faux", + "core.euiDataGridSchema.currencySortTextAsc": "Bas-Haut", + "core.euiDataGridSchema.currencySortTextDesc": "Haut-Bas", + "core.euiDataGridSchema.dateSortTextAsc": "Ancien-Nouveau", + "core.euiDataGridSchema.dateSortTextDesc": "Nouveau-Ancien", + "core.euiDataGridSchema.jsonSortTextAsc": "Petit-Grand", + "core.euiDataGridSchema.jsonSortTextDesc": "Grand-Petit", + "core.euiDataGridSchema.numberSortTextAsc": "Bas-Haut", + "core.euiDataGridSchema.numberSortTextDesc": "Haut-Bas", + "core.euiDatePopoverButton.invalidTitle": "Date non valide : {title}", + "core.euiDatePopoverButton.outdatedTitle": "Mise à jour requise : {title}", + "core.euiFieldPassword.maskPassword": "Masquer le mot de passe", + "core.euiFieldPassword.showPassword": "Afficher le mot de passe en texte brut. Remarque : votre mot de passe sera visible à l'écran.", + "core.euiFilePicker.clearSelectedFiles": "Effacer les fichiers sélectionnés", + "core.euiFilePicker.removeSelected": "Supprimer", + "core.euiFlyout.closeAriaLabel": "Fermer cette boîte de dialogue", + "core.euiForm.addressFormErrors": "Veuillez remédier aux erreurs signalées en surbrillance.", + "core.euiFormControlLayoutClearButton.label": "Effacer l'entrée", + "core.euiHeaderLinks.appNavigation": "Menu de l'application", + "core.euiHeaderLinks.openNavigationMenu": "Ouvrir le menu", + "core.euiHue.label": "Sélectionner la valeur \"hue\" du mode de couleur HSV", + "core.euiImage.closeImage": "Fermer l'image {alt} en plein écran", + "core.euiImage.openImage": "Ouvrir l'image {alt} en plein écran", + "core.euiLink.external.ariaLabel": "Lien externe", + "core.euiLink.newTarget.screenReaderOnlyText": "(s’ouvre dans un nouvel onglet ou une nouvelle fenêtre)", + "core.euiMarkdownEditorFooter.closeButton": "Fermer", + "core.euiMarkdownEditorFooter.errorsTitle": "Erreurs", + "core.euiMarkdownEditorFooter.openUploadModal": "Activer le mode de chargement de fichiers", + "core.euiMarkdownEditorFooter.showMarkdownHelp": "Afficher l'aide de Markdown", + "core.euiMarkdownEditorFooter.showSyntaxErrors": "Afficher les erreurs", + "core.euiMarkdownEditorFooter.supportedFileTypes": "Fichiers pris en charge : {supportedFileTypes}", + "core.euiMarkdownEditorFooter.syntaxTitle": "Aide pour la syntaxe", + "core.euiMarkdownEditorFooter.unsupportedFileType": "Type de fichiers non pris en charge", + "core.euiMarkdownEditorFooter.uploadingFiles": "Cliquer pour charger des fichiers", + "core.euiMarkdownEditorToolbar.editor": "Éditeur", + "core.euiMarkdownEditorToolbar.previewMarkdown": "Aperçu", + "core.euiModal.closeModal": "Ferme cette fenêtre modale.", + "core.euiNotificationEventMessages.accordionAriaLabelButtonText": "+ {messagesLength} messages pour {eventName}", + "core.euiNotificationEventMessages.accordionButtonText": "+ {messagesLength} de plus", + "core.euiNotificationEventMessages.accordionHideText": "masquer", + "core.euiNotificationEventMeta.contextMenuButton": "Menu pour {eventName}", + "core.euiNotificationEventReadButton.markAsRead": "Marquer comme lu", + "core.euiNotificationEventReadButton.markAsReadAria": "Marquer {eventName} comme lu", + "core.euiNotificationEventReadButton.markAsUnread": "Marquer comme non lu", + "core.euiNotificationEventReadButton.markAsUnreadAria": "Marquer {eventName} comme non lu", + "core.euiNotificationEventReadIcon.read": "Lu", + "core.euiNotificationEventReadIcon.readAria": "{eventName} lu", + "core.euiNotificationEventReadIcon.unread": "Non lu", + "core.euiNotificationEventReadIcon.unreadAria": "{eventName} non lu", + "core.euiPagination.firstRangeAriaLabel": "Ignorer les pages 2 à {lastPage}", + "core.euiPagination.lastRangeAriaLabel": "Ignorer les pages {firstPage} à {lastPage}", + "core.euiPagination.pageOfTotalCompressed": "{page} sur {total}", + "core.euiPaginationButton.longPageString": "Page {page} sur {totalPages}", + "core.euiPaginationButton.shortPageString": "Page {page}", + "core.euiPinnableListGroup.pinExtraActionLabel": "Épingler l'élément", + "core.euiPinnableListGroup.pinnedExtraActionLabel": "Désépingler l'élément", + "core.euiPopover.screenReaderAnnouncement": "Il s’agit d’une boîte de dialogue. Appuyez sur Échap pour quitter.", + "core.euiProgress.valueText": "{value} %", + "core.euiQuickSelect.applyButton": "Appliquer", + "core.euiQuickSelect.fullDescription": "Actuellement défini sur {timeTense} {timeValue} {timeUnit}.", + "core.euiQuickSelect.legendText": "Sélection rapide d’une plage temporelle", + "core.euiQuickSelect.nextLabel": "Fenêtre temporelle suivante", + "core.euiQuickSelect.previousLabel": "Fenêtre temporelle précédente", + "core.euiQuickSelect.quickSelectTitle": "Sélection rapide", + "core.euiQuickSelect.tenseLabel": "Durée", + "core.euiQuickSelect.unitLabel": "Unité de temps", + "core.euiQuickSelect.valueLabel": "Valeur de temps", + "core.euiRecentlyUsed.legend": "Plages de dates récemment utilisées", + "core.euiRefreshInterval.legend": "Actualiser toutes les", + "core.euiRelativeTab.fullDescription": "L'unité peut être modifiée. Elle est actuellement définie sur {unit}.", + "core.euiRelativeTab.numberInputError": "Doit être >= 0.", + "core.euiRelativeTab.numberInputLabel": "Nombre d'intervalles", + "core.euiRelativeTab.relativeDate": "Date de {position}", + "core.euiRelativeTab.roundingLabel": "Arrondir à {unit}", + "core.euiRelativeTab.unitInputLabel": "Intervalle relatif", + "core.euiResizableButton.horizontalResizerAriaLabel": "Utilisez les flèches gauche et droite pour ajuster la taille des panneaux.", + "core.euiResizableButton.verticalResizerAriaLabel": "Utilisez les flèches vers le haut et vers le bas pour ajuster la taille des panneaux.", + "core.euiResizablePanel.toggleButtonAriaLabel": "Appuyez pour afficher/masquer ce panneau.", + "core.euiSaturation.ariaLabel": "Curseur à 2 axes de valeur et de saturation du mode de couleur HSV", + "core.euiSaturation.screenReaderInstructions": "Utilisez les touches fléchées pour parcourir le dégradé de couleurs. Les coordonnées seront utilisées pour calculer les chiffres de \"valeur\" et de \"saturation\" du mode de couleur HSV, dans une plage de 0 à 1. Les flèches gauche et droite permettent de modifier la saturation. Les flèches vers le haut et vers le bas permettent de modifier la valeur.", + "core.euiSelectable.loadingOptions": "Options de chargement", + "core.euiSelectable.noAvailableOptions": "Aucune option disponible", + "core.euiSelectable.noMatchingOptions": "{searchValue} ne correspond à aucune option.", + "core.euiSelectable.placeholderName": "Options de filtre", + "core.euiSelectableListItem.excludedOption": "Option exclue.", + "core.euiSelectableListItem.excludedOptionInstructions": "Pour désélectionner cette option, appuyez sur Entrée.", + "core.euiSelectableListItem.includedOption": "Option incluse.", + "core.euiSelectableListItem.includedOptionInstructions": "Pour exclure cette option, appuyez sur Entrée.", + "core.euiSelectableTemplateSitewide.loadingResults": "Chargement des résultats", + "core.euiSelectableTemplateSitewide.noResults": "Aucun résultat disponible", + "core.euiSelectableTemplateSitewide.onFocusBadgeGoTo": "Atteindre", + "core.euiSelectableTemplateSitewide.searchPlaceholder": "Rechercher tout...", + "core.euiStat.loadingText": "Statistiques en cours de chargement", + "core.euiStepStrings.complete": "L'étape {number} : {title} est terminée.", + "core.euiStepStrings.current": "L’étape {number} : {title} est en cours.", + "core.euiStepStrings.disabled": "L'étape {number} : {title} est désactivée.", + "core.euiStepStrings.errors": "L'étape {number} : {title} contient des erreurs.", + "core.euiStepStrings.incomplete": "L'étape {number} : {title} est incomplète.", + "core.euiStepStrings.loading": "L'étape {number} : {title} est en cours de chargement.", + "core.euiStepStrings.simpleComplete": "L'étape {number} est terminée.", + "core.euiStepStrings.simpleCurrent": "L’étape {number} est en cours.", + "core.euiStepStrings.simpleDisabled": "L'étape {number} est désactivée.", + "core.euiStepStrings.simpleErrors": "L'étape {number} contient des erreurs.", + "core.euiStepStrings.simpleIncomplete": "L'étape {number} est incomplète.", + "core.euiStepStrings.simpleLoading": "L'étape {number} est en cours de chargement.", + "core.euiStepStrings.simpleStep": "Étape {number}", + "core.euiStepStrings.simpleWarning": "L'étape {number} contient des avertissements.", + "core.euiStepStrings.step": "Étape {number} : {title}", + "core.euiStepStrings.warning": "L'étape {number} : {title} contient des avertissements.", + "core.euiSuperSelectControl.selectAnOption": "Sélectionner une option : l’option {selectedValue} est sélectionnée.", + "core.euiSuperUpdateButton.cannotUpdateTooltip": "Mise à jour impossible", + "core.euiSuperUpdateButton.clickToApplyTooltip": "Cliquer pour appliquer", + "core.euiSuperUpdateButton.refreshButtonLabel": "Actualiser", + "core.euiSuperUpdateButton.updateButtonLabel": "Mettre à jour", + "core.euiSuperUpdateButton.updatingButtonLabel": "Mise à jour", + "core.euiTableHeaderCell.titleTextWithDesc": "{innerText} ; {description}", + "core.euiTablePagination.rowsPerPage": "Lignes par page", + "core.euiTablePagination.rowsPerPageOption": "{rowsPerPage} lignes", + "core.euiTableSortMobile.sorting": "Tri", + "core.euiToast.dismissToast": "Rejeter le toast", + "core.euiToast.newNotification": "Une nouvelle notification apparaît.", + "core.euiToast.notification": "Notification", + "core.euiTourStep.closeTour": "Fermer la visite", + "core.euiTourStep.endTour": "Terminer la visite", + "core.euiTourStep.skipTour": "Ignorer la visite", + "core.euiTourStepIndicator.ariaLabel": "Étape {number} {status}", + "core.euiTourStepIndicator.isActive": "active", + "core.euiTourStepIndicator.isComplete": "terminée", + "core.euiTourStepIndicator.isIncomplete": "incomplète", + "core.euiTreeView.ariaLabel": "{nodeLabel} enfant de {ariaLabel}", + "core.euiTreeView.listNavigationInstructions": "Utilisez les touches fléchées pour parcourir rapidement cette liste.", + "core.fatalErrors.clearYourSessionButtonLabel": "Effacer votre session", + "core.fatalErrors.goBackButtonLabel": "Retour", + "core.fatalErrors.somethingWentWrongTitle": "Un problème est survenu.", + "core.fatalErrors.tryRefreshingPageDescription": "Essayez d'actualiser la page. Si cela ne fonctionne pas, retournez à la page précédente ou effacez vos données de session.", + "core.notifications.errorToast.closeModal": "Fermer", + "core.notifications.globalToast.ariaLabel": "Liste de messages de notification", + "core.notifications.unableUpdateUISettingNotificationMessageTitle": "Impossible de mettre à jour le paramètre de l'interface utilisateur", + "core.status.greenTitle": "Vert", + "core.status.redTitle": "Rouge", + "core.status.yellowTitle": "Jaune", + "core.statusPage.loadStatus.serverIsDownErrorMessage": "Échec de requête du statut du serveur. Votre serveur est peut-être indisponible ?", + "core.statusPage.loadStatus.serverStatusCodeErrorMessage": "Échec de requête du statut du serveur avec le code de statut {responseStatus}.", + "core.statusPage.metricsTiles.columns.heapTotalHeader": "Tas total", + "core.statusPage.metricsTiles.columns.heapUsedHeader": "Tas utilisé", + "core.statusPage.metricsTiles.columns.loadHeader": "Charger", + "core.statusPage.metricsTiles.columns.requestsPerSecHeader": "Requêtes par seconde", + "core.statusPage.metricsTiles.columns.resTimeAvgHeader": "Temps de réponse moyen", + "core.statusPage.metricsTiles.columns.resTimeMaxHeader": "Temps de réponse max.", + "core.statusPage.serverStatus.statusTitle": "Statut Kibana : {kibanaStatus}", + "core.statusPage.statusApp.loadingErrorText": "Une erreur s'est produite lors du chargement du statut.", + "core.statusPage.statusApp.statusActions.buildText": "CRÉER {buildNum}", + "core.statusPage.statusApp.statusActions.commitText": "VALIDER {buildSha}", + "core.statusPage.statusApp.statusTitle": "Statut du plug-in", + "core.statusPage.statusTable.columns.idHeader": "ID", + "core.statusPage.statusTable.columns.statusHeader": "Statut", + "core.toasts.errorToast.seeFullError": "Voir l'erreur en intégralité", + "core.ui_settings.params.darkModeText": "Activez le mode sombre pour l'interface utilisateur Kibana. Vous devez actualiser la page pour que ce paramètre s’applique.", + "core.ui_settings.params.darkModeTitle": "Mode sombre", + "core.ui_settings.params.dateFormat.dayOfWeekText": "Quel est le premier jour de la semaine ?", + "core.ui_settings.params.dateFormat.dayOfWeekTitle": "Jour de la semaine", + "core.ui_settings.params.dateFormat.optionsLinkText": "format", + "core.ui_settings.params.dateFormat.scaled.intervalsLinkText": "Intervalles ISO8601", + "core.ui_settings.params.dateFormat.scaledText": "Les valeurs qui définissent le format utilisé lorsque les données temporelles sont rendues dans l'ordre, et lorsque les horodatages formatés doivent s'adapter à l'intervalle entre les mesures. Les clés sont {intervalsLink}.", + "core.ui_settings.params.dateFormat.scaledTitle": "Format de date scalé", + "core.ui_settings.params.dateFormat.timezone.invalidValidationMessage": "Fuseau horaire non valide : {timezone}", + "core.ui_settings.params.dateFormat.timezoneTitle": "Fuseau horaire pour le format de date", + "core.ui_settings.params.dateFormatText": "{formatLink} utilisé pour les dates formatées", + "core.ui_settings.params.dateFormatTitle": "Format de date", + "core.ui_settings.params.dateNanosFormatText": "Utilisé pour le type de données {dateNanosLink} d'Elasticsearch", + "core.ui_settings.params.dateNanosFormatTitle": "Date au format nanosecondes", + "core.ui_settings.params.dateNanosLinkTitle": "date_nanos", + "core.ui_settings.params.dayOfWeekText.invalidValidationMessage": "Jour de la semaine non valide : {dayOfWeek}", + "core.ui_settings.params.defaultRoute.defaultRouteIsRelativeValidationMessage": "Doit être une URL relative.", + "core.ui_settings.params.defaultRoute.defaultRouteText": "Ce paramètre spécifie le chemin par défaut lors de l'ouverture de Kibana. Vous pouvez utiliser ce paramètre pour modifier la page de destination à l'ouverture de Kibana. Le chemin doit être une URL relative.", + "core.ui_settings.params.defaultRoute.defaultRouteTitle": "Chemin par défaut", + "core.ui_settings.params.disableAnimationsText": "Désactivez toutes les animations non nécessaires dans l'interface utilisateur de Kibana. Actualisez la page pour appliquer les modifications.", + "core.ui_settings.params.disableAnimationsTitle": "Désactiver les animations", + "core.ui_settings.params.notifications.banner.markdownLinkText": "Markdown pris en charge", + "core.ui_settings.params.notifications.bannerLifetimeText": "La durée en millisecondes durant laquelle une notification de bannière s'affiche à l'écran. ", + "core.ui_settings.params.notifications.bannerLifetimeTitle": "Durée des notifications de bannière", + "core.ui_settings.params.notifications.bannerText": "Une bannière personnalisée à des fins de notification temporaire de l’ensemble des utilisateurs. {markdownLink}.", + "core.ui_settings.params.notifications.bannerTitle": "Notification de bannière personnalisée", + "core.ui_settings.params.notifications.errorLifetimeText": "La durée en millisecondes durant laquelle une notification d'erreur s'affiche à l'écran. ", + "core.ui_settings.params.notifications.errorLifetimeTitle": "Durée des notifications d'erreur", + "core.ui_settings.params.notifications.infoLifetimeText": "La durée en millisecondes durant laquelle une notification d'information s'affiche à l'écran. ", + "core.ui_settings.params.notifications.infoLifetimeTitle": "Durée des notifications d'information", + "core.ui_settings.params.notifications.warningLifetimeText": "La durée en millisecondes durant laquelle une notification d'avertissement s'affiche à l'écran. ", + "core.ui_settings.params.notifications.warningLifetimeTitle": "Durée des notifications d'avertissement", + "core.ui_settings.params.storeUrlText": "L'URL peut parfois devenir trop longue pour être gérée par certains navigateurs. Pour pallier ce problème, nous testons actuellement le stockage de certaines parties de l'URL dans le stockage de session. N’hésitez pas à nous faire part de vos commentaires.", + "core.ui_settings.params.storeUrlTitle": "Stocker les URL dans le stockage de session", + "core.ui_settings.params.themeVersionTitle": "Version du thème", + "core.ui.chrome.headerGlobalNav.goHomePageIconAriaLabel": "Accueil d'Elastic", + "core.ui.chrome.headerGlobalNav.helpMenuAskElasticTitle": "Questions Elastic", + "core.ui.chrome.headerGlobalNav.helpMenuButtonAriaLabel": "Menu d'aide", + "core.ui.chrome.headerGlobalNav.helpMenuDocumentation": "Documentation", + "core.ui.chrome.headerGlobalNav.helpMenuGiveFeedbackOnApp": "Donner un retour sur {appName}", + "core.ui.chrome.headerGlobalNav.helpMenuGiveFeedbackTitle": "Donner un retour", + "core.ui.chrome.headerGlobalNav.helpMenuKibanaDocumentationTitle": "Documentation Kibana", + "core.ui.chrome.headerGlobalNav.helpMenuOpenGitHubIssueTitle": "Ouvrir un ticket dans GitHub", + "core.ui.chrome.headerGlobalNav.helpMenuTitle": "Aide", + "core.ui.chrome.headerGlobalNav.helpMenuVersion": "v {version}", + "core.ui.chrome.headerGlobalNav.logoAriaLabel": "Logo Elastic", + "core.ui.enterpriseSearchNavList.label": "Enterprise Search", + "core.ui.errorUrlOverflow.bigUrlWarningNotificationMessage": "Activez l'option {storeInSessionStorageParam} dans les {advancedSettingsLink} ou simplifiez les visuels à l'écran.", + "core.ui.errorUrlOverflow.bigUrlWarningNotificationMessage.advancedSettingsLinkText": "paramètres avancés", + "core.ui.errorUrlOverflow.bigUrlWarningNotificationTitle": "L'URL est longue et Kibana pourrait cesser de fonctionner.", + "core.ui.errorUrlOverflow.errorTitle": "L'URL pour cet objet est trop longue, et nous ne pouvons pas l'afficher.", + "core.ui.errorUrlOverflow.optionsToFixError.doNotUseIEText": "Veuillez utiliser un navigateur moderne. Tous les autres navigateurs pris en charge connus n'ont pas cette limitation.", + "core.ui.errorUrlOverflow.optionsToFixError.enableOptionText": "Activez l'option {storeInSessionStorageConfig} sous {kibanaSettingsLink}.", + "core.ui.errorUrlOverflow.optionsToFixError.enableOptionText.advancedSettingsLinkText": "Paramètres avancés", + "core.ui.errorUrlOverflow.optionsToFixError.removeStuffFromDashboardText": "Simplifiez l'objet en cours de modification en supprimant du contenu ou des filtres.", + "core.ui.errorUrlOverflow.optionsToFixErrorDescription": "À essayer :", + "core.ui.kibanaNavList.label": "Analytique", + "core.ui.legacyBrowserMessage": "Cette installation Elastic présente des exigences de sécurité strictes auxquelles votre navigateur ne satisfait pas.", + "core.ui.legacyBrowserTitle": "Merci de mettre votre navigateur à niveau.", + "core.ui.loadingIndicatorAriaLabel": "Chargement du contenu", + "core.ui.managementNavList.label": "Gestion", + "core.ui.observabilityNavList.label": "Observabilité", + "core.ui.overlays.banner.attentionTitle": "Attention", + "core.ui.overlays.banner.closeButtonLabel": "Fermer", + "core.ui.primaryNav.pinnedLinksAriaLabel": "Liens épinglés", + "core.ui.primaryNav.screenReaderLabel": "Principale", + "core.ui.primaryNav.toggleNavAriaLabel": "Activer/Désactiver la navigation principale", + "core.ui.primaryNavSection.screenReaderLabel": "Liens de navigation principale, {category}", + "core.ui.publicBaseUrlWarning.muteWarningButtonLabel": "Avertissement de mise sur Muet", + "core.ui.recentLinks.linkItem.screenReaderLabel": "{recentlyAccessedItemLinklabel}, type : {pageType}", + "core.ui.recentlyViewed": "Récemment consulté", + "core.ui.recentlyViewedAriaLabel": "Liens récemment consultés", + "core.ui.securityNavList.label": "Security", + "core.ui.welcomeErrorMessage": "Elastic ne s'est pas chargé correctement. Vérifiez la sortie du serveur pour plus d'informations.", + "core.ui.welcomeMessage": "Chargement d'Elastic", + "dashboard.actions.DownloadCreateDrilldownAction.displayName": "Télécharger au format CSV", + "dashboard.actions.downloadOptionsUnsavedFilename": "sans titre", + "dashboard.actions.toggleExpandPanelMenuItem.expandedDisplayName": "Minimiser", + "dashboard.actions.toggleExpandPanelMenuItem.notExpandedDisplayName": "Maximiser le panneau", + "dashboard.addPanel.noMatchingObjectsMessage": "Aucun objet correspondant trouvé.", + "dashboard.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} a été ajouté.", + "dashboard.appLeaveConfirmModal.cancelButtonLabel": "Annuler", + "dashboard.appLeaveConfirmModal.unsavedChangesSubtitle": "Quitter le tableau de bord sans enregistrer ?", + "dashboard.appLeaveConfirmModal.unsavedChangesTitle": "Modifications non enregistrées", + "dashboard.badge.readOnly.text": "Lecture seule", + "dashboard.badge.readOnly.tooltip": "Impossible d'enregistrer les tableaux de bord", + "dashboard.changeViewModeConfirmModal.cancelButtonLabel": "Poursuivre les modifications", + "dashboard.changeViewModeConfirmModal.confirmButtonLabel": "Ignorer les modifications", + "dashboard.changeViewModeConfirmModal.description": "Vous pouvez conserver ou ignorer vos modifications lors du retour en mode Affichage. Les modifications ignorées ne peuvent toutefois pas être récupérées.", + "dashboard.changeViewModeConfirmModal.keepUnsavedChangesButtonLabel": "Conserver les modifications", + "dashboard.changeViewModeConfirmModal.leaveEditModeTitle": "Vous avez des modifications non enregistrées.", + "dashboard.cloneModal.cloneDashboardTitleAriaLabel": "Titre du tableau de bord cloné", + "dashboard.createConfirmModal.cancelButtonLabel": "Annuler", + "dashboard.createConfirmModal.confirmButtonLabel": "Redémarrer", + "dashboard.createConfirmModal.continueButtonLabel": "Poursuivre les modifications", + "dashboard.createConfirmModal.unsavedChangesSubtitle": "Vous pouvez poursuivre les modifications ou utiliser un tableau de bord vierge.", + "dashboard.createConfirmModal.unsavedChangesTitle": "Nouveau tableau de bord déjà en cours", + "dashboard.dashboardAppBreadcrumbsTitle": "Tableau de bord", + "dashboard.dashboardGrid.toast.unableToLoadDashboardDangerMessage": "Impossible de charger le tableau de bord.", + "dashboard.dashboardPageTitle": "Tableaux de bord", + "dashboard.dashboardWasNotSavedDangerMessage": "Le tableau de bord \"{dashTitle}\" n'a pas été enregistré. Erreur : {errorMessage}", + "dashboard.dashboardWasSavedSuccessMessage": "Le tableau de bord \"{dashTitle}\" a été enregistré.", + "dashboard.discardChangesConfirmModal.cancelButtonLabel": "Annuler", + "dashboard.discardChangesConfirmModal.confirmButtonLabel": "Ignorer les modifications", + "dashboard.discardChangesConfirmModal.discardChangesDescription": "Une fois les modifications ignorées, vous ne pourrez pas les récupérer.", + "dashboard.discardChangesConfirmModal.discardChangesTitle": "Ignorer les modifications apportées au tableau de bord ?", + "dashboard.editorMenu.aggBasedGroupTitle": "Basé sur une agrégation", + "dashboard.embedUrlParamExtension.filterBar": "Barre de filtre", + "dashboard.embedUrlParamExtension.include": "Inclure", + "dashboard.embedUrlParamExtension.query": "Requête", + "dashboard.embedUrlParamExtension.timeFilter": "Filtre temporel", + "dashboard.embedUrlParamExtension.topMenu": "Menu supérieur", + "dashboard.emptyDashboardAdditionalPrivilege": "Des privilèges supplémentaires sont requis pour pouvoir modifier ce tableau de bord.", + "dashboard.emptyDashboardTitle": "Ce tableau de bord est vide.", + "dashboard.emptyWidget.addPanelDescription": "Créez du contenu qui raconte une histoire sur vos données.", + "dashboard.emptyWidget.addPanelTitle": "Ajoutez votre première visualisation.", + "dashboard.factory.displayName": "Tableau de bord", + "dashboard.featureCatalogue.dashboardDescription": "Affichez et partagez une collection de visualisations et de recherches enregistrées.", + "dashboard.featureCatalogue.dashboardSubtitle": "Analysez des données à l’aide de tableaux de bord.", + "dashboard.featureCatalogue.dashboardTitle": "Tableau de bord", + "dashboard.fillDashboardTitle": "Ce tableau de bord est vide. Remplissons-le.", + "dashboard.helpMenu.appName": "Tableaux de bord", + "dashboard.howToStartWorkingOnNewDashboardDescription": "Cliquez sur Modifier dans la barre de menu ci-dessus pour commencer à ajouter des panneaux.", + "dashboard.howToStartWorkingOnNewDashboardEditLinkAriaLabel": "Modifier le tableau de bord", + "dashboard.labs.enableLabsDescription": "Cet indicateur détermine si l'observateur a accès au bouton Ateliers, un moyen rapide d'activer et de désactiver les fonctionnalités expérimentales dans le tableau de bord.", + "dashboard.labs.enableUI": "Activer le bouton Ateliers dans le tableau de bord", + "dashboard.listing.createNewDashboard.combineDataViewFromKibanaAppDescription": "Vous pouvez combiner les vues de données de n'importe quelle application Kibana dans un seul tableau de bord afin de tout regrouper.", + "dashboard.listing.createNewDashboard.createButtonLabel": "Créer un nouveau tableau de bord", + "dashboard.listing.createNewDashboard.newToKibanaDescription": "Vous êtes nouveau sur Kibana ? {sampleDataInstallLink} pour découvrir l'application.", + "dashboard.listing.createNewDashboard.sampleDataInstallLinkText": "Installez un exemple de données", + "dashboard.listing.createNewDashboard.title": "Créer votre premier tableau de bord", + "dashboard.listing.readonlyNoItemsBody": "Aucun tableau de bord n'est disponible. Pour modifier vos autorisations afin d’afficher les tableaux de bord dans cet espace, contactez votre administrateur.", + "dashboard.listing.readonlyNoItemsTitle": "Aucun tableau de bord à afficher", + "dashboard.listing.table.descriptionColumnName": "Description", + "dashboard.listing.table.entityName": "tableau de bord", + "dashboard.listing.table.entityNamePlural": "tableaux de bord", + "dashboard.listing.table.titleColumnName": "Titre", + "dashboard.listing.unsaved.discardAria": "Ignorer les modifications apportées à {title}", + "dashboard.listing.unsaved.discardTitle": "Ignorer les modifications", + "dashboard.listing.unsaved.editAria": "Poursuivre les modifications apportées à {title}", + "dashboard.listing.unsaved.editTitle": "Poursuivre les modifications", + "dashboard.listing.unsaved.loading": "Chargement", + "dashboard.listing.unsaved.unsavedChangesTitle": "Vous avez des modifications non enregistrées dans le {dash} suivant.", + "dashboard.migratedChanges": "Certains des panneaux ont été mis à jour vers la version la plus récente.", + "dashboard.noMatchRoute.bannerText": "L'application de tableau de bord ne reconnaît pas ce chemin : {route}.", + "dashboard.noMatchRoute.bannerTitleText": "Page introuvable", + "dashboard.panel.AddToLibrary": "Enregistrer dans la bibliothèque", + "dashboard.panel.addToLibrary.successMessage": "Le panneau {panelTitle} a été ajouté à la bibliothèque Visualize.", + "dashboard.panel.clonedToast": "Panneau cloné", + "dashboard.panel.clonePanel": "Cloner le panneau", + "dashboard.panel.copyToDashboard.cancel": "Annuler", + "dashboard.panel.copyToDashboard.description": "Sélectionnez l'emplacement où copier le panneau. Vous avez été redirigé vers le tableau de bord de destination.", + "dashboard.panel.copyToDashboard.existingDashboardOptionLabel": "Tableau de bord existant", + "dashboard.panel.copyToDashboard.goToDashboard": "Copier et accéder au tableau de bord", + "dashboard.panel.copyToDashboard.newDashboardOptionLabel": "Nouveau tableau de bord", + "dashboard.panel.copyToDashboard.title": "Copier dans le tableau de bord", + "dashboard.panel.invalidData": "Données non valides dans l'url", + "dashboard.panel.LibraryNotification": "Notification de la bibliothèque Visualize", + "dashboard.panel.libraryNotification.ariaLabel": "Afficher les informations de la bibliothèque et dissocier ce panneau", + "dashboard.panel.libraryNotification.toolTip": "La modification de ce panneau pourrait affecter d’autres tableaux de bord. Pour modifier ce panneau uniquement, dissociez-le de la bibliothèque.", + "dashboard.panel.removePanel.replacePanel": "Remplacer le panneau", + "dashboard.panel.title.clonedTag": "copier", + "dashboard.panel.unableToMigratePanelDataForSixOneZeroErrorMessage": "Impossible de migrer les données du panneau pour une rétro-compatibilité \"6.1.0\". Le panneau ne contient pas les champs de colonne et/ou de ligne attendus.", + "dashboard.panel.unableToMigratePanelDataForSixThreeZeroErrorMessage": "Impossible de migrer les données du panneau pour une rétro-compatibilité \"6.3.0\". Le panneau ne contient pas le champ attendu : {key}.", + "dashboard.panel.unlinkFromLibrary": "Dissocier de la bibliothèque", + "dashboard.panel.unlinkFromLibrary.successMessage": "Le panneau {panelTitle} n'est plus connecté à la bibliothèque Visualize.", + "dashboard.panelStorageError.clearError": "Une erreur s'est produite lors de la suppression des modifications non enregistrées : {message}.", + "dashboard.panelStorageError.getError": "Une erreur s'est produite lors de la récupération des modifications non enregistrées : {message}.", + "dashboard.panelStorageError.setError": "Une erreur s'est produite lors de la définition des modifications non enregistrées : {message}.", + "dashboard.placeholder.factory.displayName": "paramètre fictif", + "dashboard.savedDashboard.newDashboardTitle": "Nouveau tableau de bord", + "dashboard.solutionToolbar.addPanelButtonLabel": "Créer une visualisation", + "dashboard.solutionToolbar.editorMenuButtonLabel": "Tous les types", + "dashboard.strings.dashboardEditTitle": "Modification de {title}", + "dashboard.topNav.cloneModal.cancelButtonLabel": "Annuler", + "dashboard.topNav.cloneModal.cloneDashboardModalHeaderTitle": "Cloner le tableau de bord", + "dashboard.topNav.cloneModal.confirmButtonLabel": "Confirmer le clonage", + "dashboard.topNav.cloneModal.confirmCloneDescription": "Confirmer le clonage", + "dashboard.topNav.cloneModal.dashboardExistsDescription": "Cliquez sur {confirmClone} pour cloner le tableau de bord avec le titre dupliqué.", + "dashboard.topNav.cloneModal.dashboardExistsTitle": "Un tableau de bord nommé {newDashboardName} existe déjà.", + "dashboard.topNav.cloneModal.enterNewNameForDashboardDescription": "Veuillez saisir un autre nom pour votre tableau de bord.", + "dashboard.topNav.labsButtonAriaLabel": "ateliers", + "dashboard.topNav.labsConfigDescription": "Ateliers", + "dashboard.topNav.options.hideAllPanelTitlesSwitchLabel": "Afficher les titres de panneau", + "dashboard.topNav.options.syncColorsBetweenPanelsSwitchLabel": "Synchroniser les palettes de couleur de tous les panneaux", + "dashboard.topNav.options.useMarginsBetweenPanelsSwitchLabel": "Utiliser des marges entre les panneaux", + "dashboard.topNav.saveModal.descriptionFormRowLabel": "Description", + "dashboard.topNav.saveModal.objectType": "tableau de bord", + "dashboard.topNav.saveModal.storeTimeWithDashboardFormRowHelpText": "Le filtre temporel est défini sur l’option sélectionnée chaque fois que ce tableau de bord est chargé.", + "dashboard.topNav.saveModal.storeTimeWithDashboardFormRowLabel": "Enregistrer la plage temporelle avec le tableau de bord", + "dashboard.topNav.showCloneModal.dashboardCopyTitle": "Copie de {title}", + "dashboard.topNave.cancelButtonAriaLabel": "Basculer en mode Affichage", + "dashboard.topNave.cloneButtonAriaLabel": "cloner", + "dashboard.topNave.cloneConfigDescription": "Créer une copie du tableau de bord", + "dashboard.topNave.editButtonAriaLabel": "modifier", + "dashboard.topNave.editConfigDescription": "Basculer en mode Édition", + "dashboard.topNave.fullScreenButtonAriaLabel": "plein écran", + "dashboard.topNave.fullScreenConfigDescription": "Mode Plein écran", + "dashboard.topNave.optionsButtonAriaLabel": "options", + "dashboard.topNave.optionsConfigDescription": "Options", + "dashboard.topNave.saveAsButtonAriaLabel": "enregistrer sous", + "dashboard.topNave.saveAsConfigDescription": "Enregistrer en tant que nouveau tableau de bord", + "dashboard.topNave.saveButtonAriaLabel": "enregistrer", + "dashboard.topNave.saveConfigDescription": "Enregistrer le tableau de bord sans invite de confirmation", + "dashboard.topNave.shareButtonAriaLabel": "partager", + "dashboard.topNave.shareConfigDescription": "Partager le tableau de bord", + "dashboard.topNave.viewConfigDescription": "Basculer en mode Affichage uniquement", + "dashboard.unsavedChangesBadge": "Modifications non enregistrées", + "dashboard.urlWasRemovedInSixZeroWarningMessage": "L'url \"dashboard/create\" a été supprimée dans la version 6.0. Veuillez mettre vos signets à jour.", + "data.advancedSettings.autocompleteIgnoreTimerange": "Utiliser la plage temporelle", + "data.advancedSettings.autocompleteIgnoreTimerangeText": "Désactivez cette propriété pour obtenir des suggestions de saisie semi-automatique depuis l’intégralité de l’ensemble de données plutôt que depuis la plage temporelle définie. {learnMoreLink}", + "data.advancedSettings.autocompleteValueSuggestionMethod": "Méthode de suggestion de saisie semi-automatique", + "data.advancedSettings.autocompleteValueSuggestionMethodLearnMoreLink": "En savoir plus.", + "data.advancedSettings.autocompleteValueSuggestionMethodLink": "En savoir plus.", + "data.advancedSettings.autocompleteValueSuggestionMethodText": "La méthode utilisée pour générer des suggestions de valeur pour la saisie semi-automatique KQL. Sélectionnez terms_enum pour utiliser l'API d'énumération de termes d'Elasticsearch afin d’améliorer les performances de suggestion de saisie semi-automatique. Sélectionnez terms_agg pour utiliser l'agrégation de termes d'Elasticsearch. {learnMoreLink}", + "data.advancedSettings.courier.customRequestPreference.requestPreferenceLinkText": "Préférence de requête", + "data.advancedSettings.courier.customRequestPreferenceText": "{requestPreferenceLink} utilisé lorsque {setRequestReferenceSetting} est défini sur {customSettingValue}.", + "data.advancedSettings.courier.customRequestPreferenceTitle": "Préférence de requête personnalisée", + "data.advancedSettings.courier.ignoreFilterText": "Cette configuration améliore la prise en charge des tableaux de bord contenant des visualisations accédant à des index différents. Lorsque ce paramètre est désactivé, tous les filtres sont appliqués à toutes les visualisations. En cas d'activation, le ou les filtres sont ignorés pour une visualisation lorsque l'index de celle-ci ne contient pas le champ de filtrage.", + "data.advancedSettings.courier.ignoreFilterTitle": "Ignorer le ou les filtres", + "data.advancedSettings.courier.maxRequestsText": "Contrôle le paramètre {maxRequestsLink} utilisé pour les requêtes _msearch envoyées par Kibana. Définir ce paramètre sur 0 permet d’utiliser la valeur Elasticsearch par défaut.", + "data.advancedSettings.courier.maxRequestsTitle": "Requêtes de partitions simultanées max.", + "data.advancedSettings.courier.requestPreferenceCustom": "Personnalisée", + "data.advancedSettings.courier.requestPreferenceNone": "Aucune", + "data.advancedSettings.courier.requestPreferenceSessionId": "ID session", + "data.advancedSettings.courier.requestPreferenceText": "Permet de définir quelles partitions doivent gérer les requêtes de recherche.\n
      \n
    • {sessionId} : limite les opérations pour exécuter toutes les requêtes de recherche sur les mêmes partitions.\n Cela a l'avantage de réutiliser les caches de partition pour toutes les requêtes.
    • \n
    • {custom} : permet de définir une valeur de préférence.\n Utilisez \"courier:customRequestPreference\" pour personnaliser votre valeur de préférence.
    • \n
    • {none} : permet de ne pas définir de préférence.\n Cela peut permettre de meilleures performances, car les requêtes peuvent être réparties entre toutes les copies de partition.\n Cependant, les résultats peuvent être incohérents, les différentes partitions pouvant se trouver dans différents états d'actualisation.
    • \n
    ", + "data.advancedSettings.courier.requestPreferenceTitle": "Préférence de requête", + "data.advancedSettings.defaultIndexText": "L’index utilisé en l’absence de spécification.", + "data.advancedSettings.defaultIndexTitle": "Index par défaut", + "data.advancedSettings.docTableHighlightText": "Cela permet de mettre les résultats en surbrillance dans le tableau de bord Discover ainsi que dans les recherches enregistrées. À noter que la mise en surbrillance ralentit les requêtes dans le cas de documents volumineux.", + "data.advancedSettings.docTableHighlightTitle": "Mettre les résultats en surbrillance", + "data.advancedSettings.histogram.barTargetText": "Tente de générer ce nombre de compartiments lorsque l’intervalle \"auto\" est utilisé dans des histogrammes numériques et de date.", + "data.advancedSettings.histogram.barTargetTitle": "Nombre de compartiments cible", + "data.advancedSettings.histogram.maxBarsText": "\n Limite la densité des histogrammes numériques et de date dans tout Kibana\n pour de meilleures performances à l’aide d’une requête de test. Si la requête de test génère trop de compartiments,\n l'intervalle entre les compartiments est augmenté. Ce paramètre s'applique séparément\n pour chaque agrégation d'histogrammes et ne s'applique pas aux autres types d'agrégations.\n Pour identifier la valeur maximale de ce paramètre, divisez la valeur \"search.max_buckets\" d'Elasticsearch\n par le nombre maximal d'agrégations dans chaque visualisation.\n ", + "data.advancedSettings.histogram.maxBarsTitle": "Nombre maximal de compartiments", + "data.advancedSettings.historyLimitText": "Le nombre de valeurs les plus récentes qui s’affichent pour les champs associés à un historique (par exemple, les entrées de requête).", + "data.advancedSettings.historyLimitTitle": "Limite d'historique", + "data.advancedSettings.metaFieldsText": "Champs qui existent en dehors de _source pour fusionner avec le document lors de l'affichage.", + "data.advancedSettings.metaFieldsTitle": "Champs méta", + "data.advancedSettings.pinFiltersText": "Détermine si les filtres doivent avoir un certain état global (être épinglés) par défaut.", + "data.advancedSettings.pinFiltersTitle": "Épingler les filtres par défaut", + "data.advancedSettings.query.allowWildcardsText": "Lorsque ce paramètre est activé, le caractère \"*\" est autorisé en tant que premier caractère dans une clause de requête. Ne s'applique actuellement que lorsque les fonctionnalités de requête expérimentales sont activées dans la barre de requête. Pour ne plus autoriser l’utilisation de caractères génériques au début des requêtes Lucene de base, utilisez {queryStringOptionsPattern}.", + "data.advancedSettings.query.allowWildcardsTitle": "Autoriser les caractères génériques au début des requêtes", + "data.advancedSettings.query.queryStringOptions.optionsLinkText": "Options", + "data.advancedSettings.query.queryStringOptionsText": "{optionsLink} pour l'analyseur de chaînes de requête Lucene. Uniquement utilisé lorsque \"{queryLanguage}\" est défini sur {luceneLanguage}.", + "data.advancedSettings.query.queryStringOptionsTitle": "Options de chaîne de requête", + "data.advancedSettings.searchQueryLanguageKql": "KQL", + "data.advancedSettings.searchQueryLanguageLucene": "Lucene", + "data.advancedSettings.searchQueryLanguageText": "Le langage de requête utilisé par la barre de requête. KQL est un nouveau langage spécialement conçu pour Kibana.", + "data.advancedSettings.searchQueryLanguageTitle": "Langage de requête", + "data.advancedSettings.searchTimeout": "Délai d'expiration de la recherche", + "data.advancedSettings.searchTimeoutDesc": "Permet de définir le délai d'expiration maximal pour une session de recherche. La valeur 0 permet de désactiver le délai d’expiration afin que les requêtes soient exécutées jusqu'au bout.", + "data.advancedSettings.sortOptions.optionsLinkText": "Options", + "data.advancedSettings.sortOptionsText": "{optionsLink} pour le paramètre de tri Elasticsearch", + "data.advancedSettings.sortOptionsTitle": "Options de tri", + "data.advancedSettings.suggestFilterValuesText": "Définir cette propriété sur \"faux\" permet d’empêcher l'éditeur de filtres de suggérer des valeurs pour les champs.", + "data.advancedSettings.suggestFilterValuesTitle": "Suggestions de l'éditeur de filtres", + "data.advancedSettings.timepicker.last15Minutes": "Dernières 15 minutes", + "data.advancedSettings.timepicker.last1Hour": "Dernière heure", + "data.advancedSettings.timepicker.last1Year": "Dernière année", + "data.advancedSettings.timepicker.last24Hours": "Dernières 24 heures", + "data.advancedSettings.timepicker.last30Days": "30 derniers jours", + "data.advancedSettings.timepicker.last30Minutes": "30 dernières minutes", + "data.advancedSettings.timepicker.last7Days": "7 derniers jours", + "data.advancedSettings.timepicker.last90Days": "90 derniers jours", + "data.advancedSettings.timepicker.quickRanges.acceptedFormatsLinkText": "formats acceptés", + "data.advancedSettings.timepicker.quickRangesText": "La liste des plages à afficher dans la section rapide du filtre temporel. Il s’agit d’un tableau d'objets, avec chaque objet contenant \"de\", \"à\" (voir {acceptedFormatsLink}) et \"afficher\" (le titre à afficher).", + "data.advancedSettings.timepicker.quickRangesTitle": "Plages rapides du filtre temporel", + "data.advancedSettings.timepicker.refreshIntervalDefaultsText": "L'intervalle d'actualisation par défaut du filtre temporel. La valeur doit être spécifiée en millisecondes.", + "data.advancedSettings.timepicker.refreshIntervalDefaultsTitle": "Intervalle d'actualisation du filtre temporel", + "data.advancedSettings.timepicker.thisWeek": "Cette semaine", + "data.advancedSettings.timepicker.timeDefaultsText": "L’option de filtre temporel à utiliser lorsque Kibana est démarré sans filtre", + "data.advancedSettings.timepicker.timeDefaultsTitle": "Filtre temporel par défaut", + "data.advancedSettings.timepicker.today": "Aujourd'hui", + "data.aggTypes.buckets.ranges.rangesFormatMessage": "{gte} {from} et {lt} {to}", + "data.aggTypes.buckets.ranges.rangesFormatMessageArrowRight": "{from} → {to}", + "data.errors.fetchError": "Vérifiez votre réseau et la configuration de votre proxy. Si le problème persiste, contactez votre administrateur réseau.", + "data.filter.applyFilterActionTitle": "Appliquer le filtre à la vue en cours", + "data.filter.applyFilters.popupHeader": "Sélectionner les filtres à appliquer", + "data.filter.applyFiltersPopup.cancelButtonLabel": "Annuler", + "data.filter.applyFiltersPopup.saveButtonLabel": "Appliquer", + "data.filter.filterBar.addFilterButtonLabel": "Ajouter un filtre", + "data.filter.filterBar.deleteFilterButtonLabel": "Supprimer", + "data.filter.filterBar.disabledFilterPrefix": "Désactivé", + "data.filter.filterBar.disableFilterButtonLabel": "Désactiver temporairement", + "data.filter.filterBar.editFilterButtonLabel": "Modifier le filtre", + "data.filter.filterBar.enableFilterButtonLabel": "Réactiver", + "data.filter.filterBar.excludeFilterButtonLabel": "Exclure les résultats", + "data.filter.filterBar.fieldNotFound": "Champ {key} introuvable dans le modèle d'indexation {indexPattern}", + "data.filter.filterBar.filterItemBadgeAriaLabel": "Actions de filtrage", + "data.filter.filterBar.filterItemBadgeIconAriaLabel": "Supprimer {filter}", + "data.filter.filterBar.includeFilterButtonLabel": "Inclure les résultats", + "data.filter.filterBar.indexPatternSelectPlaceholder": "Sélectionner un modèle d'indexation", + "data.filter.filterBar.labelErrorInfo": "Modèle d'indexation {indexPattern} introuvable", + "data.filter.filterBar.labelErrorText": "Erreur", + "data.filter.filterBar.labelWarningInfo": "Le champ {fieldName} n'existe pas dans la vue en cours.", + "data.filter.filterBar.labelWarningText": "Avertissement", + "data.filter.filterBar.moreFilterActionsMessage": "Filtre : {innerText}. Sélectionner pour plus d’actions de filtrage.", + "data.filter.filterBar.negatedFilterPrefix": "NON ", + "data.filter.filterBar.pinFilterButtonLabel": "Épingler dans toutes les applications", + "data.filter.filterBar.pinnedFilterPrefix": "Épinglé", + "data.filter.filterBar.unpinFilterButtonLabel": "Désépingler", + "data.filter.filterEditor.cancelButtonLabel": "Annuler", + "data.filter.filterEditor.createCustomLabelInputLabel": "Étiquette personnalisée", + "data.filter.filterEditor.createCustomLabelSwitchLabel": "Créer une étiquette personnalisée ?", + "data.filter.filterEditor.doesNotExistOperatorOptionLabel": "n'existe pas", + "data.filter.filterEditor.editFilterPopupTitle": "Modifier le filtre", + "data.filter.filterEditor.editFilterValuesButtonLabel": "Modifier les valeurs du filtre", + "data.filter.filterEditor.editQueryDslButtonLabel": "Modifier en tant que Query DSL", + "data.filter.filterEditor.existsOperatorOptionLabel": "existe", + "data.filter.filterEditor.falseOptionLabel": "false", + "data.filter.filterEditor.fieldSelectLabel": "Champ", + "data.filter.filterEditor.fieldSelectPlaceholder": "Sélectionner d'abord un champ", + "data.filter.filterEditor.indexPatternSelectLabel": "Modèle d'indexation", + "data.filter.filterEditor.isBetweenOperatorOptionLabel": "est entre", + "data.filter.filterEditor.isNotBetweenOperatorOptionLabel": "n'est pas entre", + "data.filter.filterEditor.isNotOneOfOperatorOptionLabel": "n'est pas l'une des options suivantes", + "data.filter.filterEditor.isNotOperatorOptionLabel": "n'est pas", + "data.filter.filterEditor.isOneOfOperatorOptionLabel": "est l'une des options suivantes", + "data.filter.filterEditor.isOperatorOptionLabel": "est", + "data.filter.filterEditor.operatorSelectLabel": "Opérateur", + "data.filter.filterEditor.operatorSelectPlaceholderSelect": "Sélectionner", + "data.filter.filterEditor.operatorSelectPlaceholderWaiting": "En attente", + "data.filter.filterEditor.queryDslLabel": "Query DSL d'Elasticsearch", + "data.filter.filterEditor.rangeEndInputPlaceholder": "Fin de la plage", + "data.filter.filterEditor.rangeInputLabel": "Plage", + "data.filter.filterEditor.rangeStartInputPlaceholder": "Début de la plage", + "data.filter.filterEditor.saveButtonLabel": "Enregistrer", + "data.filter.filterEditor.trueOptionLabel": "vrai", + "data.filter.filterEditor.valueInputLabel": "Valeur", + "data.filter.filterEditor.valueInputPlaceholder": "Saisir une valeur", + "data.filter.filterEditor.valueSelectPlaceholder": "Sélectionner une valeur", + "data.filter.filterEditor.valuesSelectLabel": "Valeurs", + "data.filter.filterEditor.valuesSelectPlaceholder": "Sélectionner des valeurs", + "data.filter.options.changeAllFiltersButtonLabel": "Changer tous les filtres", + "data.filter.options.deleteAllFiltersButtonLabel": "Tout supprimer", + "data.filter.options.disableAllFiltersButtonLabel": "Tout désactiver", + "data.filter.options.enableAllFiltersButtonLabel": "Tout activer", + "data.filter.options.invertDisabledFiltersButtonLabel": "Inverser l’activation/désactivation", + "data.filter.options.invertNegatedFiltersButtonLabel": "Inverser l'inclusion", + "data.filter.options.pinAllFiltersButtonLabel": "Tout épingler", + "data.filter.options.unpinAllFiltersButtonLabel": "Tout désépingler", + "data.filter.searchBar.changeAllFiltersTitle": "Changer tous les filtres", + "data.functions.esaggs.help": "Exécuter l'agrégation AggConfig", + "data.functions.esaggs.inspector.dataRequest.description": "Cette requête interroge Elasticsearch pour récupérer les données pour la visualisation.", + "data.functions.esaggs.inspector.dataRequest.title": "Données", + "data.inspector.table..dataDescriptionTooltip": "Afficher les données derrière la visualisation", + "data.inspector.table.dataTitle": "Données", + "data.inspector.table.downloadCSVToggleButtonLabel": "Télécharger CSV", + "data.inspector.table.downloadOptionsUnsavedFilename": "non enregistré", + "data.inspector.table.exportButtonFormulasWarning": "Votre fichier CSV contient des caractères que les applications de feuilles de calcul pourraient considérer comme des formules.", + "data.inspector.table.filterForValueButtonAriaLabel": "Filtrer sur la valeur", + "data.inspector.table.filterForValueButtonTooltip": "Filtrer sur la valeur", + "data.inspector.table.filterOutValueButtonAriaLabel": "Exclure la valeur", + "data.inspector.table.filterOutValueButtonTooltip": "Exclure la valeur", + "data.inspector.table.formattedCSVButtonLabel": "CSV formaté", + "data.inspector.table.formattedCSVButtonTooltip": "Télécharger les données sous forme de tableau", + "data.inspector.table.noDataAvailableDescription": "L'élément n'a fourni aucune donnée.", + "data.inspector.table.noDataAvailableTitle": "Aucune donnée disponible", + "data.inspector.table.rawCSVButtonLabel": "CSV brut", + "data.inspector.table.rawCSVButtonTooltip": "Télécharger les données telles que fournies, par exemple, les dates sous forme d'horodatages", + "data.inspector.table.tableLabel": "Tableau {index}", + "data.inspector.table.tablesDescription": "Il y a {tablesCount, plural, one {# tableau} other {# tableaux} } au total.", + "data.inspector.table.tableSelectorLabel": "Sélectionné :", + "data.kueryAutocomplete.andOperatorDescription": "Nécessite que {bothArguments} soient ''vrai''.", + "data.kueryAutocomplete.andOperatorDescription.bothArgumentsText": "les deux arguments", + "data.kueryAutocomplete.equalOperatorDescription": "{equals} une certaine valeur", + "data.kueryAutocomplete.equalOperatorDescription.equalsText": "égale", + "data.kueryAutocomplete.existOperatorDescription": "{exists} sous un certain format", + "data.kueryAutocomplete.existOperatorDescription.existsText": "existe", + "data.kueryAutocomplete.filterResultsDescription": "Filtrer les résultats contenant {fieldName}", + "data.kueryAutocomplete.greaterThanOperatorDescription": "est {greaterThan} une certaine valeur", + "data.kueryAutocomplete.greaterThanOperatorDescription.greaterThanText": "supérieur à", + "data.kueryAutocomplete.greaterThanOrEqualOperatorDescription": "est {greaterThanOrEqualTo} une certaine valeur", + "data.kueryAutocomplete.greaterThanOrEqualOperatorDescription.greaterThanOrEqualToText": "supérieur ou égal à", + "data.kueryAutocomplete.lessThanOperatorDescription": "est {lessThan} une certaine valeur", + "data.kueryAutocomplete.lessThanOperatorDescription.lessThanText": "inférieur à", + "data.kueryAutocomplete.lessThanOrEqualOperatorDescription": "est {lessThanOrEqualTo} une certaine valeur", + "data.kueryAutocomplete.lessThanOrEqualOperatorDescription.lessThanOrEqualToText": "inférieur ou égal à", + "data.kueryAutocomplete.orOperatorDescription": "Nécessite qu’{oneOrMoreArguments} soit ''vrai''.", + "data.kueryAutocomplete.orOperatorDescription.oneOrMoreArgumentsText": "au moins un argument", + "data.noDataPopover.content": "Cette plage temporelle ne contient pas de données. Étendez ou ajustez la plage temporelle pour obtenir plus de champs et pouvoir créer des graphiques.", + "data.noDataPopover.dismissAction": "Ne plus afficher", + "data.noDataPopover.subtitle": "Conseil", + "data.noDataPopover.title": "Ensemble de données vide", + "data.painlessError.buttonTxt": "Modifier le script", + "data.painlessError.painlessScriptedFieldErrorMessage": "Erreur d'exécution du champ d'exécution ou du champ scripté sur le modèle d'indexation {indexPatternName}", + "data.parseEsInterval.invalidEsCalendarIntervalErrorMessage": "Intervalle de calendrier non valide : {interval} ; la valeur doit être 1.", + "data.parseEsInterval.invalidEsIntervalFormatErrorMessage": "Format d'intervalle non valide : {interval}", + "data.query.queryBar.clearInputLabel": "Effacer l'entrée", + "data.query.queryBar.comboboxAriaLabel": "Rechercher et filtrer la page {pageType}", + "data.query.queryBar.kqlFullLanguageName": "Langage de requête Kibana", + "data.query.queryBar.kqlLanguageName": "KQL", + "data.query.queryBar.KQLNestedQuerySyntaxInfoDocLinkText": "documents", + "data.query.queryBar.KQLNestedQuerySyntaxInfoOptOutText": "Ne plus afficher", + "data.query.queryBar.KQLNestedQuerySyntaxInfoText": "Il semblerait que votre requête porte sur un champ imbriqué. Selon le résultat visé, il existe plusieurs façons de construire une syntaxe KQL pour des requêtes imbriquées. Apprenez-en plus avec notre {link}.", + "data.query.queryBar.KQLNestedQuerySyntaxInfoTitle": "Syntaxe de requête imbriquée KQL", + "data.query.queryBar.kqlOffLabel": "Désactivé", + "data.query.queryBar.kqlOnLabel": "Activé", + "data.query.queryBar.languageSwitcher.toText": "Passer au langage de requête Kibana pour la recherche", + "data.query.queryBar.luceneLanguageName": "Lucene", + "data.query.queryBar.searchInputAriaLabel": "Commencer à taper pour rechercher et filtrer la page {pageType}", + "data.query.queryBar.searchInputPlaceholder": "Recherche", + "data.query.queryBar.syntaxOptionsDescription": "{docsLink} (KQL) offre une syntaxe de requête simplifiée et la prise en charge des champs scriptés. KQL offre également une fonctionnalité de saisie semi-automatique. Si vous désactivez KQL, {nonKqlModeHelpText}.", + "data.query.queryBar.syntaxOptionsDescription.nonKqlModeHelpText": "Kibana utilise Lucene.", + "data.query.queryBar.syntaxOptionsTitle": "Options de syntaxe", + "data.search.aggs.aggGroups.bucketsText": "Compartiments", + "data.search.aggs.aggGroups.metricsText": "Indicateurs", + "data.search.aggs.aggGroups.noneText": "Aucune", + "data.search.aggs.aggTypesLabel": "plages {fieldName}", + "data.search.aggs.buckets.dateHistogram.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.buckets.dateHistogram.dropPartials.help": "Spécifie l'utilisation ou non de drop_partials pour cette agrégation.", + "data.search.aggs.buckets.dateHistogram.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.buckets.dateHistogram.extendedBounds.help": "Avec le paramètre extended_bounds, il est désormais possible de \"forcer\" l'agrégation d'histogrammes à démarrer la conception des compartiments sur une valeur minimale spécifique et à continuer jusqu'à une valeur maximale. ", + "data.search.aggs.buckets.dateHistogram.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.buckets.dateHistogram.format.help": "Format à utiliser pour cette agrégation", + "data.search.aggs.buckets.dateHistogram.id.help": "ID pour cette agrégation", + "data.search.aggs.buckets.dateHistogram.interval.help": "Intervalle à utiliser pour cette agrégation", + "data.search.aggs.buckets.dateHistogram.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.buckets.dateHistogram.minDocCount.help": "Nombre minimal de documents à utiliser pour cette agrégation", + "data.search.aggs.buckets.dateHistogram.scaleMetricValues.help": "Spécifie l'utilisation ou non de scaleMetricValues pour cette agrégation.", + "data.search.aggs.buckets.dateHistogram.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.buckets.dateHistogram.timeRange.help": "Plage temporelle à utiliser pour cette agrégation", + "data.search.aggs.buckets.dateHistogram.timeZone.help": "Fuseau horaire à utiliser pour cette agrégation", + "data.search.aggs.buckets.dateHistogram.useNormalizedEsInterval.help": "Spécifie l'utilisation ou non de useNormalizedEsInterval pour cette agrégation.", + "data.search.aggs.buckets.dateHistogramLabel": "{fieldName} par {intervalDescription}", + "data.search.aggs.buckets.dateHistogramTitle": "Histogramme de date", + "data.search.aggs.buckets.dateRange.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.buckets.dateRange.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.buckets.dateRange.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.buckets.dateRange.id.help": "ID pour cette agrégation", + "data.search.aggs.buckets.dateRange.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.buckets.dateRange.ranges.help": "Plages à utiliser pour cette agrégation.", + "data.search.aggs.buckets.dateRange.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.buckets.dateRange.timeZone.help": "Fuseau horaire à utiliser pour cette agrégation.", + "data.search.aggs.buckets.dateRangeTitle": "Plage de dates", + "data.search.aggs.buckets.filter.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.buckets.filter.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.buckets.filter.filter.help": "Pour filtrer les résultats en fonction d’une requête KQL ou Lucene. Ne pas utiliser en association avec geo_bounding_box.", + "data.search.aggs.buckets.filter.geoBoundingBox.help": "Pour filtrer les résultats en fonction d’une localisation au sein d’une zone de délimitation", + "data.search.aggs.buckets.filter.id.help": "ID pour cette agrégation", + "data.search.aggs.buckets.filter.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.buckets.filter.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.buckets.filters.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.buckets.filters.filters.help": "Filtres à utiliser pour cette agrégation", + "data.search.aggs.buckets.filters.id.help": "ID pour cette agrégation", + "data.search.aggs.buckets.filters.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.buckets.filters.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.buckets.filtersTitle": "Filtres", + "data.search.aggs.buckets.filterTitle": "Filtre", + "data.search.aggs.buckets.geoHash.autoPrecision.help": "Spécifie l'utilisation ou non de la précision automatique pour cette agrégation.", + "data.search.aggs.buckets.geoHash.boundingBox.help": "Pour filtrer les résultats en fonction d’une localisation au sein d’une zone de délimitation", + "data.search.aggs.buckets.geoHash.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.buckets.geoHash.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.buckets.geoHash.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.buckets.geoHash.id.help": "ID pour cette agrégation", + "data.search.aggs.buckets.geoHash.isFilteredByCollar.help": "Spécifie le filtrage ou non par collier.", + "data.search.aggs.buckets.geoHash.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.buckets.geoHash.precision.help": "Précision à utiliser pour cette agrégation.", + "data.search.aggs.buckets.geoHash.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.buckets.geoHash.useGeocentroid.help": "Spécifie l'utilisation ou non d’un centroïde géométrique pour cette agrégation.", + "data.search.aggs.buckets.geohashGridTitle": "Geohash", + "data.search.aggs.buckets.geoTile.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.buckets.geoTile.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.buckets.geoTile.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.buckets.geoTile.id.help": "ID pour cette agrégation", + "data.search.aggs.buckets.geoTile.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.buckets.geoTile.precision.help": "Précision à utiliser pour cette agrégation.", + "data.search.aggs.buckets.geoTile.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.buckets.geoTile.useGeocentroid.help": "Spécifie l'utilisation ou non d’un centroïde géométrique pour cette agrégation.", + "data.search.aggs.buckets.geotileGridTitle": "Geotile", + "data.search.aggs.buckets.histogram.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.buckets.histogram.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.buckets.histogram.extendedBounds.help": "Avec le paramètre extended_bounds, il est désormais possible de \"forcer\" l'agrégation d'histogrammes à démarrer la conception des compartiments sur une valeur minimale spécifique et à continuer jusqu'à une valeur maximale. ", + "data.search.aggs.buckets.histogram.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.buckets.histogram.hasExtendedBounds.help": "Spécifie l'utilisation ou non de has_extended_bounds pour cette agrégation.", + "data.search.aggs.buckets.histogram.id.help": "ID pour cette agrégation", + "data.search.aggs.buckets.histogram.interval.help": "Intervalle à utiliser pour cette agrégation", + "data.search.aggs.buckets.histogram.intervalBase.help": "Intervalle de base à utiliser pour cette agrégation", + "data.search.aggs.buckets.histogram.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.buckets.histogram.maxBars.help": "Calcule l'intervalle pour obtenir approximativement le nombre de barres spécifié.", + "data.search.aggs.buckets.histogram.minDocCount.help": "Spécifie l'utilisation ou non de min_doc_count pour cette agrégation.", + "data.search.aggs.buckets.histogram.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.buckets.histogramTitle": "Histogramme", + "data.search.aggs.buckets.intervalOptions.autoDisplayName": "Auto", + "data.search.aggs.buckets.intervalOptions.dailyDisplayName": "Jour", + "data.search.aggs.buckets.intervalOptions.hourlyDisplayName": "Heure", + "data.search.aggs.buckets.intervalOptions.millisecondDisplayName": "Milliseconde", + "data.search.aggs.buckets.intervalOptions.minuteDisplayName": "Minute", + "data.search.aggs.buckets.intervalOptions.monthlyDisplayName": "Mois", + "data.search.aggs.buckets.intervalOptions.secondDisplayName": "Seconde", + "data.search.aggs.buckets.intervalOptions.weeklyDisplayName": "Semaine", + "data.search.aggs.buckets.intervalOptions.yearlyDisplayName": "Année", + "data.search.aggs.buckets.ipRange.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.buckets.ipRange.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.buckets.ipRange.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.buckets.ipRange.id.help": "ID pour cette agrégation", + "data.search.aggs.buckets.ipRange.ipRangeType.help": "Type de plage d'IP à utiliser pour cette agrégation. Doit être l’une des valeurs suivantes : mask, fromTo.", + "data.search.aggs.buckets.ipRange.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.buckets.ipRange.ranges.help": "Plages à utiliser pour cette agrégation.", + "data.search.aggs.buckets.ipRange.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.buckets.ipRangeLabel": "Plages d'IP de {fieldName}", + "data.search.aggs.buckets.ipRangeTitle": "Plage d'IP", + "data.search.aggs.buckets.range.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.buckets.range.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.buckets.range.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.buckets.range.id.help": "ID pour cette agrégation", + "data.search.aggs.buckets.range.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.buckets.range.ranges.help": "Plages en série à utiliser pour cette agrégation.", + "data.search.aggs.buckets.range.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.buckets.rangeTitle": "Plage", + "data.search.aggs.buckets.shardDelay.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.buckets.shardDelay.delay.help": "Délai entre les partitions à traiter. Exemple : \"5s\".", + "data.search.aggs.buckets.shardDelay.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.buckets.shardDelay.id.help": "ID pour cette agrégation", + "data.search.aggs.buckets.shardDelay.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.buckets.shardDelay.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.buckets.significantTerms.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.buckets.significantTerms.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.buckets.significantTerms.exclude.help": "Valeurs de compartiment spécifiques à exclure des résultats", + "data.search.aggs.buckets.significantTerms.excludeLabel": "Exclure", + "data.search.aggs.buckets.significantTerms.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.buckets.significantTerms.id.help": "ID pour cette agrégation", + "data.search.aggs.buckets.significantTerms.include.help": "Valeurs de compartiment spécifiques à inclure dans les résultats", + "data.search.aggs.buckets.significantTerms.includeLabel": "Inclure", + "data.search.aggs.buckets.significantTerms.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.buckets.significantTerms.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.buckets.significantTerms.size.help": "Nombre maximal de compartiments à extraire", + "data.search.aggs.buckets.significantTermsLabel": "Top {size} des termes les plus inhabituels pour {fieldName}", + "data.search.aggs.buckets.significantTermsTitle": "Termes importants", + "data.search.aggs.buckets.terms.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.buckets.terms.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.buckets.terms.exclude.help": "Valeurs de compartiment spécifiques à exclure des résultats", + "data.search.aggs.buckets.terms.excludeLabel": "Exclure", + "data.search.aggs.buckets.terms.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.buckets.terms.id.help": "ID pour cette agrégation", + "data.search.aggs.buckets.terms.include.help": "Valeurs de compartiment spécifiques à inclure dans les résultats", + "data.search.aggs.buckets.terms.includeLabel": "Inclure", + "data.search.aggs.buckets.terms.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.buckets.terms.missingBucket.help": "Lorsqu'il est défini sur ''vrai'', ce paramètre regroupe tous les compartiments avec des champs manquants.", + "data.search.aggs.buckets.terms.missingBucketLabel": "Manquant", + "data.search.aggs.buckets.terms.missingBucketLabel.help": "Étiquette par défaut utilisée dans les graphiques lorsqu'il manque un champ aux documents.", + "data.search.aggs.buckets.terms.order.help": "Ordre dans lequel renvoyer les résultats : croissant ou décroissant", + "data.search.aggs.buckets.terms.orderAgg.help": "Configuration d'agrégation à utiliser pour ordonner les résultats", + "data.search.aggs.buckets.terms.orderAscendingTitle": "Croissant", + "data.search.aggs.buckets.terms.orderBy.help": "Champ selon lequel ordonner les résultats", + "data.search.aggs.buckets.terms.orderDescendingTitle": "Décroissant", + "data.search.aggs.buckets.terms.otherBucket.help": "Lorsqu'il est défini sur ''vrai'', ce paramètre regroupe tous les compartiments au-delà de la taille autorisée.", + "data.search.aggs.buckets.terms.otherBucketDescription": "Cette requête comptabilise le nombre de documents qui ne répondent pas au critère des compartiments de données.", + "data.search.aggs.buckets.terms.otherBucketLabel": "Autre", + "data.search.aggs.buckets.terms.otherBucketLabel.help": "Étiquette par défaut utilisée dans les graphiques pour les documents du compartiment Autre", + "data.search.aggs.buckets.terms.otherBucketTitle": "Compartiment Autre", + "data.search.aggs.buckets.terms.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.buckets.terms.size.help": "Nombre maximal de compartiments à extraire", + "data.search.aggs.buckets.termsTitle": "Termes", + "data.search.aggs.error.aggNotFound": "Aucun type d'agrégation enregistré pour \"{type}\".", + "data.search.aggs.function.buckets.dateHistogram.help": "Génère une configuration d'agrégation en série pour une agrégation Histogramme.", + "data.search.aggs.function.buckets.dateRange.help": "Génère une configuration d'agrégation en série pour une agrégation Plage de dates.", + "data.search.aggs.function.buckets.filter.help": "Génère une configuration d'agrégation en série pour une agrégation Filtre.", + "data.search.aggs.function.buckets.filters.help": "Génère une configuration d'agrégation en série pour une agrégation Filtre.", + "data.search.aggs.function.buckets.geoHash.help": "Génère une configuration d'agrégation en série pour une agrégation Geohash.", + "data.search.aggs.function.buckets.geoTile.help": "Génère une configuration d'agrégation en série pour une agrégation Geotile.", + "data.search.aggs.function.buckets.histogram.help": "Génère une configuration d'agrégation en série pour une agrégation Histogramme.", + "data.search.aggs.function.buckets.ipRange.help": "Génère une configuration d'agrégation en série pour une agrégation Plage d'IP.", + "data.search.aggs.function.buckets.range.help": "Génère une configuration d'agrégation en série pour une agrégation Plage.", + "data.search.aggs.function.buckets.shardDelay.help": "Génère une configuration d'agrégation en série pour une agrégation Délai de partition.", + "data.search.aggs.function.buckets.significantTerms.help": "Génère une configuration d'agrégation en série pour une agrégation Termes importants.", + "data.search.aggs.function.buckets.terms.help": "Génère une configuration d'agrégation en série pour une agrégation Termes.", + "data.search.aggs.function.metrics.avg.help": "Génère une configuration d'agrégation en série pour une agrégation Moyenne.", + "data.search.aggs.function.metrics.bucket_avg.help": "Génère une configuration d'agrégation en série pour une agrégation Moyenne compartiment.", + "data.search.aggs.function.metrics.bucket_max.help": "Génère une configuration d'agrégation en série pour une agrégation Max. compartiment.", + "data.search.aggs.function.metrics.bucket_min.help": "Génère une configuration d'agrégation en série pour une agrégation Min. compartiment.", + "data.search.aggs.function.metrics.bucket_sum.help": "Génère une configuration d'agrégation en série pour une agrégation Somme compartiment.", + "data.search.aggs.function.metrics.cardinality.help": "Génère une configuration d'agrégation en série pour une agrégation Cardinalité.", + "data.search.aggs.function.metrics.count.help": "Génère une configuration d'agrégation en série pour une agrégation Décompte.", + "data.search.aggs.function.metrics.cumulative_sum.help": "Génère une configuration d'agrégation en série pour une agrégation Somme cumulée.", + "data.search.aggs.function.metrics.derivative.help": "Génère une configuration d'agrégation en série pour une agrégation Dérivée.", + "data.search.aggs.function.metrics.filtered_metric.help": "Génère une configuration d'agrégation en série pour une agrégation Indicateur filtré.", + "data.search.aggs.function.metrics.geo_bounds.help": "Génère une configuration d'agrégation en série pour une agrégation Délimitation géométrique.", + "data.search.aggs.function.metrics.geo_centroid.help": "Génère une configuration d'agrégation en série pour une agrégation Centroïde géométrique.", + "data.search.aggs.function.metrics.max.help": "Génère une configuration d'agrégation en série pour une agrégation Max.", + "data.search.aggs.function.metrics.median.help": "Génère une configuration d'agrégation en série pour une agrégation Médiane.", + "data.search.aggs.function.metrics.min.help": "Génère une configuration d'agrégation en série pour une agrégation Min.", + "data.search.aggs.function.metrics.moving_avg.help": "Génère une configuration d'agrégation en série pour une agrégation Moyenne mobile.", + "data.search.aggs.function.metrics.percentile_ranks.help": "Génère une configuration d'agrégation en série pour une agrégation Rangs centiles.", + "data.search.aggs.function.metrics.percentiles.help": "Génère une configuration d'agrégation en série pour une agrégation Centiles.", + "data.search.aggs.function.metrics.serial_diff.help": "Génère une configuration d'agrégation en série pour une agrégation Différenciation en série.", + "data.search.aggs.function.metrics.singlePercentile.help": "Génère une configuration d'agrégation en série pour une agrégation Centile unique.", + "data.search.aggs.function.metrics.std_deviation.help": "Génère une configuration d'agrégation en série pour une agrégation Écart-type.", + "data.search.aggs.function.metrics.sum.help": "Génère une configuration d'agrégation en série pour une agrégation Somme.", + "data.search.aggs.function.metrics.top_hit.help": "Génère une configuration d'agrégation en série pour une agrégation Meilleur résultat.", + "data.search.aggs.histogram.missingMaxMinValuesWarning": "Impossible d’extraire les valeurs max. et min. pour scaler automatiquement les compartiments de l'histogramme. Cela peut entraîner des performances de visualisation médiocres.", + "data.search.aggs.metrics.averageBucketTitle": "Moyenne compartiment", + "data.search.aggs.metrics.averageLabel": "Moyenne {field}", + "data.search.aggs.metrics.averageTitle": "Moyenne", + "data.search.aggs.metrics.avg.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.avg.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.avg.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.metrics.avg.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.avg.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.avg.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.bucket_avg.customBucket.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines enfants", + "data.search.aggs.metrics.bucket_avg.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.bucket_avg.customMetric.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines enfants", + "data.search.aggs.metrics.bucket_avg.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.bucket_avg.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.bucket_avg.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.bucket_avg.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.bucket_max.customBucket.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines enfants", + "data.search.aggs.metrics.bucket_max.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.bucket_max.customMetric.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines enfants", + "data.search.aggs.metrics.bucket_max.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.bucket_max.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.bucket_max.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.bucket_max.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.bucket_min.customBucket.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines enfants", + "data.search.aggs.metrics.bucket_min.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.bucket_min.customMetric.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines enfants", + "data.search.aggs.metrics.bucket_min.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.bucket_min.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.bucket_min.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.bucket_min.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.bucket_sum.customBucket.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines enfants", + "data.search.aggs.metrics.bucket_sum.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.bucket_sum.customMetric.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines enfants", + "data.search.aggs.metrics.bucket_sum.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.bucket_sum.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.bucket_sum.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.bucket_sum.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.cardinality.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.cardinality.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.cardinality.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.metrics.cardinality.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.cardinality.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.cardinality.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.count.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.count.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.count.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.count.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.countLabel": "Décompte", + "data.search.aggs.metrics.countTitle": "Décompte", + "data.search.aggs.metrics.cumulative_sum.buckets_path.help": "Chemin d’accès à l'indicateur d’intérêt", + "data.search.aggs.metrics.cumulative_sum.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.cumulative_sum.customMetric.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines parents", + "data.search.aggs.metrics.cumulative_sum.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.cumulative_sum.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.cumulative_sum.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.cumulative_sum.metricAgg.help": "ID correspondant à la configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines parents", + "data.search.aggs.metrics.cumulative_sum.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.cumulativeSumLabel": "somme cumulée", + "data.search.aggs.metrics.cumulativeSumTitle": "Somme cumulée", + "data.search.aggs.metrics.derivative.buckets_path.help": "Chemin d’accès à l'indicateur d’intérêt", + "data.search.aggs.metrics.derivative.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.derivative.customMetric.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines parents", + "data.search.aggs.metrics.derivative.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.derivative.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.derivative.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.derivative.metricAgg.help": "ID correspondant à la configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines parents", + "data.search.aggs.metrics.derivative.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.derivativeLabel": "dérivée", + "data.search.aggs.metrics.derivativeTitle": "Dérivée", + "data.search.aggs.metrics.filtered_metric.customBucket.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines enfants. Doit être une agrégation de filtres.", + "data.search.aggs.metrics.filtered_metric.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.filtered_metric.customMetric.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines enfants", + "data.search.aggs.metrics.filtered_metric.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.filtered_metric.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.filtered_metric.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.filteredMetricLabel": "filtré", + "data.search.aggs.metrics.filteredMetricTitle": "Indicateur filtré", + "data.search.aggs.metrics.geo_bounds.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.geo_bounds.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.geo_bounds.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.metrics.geo_bounds.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.geo_bounds.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.geo_bounds.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.geo_centroid.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.geo_centroid.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.geo_centroid.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.metrics.geo_centroid.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.geo_centroid.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.geo_centroid.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.geoBoundsLabel": "Délimitation géométrique", + "data.search.aggs.metrics.geoBoundsTitle": "Délimitation géométrique", + "data.search.aggs.metrics.geoCentroidLabel": "Centroïde géométrique", + "data.search.aggs.metrics.geoCentroidTitle": "Centroïde géométrique", + "data.search.aggs.metrics.max.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.max.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.max.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.metrics.max.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.max.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.max.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.maxBucketTitle": "Max. compartiment", + "data.search.aggs.metrics.maxLabel": "Max. {field}", + "data.search.aggs.metrics.maxTitle": "Max.", + "data.search.aggs.metrics.median.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.median.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.median.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.metrics.median.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.median.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.median.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.medianLabel": "Médiane {field}", + "data.search.aggs.metrics.medianTitle": "Médiane", + "data.search.aggs.metrics.metricAggregationsSubtypeTitle": "Agrégations d'indicateurs", + "data.search.aggs.metrics.min.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.min.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.min.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.metrics.min.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.min.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.min.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.minBucketTitle": "Min. compartiment", + "data.search.aggs.metrics.minLabel": "Min. {field}", + "data.search.aggs.metrics.minTitle": "Min.", + "data.search.aggs.metrics.moving_avg.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.moving_avg.customMetric.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines parents", + "data.search.aggs.metrics.moving_avg.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.moving_avg.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.moving_avg.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.moving_avg.metricAgg.help": "ID correspondant à la configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines parents", + "data.search.aggs.metrics.moving_avg.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.moving_avg.script.help": "ID correspondant à la configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines parents", + "data.search.aggs.metrics.moving_avg.window.help": "La taille de la fenêtre à \"faire glisser\" le long de l'histogramme.", + "data.search.aggs.metrics.movingAvgLabel": "moyenne mobile", + "data.search.aggs.metrics.movingAvgTitle": "Moyenne mobile", + "data.search.aggs.metrics.overallAverageLabel": "moyenne générale", + "data.search.aggs.metrics.overallMaxLabel": "max. général", + "data.search.aggs.metrics.overallMinLabel": "min. général", + "data.search.aggs.metrics.overallSumLabel": "somme générale", + "data.search.aggs.metrics.parentPipelineAggregationsSubtypeTitle": "Agrégations de pipelines parents", + "data.search.aggs.metrics.percentile_ranks.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.percentile_ranks.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.percentile_ranks.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.metrics.percentile_ranks.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.percentile_ranks.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.percentile_ranks.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.percentile_ranks.values.help": "Plage de rangs centiles", + "data.search.aggs.metrics.percentileRanks.valuePropsLabel": "Rang centile {format} de \"{label}\"", + "data.search.aggs.metrics.percentileRanksLabel": "Rangs centiles de {field}", + "data.search.aggs.metrics.percentileRanksTitle": "Rangs centiles", + "data.search.aggs.metrics.percentiles.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.percentiles.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.percentiles.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.metrics.percentiles.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.percentiles.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.percentiles.percents.help": "Plage de rangs centiles", + "data.search.aggs.metrics.percentiles.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.percentiles.valuePropsLabel": "{percentile} centile de {label}", + "data.search.aggs.metrics.percentilesLabel": "Centiles de {field}", + "data.search.aggs.metrics.percentilesTitle": "Centiles", + "data.search.aggs.metrics.serial_diff.buckets_path.help": "Chemin d’accès à l'indicateur d’intérêt", + "data.search.aggs.metrics.serial_diff.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.serial_diff.customMetric.help": "Configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines parents", + "data.search.aggs.metrics.serial_diff.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.serial_diff.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.serial_diff.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.serial_diff.metricAgg.help": "ID correspondant à la configuration d'agrégation à utiliser pour la conception d'agrégations de pipelines parents", + "data.search.aggs.metrics.serial_diff.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.serialDiffLabel": "différenciation en série", + "data.search.aggs.metrics.serialDiffTitle": "Différenciation en série", + "data.search.aggs.metrics.siblingPipelineAggregationsSubtypeTitle": "Agrégations de pipelines enfants", + "data.search.aggs.metrics.singlePercentile.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.singlePercentile.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.singlePercentile.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.metrics.singlePercentile.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.singlePercentile.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.singlePercentile.percentile.help": "Centile à récupérer", + "data.search.aggs.metrics.singlePercentile.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.singlePercentileLabel": "Centile {field}", + "data.search.aggs.metrics.singlePercentileTitle": "Centile", + "data.search.aggs.metrics.standardDeviation.keyDetailsLabel": "Écart-type de {fieldDisplayName}", + "data.search.aggs.metrics.standardDeviation.lowerKeyDetailsTitle": "{label} inférieur", + "data.search.aggs.metrics.standardDeviation.upperKeyDetailsTitle": "{label} supérieur", + "data.search.aggs.metrics.standardDeviationLabel": "Écart-type de {field}", + "data.search.aggs.metrics.standardDeviationTitle": "Écart-type", + "data.search.aggs.metrics.std_deviation.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.std_deviation.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.std_deviation.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.metrics.std_deviation.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.std_deviation.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.std_deviation.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.sum.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.sum.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.sum.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.metrics.sum.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.sum.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.sum.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.sumBucketTitle": "Somme compartiment", + "data.search.aggs.metrics.sumLabel": "Somme de {field}", + "data.search.aggs.metrics.sumTitle": "Somme", + "data.search.aggs.metrics.timeShift.help": "Décalez la plage temporelle de l'indicateur d'une durée définie, par exemple 1 h ou 7 j. \"précédent\" utilisera la plage temporelle la plus proche du filtre d'histogramme de date ou de plage temporelle.", + "data.search.aggs.metrics.top_hit.aggregate.help": "Agréger le type", + "data.search.aggs.metrics.top_hit.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", + "data.search.aggs.metrics.top_hit.enabled.help": "Spécifie si cette agrégation doit être activée.", + "data.search.aggs.metrics.top_hit.field.help": "Champ à utiliser pour cette agrégation", + "data.search.aggs.metrics.top_hit.id.help": "ID pour cette agrégation", + "data.search.aggs.metrics.top_hit.json.help": "Json avancé à inclure lorsque l'agrégation est envoyée vers Elasticsearch", + "data.search.aggs.metrics.top_hit.schema.help": "Schéma à utiliser pour cette agrégation", + "data.search.aggs.metrics.top_hit.size.help": "Nombre maximal de compartiments à extraire", + "data.search.aggs.metrics.top_hit.sortField.help": "Champ selon lequel ordonner les résultats", + "data.search.aggs.metrics.top_hit.sortOrder.help": "Ordre dans lequel renvoyer les résultats : croissant ou décroissant", + "data.search.aggs.metrics.topHit.ascendingLabel": "Croissant", + "data.search.aggs.metrics.topHit.averageLabel": "Moyenne", + "data.search.aggs.metrics.topHit.concatenateLabel": "Concaténer", + "data.search.aggs.metrics.topHit.descendingLabel": "Décroissant", + "data.search.aggs.metrics.topHit.firstPrefixLabel": "Premier", + "data.search.aggs.metrics.topHit.lastPrefixLabel": "Dernier", + "data.search.aggs.metrics.topHit.maxLabel": "Max.", + "data.search.aggs.metrics.topHit.minLabel": "Min.", + "data.search.aggs.metrics.topHit.sumLabel": "Somme", + "data.search.aggs.metrics.topHitTitle": "Meilleur résultat", + "data.search.aggs.metrics.uniqueCountLabel": "Décompte unique de {field}", + "data.search.aggs.metrics.uniqueCountTitle": "Décompte unique", + "data.search.aggs.otherBucket.labelForMissingValuesLabel": "Étiquette pour des valeurs manquantes", + "data.search.aggs.otherBucket.labelForOtherBucketLabel": "Étiquette pour autre compartiment", + "data.search.aggs.paramTypes.field.invalidSavedFieldParameterErrorMessage": "Le champ enregistré \"{fieldParameter}\" du modèle d'indexation \"{indexPatternTitle}\" n'est pas valide pour une utilisation avec l'agrégation \"{aggType}\". Veuillez sélectionner un nouveau champ.", + "data.search.aggs.paramTypes.field.notFoundSavedFieldParameterErrorMessage": "Le champ \"{fieldParameter}\" associé à cet objet n'existe plus dans le modèle d'indexation. Veuillez utiliser un autre champ.", + "data.search.aggs.paramTypes.field.requiredFieldParameterErrorMessage": "{fieldParameter} est un paramètre requis.", + "data.search.aggs.percentageOfLabel": "Pourcentage de {label}", + "data.search.aggs.string.customLabel": "Étiquette personnalisée", + "data.search.dataRequest.title": "Données", + "data.search.es_search.dataRequest.description": "Cette requête interroge Elasticsearch pour récupérer les données pour la visualisation.", + "data.search.es_search.hitsDescription": "Le nombre de documents renvoyés par la requête.", + "data.search.es_search.hitsLabel": "Résultats", + "data.search.es_search.hitsTotalDescription": "Le nombre de documents correspondant à la requête.", + "data.search.es_search.hitsTotalLabel": "Résultats (total)", + "data.search.es_search.indexPatternDescription": "Le modèle d'indexation qui se connecte aux index Elasticsearch.", + "data.search.es_search.queryTimeDescription": "Le temps qu'il a fallu pour traiter la requête. Ne comprend pas le temps nécessaire pour envoyer la requête ni l'analyser dans le navigateur.", + "data.search.es_search.queryTimeLabel": "Durée de la requête", + "data.search.es_search.queryTimeValue": "{queryTime}ms", + "data.search.esaggs.error.kibanaRequest": "Une requête Kibana est nécessaire pour exécuter cette recherche sur le serveur. Veuillez fournir un objet de requête pour les paramètres d'exécution de l'expression.", + "data.search.esdsl.help": "Exécuter une requête Elasticsearch", + "data.search.esdsl.index.help": "Index Elasticsearch à interroger", + "data.search.esdsl.q.help": "Requête DSL", + "data.search.esdsl.size.help": "Paramètre de taille de l’API de recherche d’Elasticsearch", + "data.search.esErrorTitle": "Impossible d’extraire les résultats de recherche", + "data.search.functions.cidr.cidr.help": "Spécifier le bloc CIDR", + "data.search.functions.cidr.help": "Créer une plage CIDR", + "data.search.functions.dateRange.from.help": "Spécifier la date de début", + "data.search.functions.dateRange.help": "Créer une plage de dates", + "data.search.functions.dateRange.to.help": "Spécifier la date de fin", + "data.search.functions.esaggs.aggConfigs.help": "Liste des agrégations configurées avec des fonctions agg_type", + "data.search.functions.esaggs.index.help": "Modèle d'indexation extrait avec indexPatternLoad", + "data.search.functions.esaggs.metricsAtAllLevels.help": "Spécifie l’inclusion ou non des colonnes avec indicateurs pour chaque niveau de compartiment.", + "data.search.functions.esaggs.partialRows.help": "Détermine s'il faut renvoyer ou non les lignes ne contenant que des données partielles.", + "data.search.functions.esaggs.timeFields.help": "Spécifiez des champs temporels afin d’obtenir les plages temporelles résolues pour la requête.", + "data.search.functions.existsFilter.field.help": "Spécifiez le champ que vous souhaitez filtrer. Utilisez la fonction ''field''.", + "data.search.functions.existsFilter.help": "Créer un filtre Kibana existant", + "data.search.functions.existsFilter.negate.help": "Si le filtre doit être inversé.", + "data.search.functions.extendedBounds.help": "Créer des limites étendues", + "data.search.functions.extendedBounds.max.help": "Spécifier la valeur de la limite supérieure", + "data.search.functions.extendedBounds.min.help": "Spécifier la valeur de la limite inférieure", + "data.search.functions.field.help": "Créer un champ Kibana", + "data.search.functions.field.name.help": "Nom du champ", + "data.search.functions.field.script.help": "Script de champ, au cas où le champ serait scripté.", + "data.search.functions.field.type.help": "Type du champ", + "data.search.functions.geoBoundingBox.arguments.error": "Au moins un des groupes de paramètres suivants doit être fourni : {parameters}.", + "data.search.functions.geoBoundingBox.bottom_left.help": "Spécifier l’angle inférieur gauche", + "data.search.functions.geoBoundingBox.bottom_right.help": "Spécifier l’angle inférieur droit", + "data.search.functions.geoBoundingBox.bottom.help": "Spécifier la coordonnée inférieure", + "data.search.functions.geoBoundingBox.help": "Créer une zone de délimitation géométrique", + "data.search.functions.geoBoundingBox.left.help": "Spécifier la coordonnée gauche", + "data.search.functions.geoBoundingBox.right.help": "Spécifier la coordonnée droite", + "data.search.functions.geoBoundingBox.top_left.help": "Spécifier l’angle supérieur gauche", + "data.search.functions.geoBoundingBox.top_right.help": "Spécifier l’angle supérieur droit", + "data.search.functions.geoBoundingBox.top.help": "Spécifier la coordonnée supérieure", + "data.search.functions.geoBoundingBox.wkt.help": "Spécifier le texte bien connu (WKT)", + "data.search.functions.geoPoint.arguments.error": "Les paramètres \"lat\" et \"lon\" ou \"point\" doivent être spécifiés.", + "data.search.functions.geoPoint.help": "Créer un point géographique", + "data.search.functions.geoPoint.lat.help": "Spécifier la latitude", + "data.search.functions.geoPoint.lon.help": "Spécifier la longitude", + "data.search.functions.geoPoint.point.error": "Le paramètre du point doit être une chaîne ou deux valeurs numériques.", + "data.search.functions.geoPoint.point.help": "Spécifiez le point sous la forme d’une chaîne de coordonnées séparées par des virgules ou sous la forme de deux valeurs numériques.", + "data.search.functions.ipRange.from.help": "Spécifier l'adresse de début", + "data.search.functions.ipRange.help": "Créer une plage d'IP", + "data.search.functions.ipRange.to.help": "Spécifier l'adresse de fin", + "data.search.functions.kibana_context.filters.help": "Spécifier des filtres génériques Kibana", + "data.search.functions.kibana_context.help": "Met à jour le contexte général de Kibana.", + "data.search.functions.kibana_context.q.help": "Spécifier une recherche en texte libre Kibana", + "data.search.functions.kibana_context.savedSearchId.help": "Spécifier l'ID de recherche enregistrée à utiliser pour les requêtes et les filtres", + "data.search.functions.kibana_context.timeRange.help": "Spécifier le filtre de plage temporelle Kibana", + "data.search.functions.kibana.help": "Permet d’obtenir le contexte général de Kibana.", + "data.search.functions.kibanaFilter.disabled.help": "Si le filtre doit être désactivé", + "data.search.functions.kibanaFilter.field.help": "Spécifier une recherche en texte libre esdsl", + "data.search.functions.kibanaFilter.help": "Créer un filtre Kibana", + "data.search.functions.kibanaFilter.negate.help": "Si le filtre doit être inversé", + "data.search.functions.kql.help": "Créer une requête KQL Kibana", + "data.search.functions.kql.q.help": "Spécifier une recherche en texte libre KQL Kibana", + "data.search.functions.lucene.help": "Créer une requête Lucene Kibana", + "data.search.functions.lucene.q.help": "Spécifier une recherche en texte libre Lucene", + "data.search.functions.numericalRange.from.help": "Spécifier la valeur de début", + "data.search.functions.numericalRange.help": "Créer une plage numérique", + "data.search.functions.numericalRange.label.help": "Spécifier l'étiquette de la plage", + "data.search.functions.numericalRange.to.help": "Spécifier la valeur de fin", + "data.search.functions.phraseFilter.field.help": "Spécifiez le champ que vous souhaitez filtrer. Utilisez la fonction ''field''.", + "data.search.functions.phraseFilter.help": "Créer un filtre d’expression Kibana", + "data.search.functions.phraseFilter.negate.help": "Si le filtre doit être inversé", + "data.search.functions.phraseFilter.phrase.help": "Spécifier l'expression", + "data.search.functions.queryFilter.help": "Créer un filtre de requête", + "data.search.functions.queryFilter.input.help": "Spécifier le filtre de requête", + "data.search.functions.queryFilter.label.help": "Spécifier l'étiquette du filtre", + "data.search.functions.range.gt.help": "Supérieur à", + "data.search.functions.range.gte.help": "Supérieur ou égal à", + "data.search.functions.range.help": "Créer un filtre de plage Kibana", + "data.search.functions.range.lt.help": "Inférieur à", + "data.search.functions.range.lte.help": "Inférieur ou égal à", + "data.search.functions.rangeFilter.field.help": "Spécifiez le champ que vous souhaitez filtrer. Utilisez la fonction ''field''.", + "data.search.functions.rangeFilter.help": "Créer un filtre de plage Kibana", + "data.search.functions.rangeFilter.negate.help": "Si le filtre doit être inversé", + "data.search.functions.rangeFilter.range.help": "Spécifiez la plage à l’aide de la fonction ''range''.", + "data.search.functions.timerange.from.help": "Spécifier la date de début", + "data.search.functions.timerange.help": "Créer une plage temporelle Kibana", + "data.search.functions.timerange.mode.help": "Spécifier le mode (absolu ou relatif)", + "data.search.functions.timerange.to.help": "Spécifier la date de fin", + "data.search.httpErrorTitle": "Impossible d’extraire vos données", + "data.search.searchBar.savedQueryDescriptionLabelText": "Description", + "data.search.searchBar.savedQueryDescriptionText": "Enregistrez le texte et les filtres de la requête que vous souhaitez réutiliser.", + "data.search.searchBar.savedQueryForm.titleConflictText": "Ce nom est en conflit avec une requête enregistrée existante.", + "data.search.searchBar.savedQueryFormCancelButtonText": "Annuler", + "data.search.searchBar.savedQueryFormSaveButtonText": "Enregistrer", + "data.search.searchBar.savedQueryFormTitle": "Enregistrer la requête", + "data.search.searchBar.savedQueryIncludeFiltersLabelText": "Inclure les filtres", + "data.search.searchBar.savedQueryIncludeTimeFilterLabelText": "Inclure le filtre temporel", + "data.search.searchBar.savedQueryNameHelpText": "Un nom est requis. Le nom ne peut pas contenir d'espace vide au début ou à la fin. Le nom doit être unique.", + "data.search.searchBar.savedQueryNameLabelText": "Nom", + "data.search.searchBar.savedQueryNoSavedQueriesText": "Aucune requête enregistrée.", + "data.search.searchBar.savedQueryPopoverButtonText": "Voir les requêtes enregistrées", + "data.search.searchBar.savedQueryPopoverClearButtonAriaLabel": "Effacer la requête enregistrée en cours", + "data.search.searchBar.savedQueryPopoverClearButtonText": "Effacer", + "data.search.searchBar.savedQueryPopoverConfirmDeletionCancelButtonText": "Annuler", + "data.search.searchBar.savedQueryPopoverConfirmDeletionConfirmButtonText": "Supprimer", + "data.search.searchBar.savedQueryPopoverConfirmDeletionTitle": "Supprimer \"{savedQueryName}\" ?", + "data.search.searchBar.savedQueryPopoverDeleteButtonAriaLabel": "Supprimer la requête enregistrée {savedQueryName}", + "data.search.searchBar.savedQueryPopoverSaveAsNewButtonAriaLabel": "Enregistrer en tant que nouvelle requête enregistrée", + "data.search.searchBar.savedQueryPopoverSaveAsNewButtonText": "Enregistrer en tant que nouvelle", + "data.search.searchBar.savedQueryPopoverSaveButtonAriaLabel": "Enregistrer une nouvelle requête enregistrée", + "data.search.searchBar.savedQueryPopoverSaveButtonText": "Enregistrer la requête en cours", + "data.search.searchBar.savedQueryPopoverSaveChangesButtonAriaLabel": "Enregistrer les modifications apportées à {title}", + "data.search.searchBar.savedQueryPopoverSaveChangesButtonText": "Enregistrer les modifications", + "data.search.searchBar.savedQueryPopoverSavedQueryListItemButtonAriaLabel": "Bouton de requête enregistrée {savedQueryName}", + "data.search.searchBar.savedQueryPopoverSavedQueryListItemDescriptionAriaLabel": "Description de {savedQueryName}", + "data.search.searchBar.savedQueryPopoverSavedQueryListItemSelectedButtonAriaLabel": "Bouton de requête enregistrée {savedQueryName} sélectionné. Appuyez pour effacer les modifications.", + "data.search.searchBar.savedQueryPopoverTitleText": "Requêtes enregistrées", + "data.search.searchSource.fetch.requestTimedOutNotificationMessage": "Les données peuvent être incomplètes parce que votre requête est arrivée à échéance.", + "data.search.searchSource.fetch.shardsFailedModal.close": "Fermer", + "data.search.searchSource.fetch.shardsFailedModal.copyToClipboard": "Copier la réponse dans le presse-papiers", + "data.search.searchSource.fetch.shardsFailedModal.failureHeader": "{failureName} à {failureDetails}", + "data.search.searchSource.fetch.shardsFailedModal.showDetails": "Afficher les détails", + "data.search.searchSource.fetch.shardsFailedModal.tabHeaderRequest": "Requête", + "data.search.searchSource.fetch.shardsFailedModal.tabHeaderResponse": "Réponse", + "data.search.searchSource.fetch.shardsFailedModal.tabHeaderShardFailures": "Échecs de partition", + "data.search.searchSource.fetch.shardsFailedModal.tableColIndex": "Index", + "data.search.searchSource.fetch.shardsFailedModal.tableColNode": "Nœud", + "data.search.searchSource.fetch.shardsFailedModal.tableColReason": "Raison", + "data.search.searchSource.fetch.shardsFailedModal.tableColShard": "Partition", + "data.search.searchSource.fetch.shardsFailedModal.tableRowCollapse": "Réduire {rowDescription}", + "data.search.searchSource.fetch.shardsFailedModal.tableRowExpand": "Développer {rowDescription}", + "data.search.searchSource.fetch.shardsFailedNotificationDescription": "Les données que vous consultez peuvent être incomplètes ou erronées.", + "data.search.searchSource.fetch.shardsFailedNotificationMessage": "Échec de {shardsFailed} partitions sur {shardsTotal}", + "data.search.searchSource.hitsDescription": "Le nombre de documents renvoyés par la requête.", + "data.search.searchSource.hitsLabel": "Résultats", + "data.search.searchSource.hitsTotalDescription": "Le nombre de documents correspondant à la requête.", + "data.search.searchSource.hitsTotalLabel": "Résultats (total)", + "data.search.searchSource.indexPatternIdDescription": "L'ID dans l'index {kibanaIndexPattern}.", + "data.search.searchSource.queryTimeDescription": "Le temps qu'il a fallu pour traiter la requête. Ne comprend pas le temps nécessaire pour envoyer la requête ni l'analyser dans le navigateur.", + "data.search.searchSource.queryTimeLabel": "Durée de la requête", + "data.search.searchSource.queryTimeValue": "{queryTime}ms", + "data.search.searchSource.requestTimeDescription": "Durée de la requête depuis le navigateur jusqu’à Elasticsearch et retour. N’inclut pas le temps d’attente de la requête dans la file.", + "data.search.searchSource.requestTimeLabel": "Durée de la requête", + "data.search.searchSource.requestTimeValue": "{requestTime}ms", + "data.search.timeBuckets.dayLabel": "{amount, plural, one {un jour} other {# jours}}", + "data.search.timeBuckets.hourLabel": "{amount, plural, one {une heure} other {# heures}}", + "data.search.timeBuckets.infinityLabel": "Plus d'une année", + "data.search.timeBuckets.millisecondLabel": "{amount, plural, one {une milliseconde} other {# millisecondes}}", + "data.search.timeBuckets.minuteLabel": "{amount, plural, one {une minute} other {# minutes}}", + "data.search.timeBuckets.monthLabel": "un mois", + "data.search.timeBuckets.secondLabel": "{amount, plural, one {une seconde} other {# secondes}}", + "data.search.timeBuckets.yearLabel": "une année", + "data.search.timeoutContactAdmin": "Votre requête a expiré. Contactez l'administrateur système pour accroître le temps d'exécution.", + "data.search.timeoutIncreaseSetting": "Votre requête a expiré. Augmentez le temps d'exécution en utilisant le paramètre avancé de délai d'expiration de la recherche.", + "data.search.timeoutIncreaseSettingActionText": "Modifier le paramètre", + "data.search.unableToGetSavedQueryToastTitle": "Impossible de charger la requête enregistrée {savedQueryId}", + "data.searchSession.warning.readDocs": "En savoir plus", + "data.searchSessionIndicator.noCapability": "Vous n'êtes pas autorisé à créer des sessions de recherche.", + "data.searchSessions.sessionService.sessionEditNameError": "Échec de modification du nom de la session de recherche", + "data.searchSessions.sessionService.sessionObjectFetchError": "Échec de récupération des informations de la session de recherche", + "data.triggers.applyFilterDescription": "Lorsque le filtre Kibana est appliqué. Peut être un filtre simple ou un filtre de plage.", + "data.triggers.applyFilterTitle": "Appliquer le filtre", + "devTools.badge.readOnly.text": "Lecture seule", + "devTools.badge.readOnly.tooltip": "Enregistrement impossible", + "devTools.devToolsTitle": "Outils de développement", + "discover.advancedSettings.context.defaultSizeText": "Le nombre d'entrées connexes à afficher dans la vue contextuelle", + "discover.advancedSettings.context.defaultSizeTitle": "Taille de contexte", + "discover.advancedSettings.context.sizeStepText": "L’incrément duquel augmenter ou diminuer la taille de contexte", + "discover.advancedSettings.context.sizeStepTitle": "Incrément de taille de contexte", + "discover.advancedSettings.context.tieBreakerFieldsText": "Une liste de champs séparés par des virgules à utiliser pour départager les documents présentant la même valeur d'horodatage. Le premier champ de cette liste qui est à la fois présent et triable dans le modèle d'indexation en cours est utilisé.", + "discover.advancedSettings.context.tieBreakerFieldsTitle": "Champs de départage", + "discover.advancedSettings.defaultColumnsText": "Les colonnes affichées par défaut dans l'onglet Discover", + "discover.advancedSettings.defaultColumnsTitle": "Colonnes par défaut", + "discover.advancedSettings.discover.modifyColumnsOnSwitchText": "Supprimez les colonnes qui ne sont pas disponibles dans le nouveau modèle d'indexation.", + "discover.advancedSettings.discover.modifyColumnsOnSwitchTitle": "Modifier les colonnes en cas de changement des modèles d'indexation", + "discover.advancedSettings.discover.multiFieldsLinkText": "champs multiples", + "discover.advancedSettings.discover.readFieldsFromSource": "Lire les champs depuis _source", + "discover.advancedSettings.discover.readFieldsFromSourceDescription": "Lorsque cette option est activée, les documents sont chargés directement depuis ''_source''. Elle sera bientôt déclassée. Lorsqu'elle est désactivée, les champs sont extraits via la nouvelle API de champ du service de recherche de haut niveau.", + "discover.advancedSettings.discover.showMultifields": "Afficher les champs multiples", + "discover.advancedSettings.discover.showMultifieldsDescription": "Détermine si les {multiFields} doivent s'afficher dans la fenêtre de document étendue. Dans la plupart des cas, les champs multiples sont les mêmes que les champs d'origine. Cette option est uniquement disponible lorsque le paramètre ''searchFieldsFromSource'' est désactivé.", + "discover.advancedSettings.docTableHideTimeColumnText": "Permet de masquer la colonne ''Time'' dans Discover et dans toutes les recherches enregistrées des tableaux de bord.", + "discover.advancedSettings.docTableHideTimeColumnTitle": "Masquer la colonne ''Time''", + "discover.advancedSettings.fieldsPopularLimitText": "Les N champs les plus populaires à afficher", + "discover.advancedSettings.fieldsPopularLimitTitle": "Limite de champs populaires", + "discover.advancedSettings.maxDocFieldsDisplayedText": "Le nombre maximal de champs renvoyés dans la colonne de document", + "discover.advancedSettings.maxDocFieldsDisplayedTitle": "Nombre maximal de champs de document affichés", + "discover.advancedSettings.sampleSizeText": "Le nombre de lignes à afficher dans le tableau", + "discover.advancedSettings.sampleSizeTitle": "Nombre de lignes", + "discover.advancedSettings.searchOnPageLoadText": "Détermine si une recherche est exécutée lors du premier chargement de Discover. Ce paramètre n'a pas d'effet lors du chargement d’une recherche enregistrée.", + "discover.advancedSettings.searchOnPageLoadTitle": "Recherche au chargement de la page", + "discover.advancedSettings.sortDefaultOrderText": "Détermine le sens de tri par défaut pour les modèles d'indexation temporelle dans l’application Discover.", + "discover.advancedSettings.sortDefaultOrderTitle": "Sens de tri par défaut", + "discover.advancedSettings.sortOrderAsc": "Croissant", + "discover.advancedSettings.sortOrderDesc": "Décroissant", + "discover.backToTopLinkText": "Revenir en haut de la page.", + "discover.badge.readOnly.text": "Lecture seule", + "discover.badge.readOnly.tooltip": "Impossible d’enregistrer les recherches", + "discover.bucketIntervalTooltip": "Cet intervalle crée {bucketsDescription} pour permettre l’affichage dans la plage temporelle sélectionnée, il a donc été redimensionné vers {bucketIntervalDescription}.", + "discover.bucketIntervalTooltip.tooLargeBucketsText": "des compartiments trop volumineux", + "discover.bucketIntervalTooltip.tooManyBucketsText": "un trop grand nombre de compartiments", + "discover.clearSelection": "Effacer la sélection", + "discover.context.breadcrumb": "Documents relatifs", + "discover.context.contextOfTitle": "Les documents relatifs à #{anchorId}", + "discover.context.failedToLoadAnchorDocumentDescription": "Échec de chargement du document ancré", + "discover.context.failedToLoadAnchorDocumentErrorDescription": "Le document ancré n’a pas pu être chargé.", + "discover.context.invalidTieBreakerFiledSetting": "Paramètre de champ de départage non valide", + "discover.context.loadButtonLabel": "Charger", + "discover.context.loadingDescription": "Chargement...", + "discover.context.newerDocumentsAriaLabel": "Nombre de documents plus récents", + "discover.context.newerDocumentsDescription": "documents plus récents", + "discover.context.newerDocumentsWarning": "Seuls {docCount} documents plus récents que le document ancré ont été trouvés.", + "discover.context.newerDocumentsWarningZero": "Aucun document plus récent que le document ancré n'a été trouvé.", + "discover.context.olderDocumentsAriaLabel": "Nombre de documents plus anciens", + "discover.context.olderDocumentsDescription": "documents plus anciens", + "discover.context.olderDocumentsWarning": "Seuls {docCount} documents plus anciens que le document ancré ont été trouvés.", + "discover.context.olderDocumentsWarningZero": "Aucun document plus ancien que le document ancré n'a été trouvé.", + "discover.context.reloadPageDescription.reloadOrVisitTextMessage": "Veuillez recharger le document ou revenir à la liste pour sélectionner un document ancré valide.", + "discover.context.unableToLoadAnchorDocumentDescription": "Impossible de charger le document ancré", + "discover.context.unableToLoadDocumentDescription": "Impossible de charger les documents", + "discover.controlColumnHeader": "Colonne de commande", + "discover.copyToClipboardJSON": "Copier les documents dans le presse-papiers (JSON)", + "discover.discoverBreadcrumbTitle": "Discover", + "discover.discoverDefaultSearchSessionName": "Discover", + "discover.discoverDescription": "Explorez vos données de manière interactive en interrogeant et en filtrant des documents bruts.", + "discover.discoverSubtitle": "Recherchez et obtenez des informations.", + "discover.discoverTitle": "Discover", + "discover.doc.couldNotFindDocumentsDescription": "Aucun document ne correspond à cet ID.", + "discover.doc.failedToExecuteQueryDescription": "Impossible d'exécuter la recherche", + "discover.doc.failedToLocateDocumentDescription": "Document introuvable", + "discover.doc.loadingDescription": "Chargement…", + "discover.doc.somethingWentWrongDescription": "Index {indexName} manquant.", + "discover.doc.somethingWentWrongDescriptionAddon": "Veuillez vérifier que cet index existe.", + "discover.docTable.documentsNavigation": "Navigation dans les documents", + "discover.docTable.limitedSearchResultLabel": "Limité à {resultCount} résultats. Veuillez affiner votre recherche.", + "discover.docTable.noResultsTitle": "Aucun résultat trouvé.", + "discover.docTable.rows": "lignes", + "discover.docTable.rowsPerPage": "Lignes par page : {pageSize}", + "discover.docTable.tableHeader.documentHeader": "Document", + "discover.docTable.tableHeader.moveColumnLeftButtonAriaLabel": "Déplacer la colonne {columnName} vers la gauche", + "discover.docTable.tableHeader.moveColumnLeftButtonTooltip": "Déplacer la colonne vers la gauche", + "discover.docTable.tableHeader.moveColumnRightButtonAriaLabel": "Déplacer la colonne {columnName} vers la droite", + "discover.docTable.tableHeader.moveColumnRightButtonTooltip": "Déplacer la colonne vers la droite", + "discover.docTable.tableHeader.removeColumnButtonAriaLabel": "Supprimer la colonne {columnName}", + "discover.docTable.tableHeader.removeColumnButtonTooltip": "Supprimer la colonne", + "discover.docTable.tableHeader.sortByColumnAscendingAriaLabel": "Trier la colonne {columnName} par ordre croissant", + "discover.docTable.tableHeader.sortByColumnDescendingAriaLabel": "Trier la colonne {columnName} par ordre décroissant", + "discover.docTable.tableHeader.sortByColumnUnsortedAriaLabel": "Arrêter de trier la colonne {columnName}", + "discover.docTable.tableRow.detailHeading": "Document développé", + "discover.docTable.tableRow.filterForValueButtonAriaLabel": "Filtrer sur la valeur", + "discover.docTable.tableRow.filterForValueButtonTooltip": "Filtrer sur la valeur", + "discover.docTable.tableRow.filterOutValueButtonAriaLabel": "Exclure la valeur", + "discover.docTable.tableRow.filterOutValueButtonTooltip": "Exclure la valeur", + "discover.docTable.tableRow.toggleRowDetailsButtonAriaLabel": "Afficher/Masquer les détails de la ligne", + "discover.docTable.tableRow.viewSingleDocumentLinkText": "Afficher un seul document", + "discover.docTable.tableRow.viewSurroundingDocumentsLinkText": "Afficher les documents alentour", + "discover.docTable.totalDocuments": "{totalDocuments} documents", + "discover.documentsAriaLabel": "Documents", + "discover.docViews.json.jsonTitle": "JSON", + "discover.docViews.table.filterForFieldPresentButtonAriaLabel": "Filtrer sur le champ", + "discover.docViews.table.filterForFieldPresentButtonTooltip": "Filtrer sur le champ", + "discover.docViews.table.filterForValueButtonAriaLabel": "Filtrer sur la valeur", + "discover.docViews.table.filterForValueButtonTooltip": "Filtrer sur la valeur", + "discover.docViews.table.filterOutValueButtonAriaLabel": "Exclure la valeur", + "discover.docViews.table.filterOutValueButtonTooltip": "Exclure la valeur", + "discover.docViews.table.scoreSortWarningTooltip": "Filtrez sur _score pour pouvoir récupérer les valeurs correspondantes.", + "discover.docViews.table.tableTitle": "Tableau", + "discover.docViews.table.toggleColumnInTableButtonAriaLabel": "Afficher/Masquer la colonne dans le tableau", + "discover.docViews.table.toggleColumnInTableButtonTooltip": "Afficher/Masquer la colonne dans le tableau", + "discover.docViews.table.toggleFieldDetails": "Afficher/Masquer les détails du champ", + "discover.docViews.table.unableToFilterForPresenceOfMetaFieldsTooltip": "Impossible de filtrer sur les champs méta", + "discover.docViews.table.unableToFilterForPresenceOfScriptedFieldsTooltip": "Impossible de filtrer sur les champs scriptés", + "discover.docViews.table.unindexedFieldsCanNotBeSearchedTooltip": "Il est impossible d’effectuer une recherche sur des champs non indexés.", + "discover.embeddable.inspectorRequestDataTitle": "Données", + "discover.embeddable.inspectorRequestDescription": "Cette requête interroge Elasticsearch afin de récupérer les données pour la recherche.", + "discover.embeddable.search.displayName": "recherche", + "discover.field.mappingConflict": "Ce champ est défini avec plusieurs types (chaîne, entier, etc.) dans les différents index qui correspondent à ce modèle. Vous pouvez toujours utiliser ce champ conflictuel, mais il sera indisponible pour les fonctions qui nécessitent que Kibana en connaisse le type. Pour corriger ce problème, vous devrez réindexer vos données.", + "discover.field.mappingConflict.title": "Conflit de mapping", + "discover.field.title": "{fieldName} ({fieldDisplayName})", + "discover.fieldChooser.detailViews.emptyStringText": "Chaîne vide", + "discover.fieldChooser.detailViews.existsInRecordsText": "Existe dans {value} / {totalValue} enregistrements", + "discover.fieldChooser.detailViews.filterOutValueButtonAriaLabel": "Exclure le {field} : \"{value}\"", + "discover.fieldChooser.detailViews.filterValueButtonAriaLabel": "Filtrer sur le {field} : \"{value}\"", + "discover.fieldChooser.detailViews.valueOfRecordsText": "{value}/{totalValue} enregistrements", + "discover.fieldChooser.discoverField.actions": "Actions", + "discover.fieldChooser.discoverField.addButtonAriaLabel": "Ajouter {field} au tableau", + "discover.fieldChooser.discoverField.addFieldTooltip": "Ajouter le champ en tant que colonne", + "discover.fieldChooser.discoverField.deleteFieldLabel": "Supprimer le champ du modèle d'indexation", + "discover.fieldChooser.discoverField.editFieldLabel": "Modifier le champ du modèle d'indexation", + "discover.fieldChooser.discoverField.fieldTopValuesLabel": "Top 5 des valeurs", + "discover.fieldChooser.discoverField.multiField": "champ multiple", + "discover.fieldChooser.discoverField.multiFields": "Champs multiples", + "discover.fieldChooser.discoverField.multiFieldTooltipContent": "Les champs multiples peuvent avoir plusieurs valeurs.", + "discover.fieldChooser.discoverField.name": "Champ", + "discover.fieldChooser.discoverField.removeButtonAriaLabel": "Supprimer {field} du tableau", + "discover.fieldChooser.discoverField.removeFieldTooltip": "Supprimer le champ du tableau", + "discover.fieldChooser.discoverField.value": "Valeur", + "discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForGeoFieldsErrorMessage": "L'analyse n'est pas disponible pour les champs géométriques.", + "discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForObjectFieldsErrorMessage": "L'analyse n'est pas disponible pour les champs d'objet.", + "discover.fieldChooser.fieldCalculator.fieldIsNotPresentInDocumentsErrorMessage": "Ce champ est présent dans votre mapping Elasticsearch, mais pas dans les {hitsLength} documents affichés dans le tableau des documents. Cependant, vous pouvez toujours le consulter ou effectuer une recherche dessus.", + "discover.fieldChooser.fieldFilterButtonLabel": "Filtrer par type", + "discover.fieldChooser.fieldsMobileButtonLabel": "Champs", + "discover.fieldChooser.filter.aggregatableLabel": "Regroupable", + "discover.fieldChooser.filter.availableFieldsTitle": "Champs disponibles", + "discover.fieldChooser.filter.fieldSelectorLabel": "Sélection des options du filtre {id}", + "discover.fieldChooser.filter.filterByTypeLabel": "Filtrer par type", + "discover.fieldChooser.filter.indexAndFieldsSectionAriaLabel": "Index et champs", + "discover.fieldChooser.filter.popularTitle": "Populaire", + "discover.fieldChooser.filter.searchableLabel": "Interrogeable", + "discover.fieldChooser.filter.selectedFieldsTitle": "Champs sélectionnés", + "discover.fieldChooser.filter.toggleButton.any": "tout", + "discover.fieldChooser.filter.toggleButton.no": "non", + "discover.fieldChooser.filter.toggleButton.yes": "oui", + "discover.fieldChooser.filter.typeLabel": "Type", + "discover.fieldChooser.indexPatterns.actionsPopoverLabel": "Paramètres du modèle d'indexation", + "discover.fieldChooser.indexPatterns.addFieldButton": "Ajouter un champ au modèle d'indexation", + "discover.fieldChooser.indexPatterns.manageFieldButton": "Gérer les champs du modèle d'indexation", + "discover.fieldChooser.searchPlaceHolder": "Rechercher les noms de champs", + "discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel": "Masquer les paramètres de filtre de champs", + "discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel": "Afficher les paramètres de filtre de champs", + "discover.fieldChooser.visualizeButton.label": "Visualiser", + "discover.fieldList.flyoutBackIcon": "Retour", + "discover.fieldList.flyoutHeading": "Liste des champs", + "discover.fieldNameIcons.booleanAriaLabel": "Champ booléen", + "discover.fieldNameIcons.conflictFieldAriaLabel": "Champ conflictuel", + "discover.fieldNameIcons.dateFieldAriaLabel": "Champ de date", + "discover.fieldNameIcons.geoPointFieldAriaLabel": "Champ de point géographique", + "discover.fieldNameIcons.geoShapeFieldAriaLabel": "Champ de forme géométrique", + "discover.fieldNameIcons.ipAddressFieldAriaLabel": "Champ d'adresse IP", + "discover.fieldNameIcons.murmur3FieldAriaLabel": "Champ Murmur3", + "discover.fieldNameIcons.nestedFieldAriaLabel": "Champ imbriqué", + "discover.fieldNameIcons.numberFieldAriaLabel": "Champ numérique", + "discover.fieldNameIcons.sourceFieldAriaLabel": "Champ source", + "discover.fieldNameIcons.stringFieldAriaLabel": "Champ de chaîne", + "discover.fieldNameIcons.unknownFieldAriaLabel": "Champ inconnu", + "discover.grid.documentHeader": "Document", + "discover.grid.filterFor": "Filtrer sur", + "discover.grid.filterForAria": "Filtrer sur cette {value}", + "discover.grid.filterOut": "Exclure", + "discover.grid.filterOutAria": "Exclure cette {value}", + "discover.grid.flyout.documentNavigation": "Navigation dans le document", + "discover.grid.flyout.toastColumnAdded": "La colonne \"{columnName}\" a été ajoutée.", + "discover.grid.flyout.toastColumnRemoved": "La colonne \"{columnName}\" a été supprimée.", + "discover.grid.flyout.toastFilterAdded": "Le filtre a été ajouté.", + "discover.grid.tableRow.detailHeading": "Document développé", + "discover.grid.tableRow.viewSingleDocumentLinkTextSimple": "Document unique", + "discover.grid.tableRow.viewSurroundingDocumentsLinkTextSimple": "Documents relatifs", + "discover.grid.tableRow.viewText": "Afficher :", + "discover.grid.viewDoc": "Afficher/Masquer les détails de la boîte de dialogue", + "discover.helpMenu.appName": "Discover", + "discover.hideChart": "Masquer le graphique", + "discover.histogramOfFoundDocumentsAriaLabel": "Histogramme des documents détectés", + "discover.hitCountSpinnerAriaLabel": "Nombre final de résultats toujours en chargement", + "discover.hitsPluralTitle": "{formattedHits} {hits, plural, one {résultat} other {résultats}}", + "discover.howToSeeOtherMatchingDocumentsDescription": "Voici les {sampleSize} premiers documents correspondant à votre recherche. Veuillez affiner celle-ci pour en voir plus.", + "discover.howToSeeOtherMatchingDocumentsDescriptionGrid": "Voici les {sampleSize} premiers documents correspondant à votre recherche. Veuillez affiner celle-ci pour en voir plus.", + "discover.inspectorRequestDataTitleChart": "Données du graphique", + "discover.inspectorRequestDataTitleDocuments": "Documents", + "discover.inspectorRequestDataTitleTotalHits": "Nombre total de résultats", + "discover.inspectorRequestDescriptionChart": "Cette requête interroge Elasticsearch afin de récupérer les données d'agrégation pour le graphique.", + "discover.inspectorRequestDescriptionDocument": "Cette requête interroge Elasticsearch afin de récupérer les documents.", + "discover.inspectorRequestDescriptionTotalHits": "Cette requête interroge Elasticsearch afin de récupérer le nombre total de résultats.", + "discover.json.codeEditorAriaLabel": "Affichage JSON en lecture seule d’un document Elasticsearch", + "discover.json.copyToClipboardLabel": "Copier dans le presse-papiers", + "discover.loadingChartResults": "Chargement du graphique", + "discover.loadingDocuments": "Chargement des documents", + "discover.loadingJSON": "Chargement de JSON", + "discover.loadingResults": "Chargement des résultats", + "discover.localMenu.inspectTitle": "Inspecter", + "discover.localMenu.localMenu.newSearchTitle": "Nouveau", + "discover.localMenu.localMenu.optionsTitle": "Options", + "discover.localMenu.newSearchDescription": "Nouvelle recherche", + "discover.localMenu.openInspectorForSearchDescription": "Ouvrir l'inspecteur de recherche", + "discover.localMenu.openSavedSearchDescription": "Ouvrir une recherche enregistrée", + "discover.localMenu.openTitle": "Ouvrir", + "discover.localMenu.optionsDescription": "Options", + "discover.localMenu.saveSaveSearchObjectType": "recherche", + "discover.localMenu.saveSearchDescription": "Enregistrer la recherche", + "discover.localMenu.saveTitle": "Enregistrer", + "discover.localMenu.shareSearchDescription": "Partager la recherche", + "discover.localMenu.shareTitle": "Partager", + "discover.noResults.adjustFilters": "Modifiez les filtres.", + "discover.noResults.adjustSearch": "Modifiez la requête.", + "discover.noResults.expandYourTimeRangeTitle": "Étendre la plage temporelle", + "discover.noResults.queryMayNotMatchTitle": "Essayez de rechercher sur une période plus longue.", + "discover.noResults.searchExamples.noResultsBecauseOfError": "Une erreur s’est produite lors de la récupération des résultats de recherche.", + "discover.noResults.searchExamples.noResultsMatchSearchCriteriaTitle": "Aucun résultat ne correspond à vos critères de recherche.", + "discover.noResultsFound": "Aucun résultat trouvé.", + "discover.notifications.invalidTimeRangeText": "La plage temporelle spécifiée n'est pas valide (de : \"{from}\" à \"{to}\").", + "discover.notifications.invalidTimeRangeTitle": "Plage temporelle non valide", + "discover.notifications.notSavedSearchTitle": "La recherche \"{savedSearchTitle}\" n'a pas été enregistrée.", + "discover.notifications.savedSearchTitle": "La recherche \"{savedSearchTitle}\" a été enregistrée.", + "discover.partialHits": "≥ {formattedHits} {hits, plural, one {résultat} other {résultats}}", + "discover.reloadSavedSearchButton": "Réinitialiser la recherche", + "discover.removeColumnLabel": "Supprimer la colonne", + "discover.rootBreadcrumb": "Discover", + "discover.savedSearch.savedObjectName": "Recherche enregistrée", + "discover.searchGenerationWithDescription": "Tableau généré par la recherche {searchTitle}", + "discover.searchGenerationWithDescriptionGrid": "Tableau généré par la recherche {searchTitle} ({searchDescription})", + "discover.searchingTitle": "Recherche", + "discover.selectColumnHeader": "Sélectionner la colonne", + "discover.selectedDocumentsNumber": "{nr} documents sélectionnés", + "discover.showAllDocuments": "Afficher tous les documents", + "discover.showChart": "Afficher le graphique", + "discover.showErrorMessageAgain": "Afficher le message d'erreur", + "discover.showSelectedDocumentsOnly": "Afficher uniquement les documents sélectionnés", + "discover.skipToBottomButtonLabel": "Atteindre la fin du tableau", + "discover.sourceViewer.errorMessage": "Impossible de récupérer les données pour le moment. Actualisez l'onglet et réessayez.", + "discover.sourceViewer.errorMessageTitle": "Une erreur s'est produite.", + "discover.sourceViewer.refresh": "Actualiser", + "discover.toggleSidebarAriaLabel": "Afficher/Masquer la barre latérale", + "discover.topNav.openSearchPanel.manageSearchesButtonLabel": "Gérer les recherches", + "discover.topNav.openSearchPanel.noSearchesFoundDescription": "Aucune recherche correspondante trouvée.", + "discover.topNav.openSearchPanel.openSearchTitle": "Ouvrir une recherche", + "discover.topNav.optionsPopover.currentViewMode": "{viewModeLabel} : {currentViewMode}", + "discover.uninitializedRefreshButtonText": "Actualiser les données", + "discover.uninitializedText": "Saisissez une requête, ajoutez quelques filtres, ou cliquez simplement sur Actualiser afin d’extraire les résultats pour la requête en cours.", + "discover.uninitializedTitle": "Commencer la recherche", + "embeddableApi.addPanel.createNewDefaultOption": "Créer", + "embeddableApi.addPanel.displayName": "Ajouter un panneau", + "embeddableApi.addPanel.noMatchingObjectsMessage": "Aucun objet correspondant trouvé.", + "embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} a été ajouté.", + "embeddableApi.addPanel.Title": "Ajouter depuis la bibliothèque", + "embeddableApi.attributeService.saveToLibraryError": "Une erreur s'est produite lors de l'enregistrement. Erreur : {errorMessage}", + "embeddableApi.contextMenuTrigger.description": "Un menu contextuel cliquable dans l’angle supérieur droit du panneau.", + "embeddableApi.contextMenuTrigger.title": "Menu contextuel", + "embeddableApi.customizePanel.action.displayName": "Modifier le titre du panneau", + "embeddableApi.customizePanel.modal.cancel": "Annuler", + "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleFormRowLabel": "Titre du panneau", + "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleInputAriaLabel": "Entrez un titre personnalisé pour le panneau.", + "embeddableApi.customizePanel.modal.optionsMenuForm.resetCustomDashboardButtonLabel": "Réinitialiser", + "embeddableApi.customizePanel.modal.saveButtonTitle": "Enregistrer", + "embeddableApi.customizePanel.modal.showTitle": "Afficher le titre du panneau", + "embeddableApi.customizeTitle.optionsMenuForm.panelTitleFormRowLabel": "Titre du panneau", + "embeddableApi.customizeTitle.optionsMenuForm.panelTitleInputAriaLabel": "Les modifications apportées à cette entrée sont appliquées immédiatement. Appuyez sur Entrée pour quitter.", + "embeddableApi.customizeTitle.optionsMenuForm.resetCustomDashboardButtonLabel": "Réinitialiser le titre", + "embeddableApi.errors.embeddableFactoryNotFound": "Impossible de charger {type}. Veuillez effectuer une mise à niveau vers la distribution par défaut d'Elasticsearch et de Kibana avec la licence appropriée.", + "embeddableApi.errors.paneldoesNotExist": "Panneau introuvable", + "embeddableApi.helloworld.displayName": "bonjour", + "embeddableApi.panel.dashboardPanelAriaLabel": "Panneau du tableau de bord", + "embeddableApi.panel.editPanel.displayName": "Modifier {value}", + "embeddableApi.panel.editTitleAriaLabel": "Cliquez pour modifier le titre : {title}", + "embeddableApi.panel.enhancedDashboardPanelAriaLabel": "Panneau du tableau de bord : {title}", + "embeddableApi.panel.inspectPanel.displayName": "Inspecter", + "embeddableApi.panel.inspectPanel.untitledEmbeddableFilename": "sans titre", + "embeddableApi.panel.labelAborted": "Annulé", + "embeddableApi.panel.labelError": "Erreur", + "embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabel": "Options de panneau", + "embeddableApi.panel.optionsMenu.panelOptionsButtonEnhancedAriaLabel": "Options de panneau pour {title}", + "embeddableApi.panel.placeholderTitle": "[Aucun titre]", + "embeddableApi.panel.removePanel.displayName": "Supprimer du tableau de bord", + "embeddableApi.panelBadgeTrigger.description": "Des actions apparaissent dans la barre de titre lorsqu'un élément pouvant être intégré est chargé dans un panneau.", + "embeddableApi.panelBadgeTrigger.title": "Badges du panneau", + "embeddableApi.panelNotificationTrigger.description": "Les actions apparaissent dans l’angle supérieur droit des panneaux.", + "embeddableApi.panelNotificationTrigger.title": "Notifications du panneau", + "embeddableApi.samples.contactCard.displayName": "carte de visite", + "embeddableApi.samples.filterableContainer.displayName": "tableau de bord filtrable", + "embeddableApi.samples.filterableEmbeddable.displayName": "filtrable", + "embeddableApi.selectRangeTrigger.description": "Une plage de valeurs sur la visualisation", + "embeddableApi.selectRangeTrigger.title": "Sélection de la plage", + "embeddableApi.valueClickTrigger.description": "Un point de données cliquable sur la visualisation", + "embeddableApi.valueClickTrigger.title": "Clic unique", + "esQuery.kql.errors.endOfInputText": "fin de l'entrée", + "esQuery.kql.errors.fieldNameText": "nom du champ", + "esQuery.kql.errors.literalText": "littéral", + "esQuery.kql.errors.syntaxError": "{expectedList} attendu, mais {foundInput} détecté.", + "esQuery.kql.errors.valueText": "valeur", + "esQuery.kql.errors.whitespaceText": "whitespace", + "esUi.cronEditor.cronDaily.fieldHour.textAtLabel": "À", + "esUi.cronEditor.cronDaily.fieldTimeLabel": "Heure", + "esUi.cronEditor.cronDaily.hourSelectLabel": "Heure", + "esUi.cronEditor.cronDaily.minuteSelectLabel": "Minute", + "esUi.cronEditor.cronHourly.fieldMinute.textAtLabel": "À", + "esUi.cronEditor.cronHourly.fieldTimeLabel": "Minute", + "esUi.cronEditor.cronMonthly.fieldDateLabel": "Date", + "esUi.cronEditor.cronMonthly.fieldHour.textAtLabel": "À", + "esUi.cronEditor.cronMonthly.fieldTimeLabel": "Heure", + "esUi.cronEditor.cronMonthly.hourSelectLabel": "Heure", + "esUi.cronEditor.cronMonthly.minuteSelectLabel": "Minute", + "esUi.cronEditor.cronMonthly.textOnTheLabel": "Le", + "esUi.cronEditor.cronWeekly.fieldDateLabel": "Jour", + "esUi.cronEditor.cronWeekly.fieldHour.textAtLabel": "À", + "esUi.cronEditor.cronWeekly.fieldTimeLabel": "Heure", + "esUi.cronEditor.cronWeekly.hourSelectLabel": "Heure", + "esUi.cronEditor.cronWeekly.minuteSelectLabel": "Minute", + "esUi.cronEditor.cronWeekly.textOnLabel": "Le", + "esUi.cronEditor.cronYearly.fieldDate.textOnTheLabel": "Le", + "esUi.cronEditor.cronYearly.fieldDateLabel": "Date", + "esUi.cronEditor.cronYearly.fieldHour.textAtLabel": "À", + "esUi.cronEditor.cronYearly.fieldMonth.textInLabel": "En", + "esUi.cronEditor.cronYearly.fieldMonthLabel": "Mois", + "esUi.cronEditor.cronYearly.fieldTimeLabel": "Heure", + "esUi.cronEditor.cronYearly.hourSelectLabel": "Heure", + "esUi.cronEditor.cronYearly.minuteSelectLabel": "Minute", + "esUi.cronEditor.day.friday": "vendredi", + "esUi.cronEditor.day.monday": "lundi", + "esUi.cronEditor.day.saturday": "samedi", + "esUi.cronEditor.day.sunday": "dimanche", + "esUi.cronEditor.day.thursday": "jeudi", + "esUi.cronEditor.day.tuesday": "mardi", + "esUi.cronEditor.day.wednesday": "mercredi", + "esUi.cronEditor.fieldFrequencyLabel": "Fréquence", + "esUi.cronEditor.month.april": "avril", + "esUi.cronEditor.month.august": "août", + "esUi.cronEditor.month.december": "décembre", + "esUi.cronEditor.month.february": "février", + "esUi.cronEditor.month.january": "janvier", + "esUi.cronEditor.month.july": "juillet", + "esUi.cronEditor.month.june": "juin", + "esUi.cronEditor.month.march": "mars", + "esUi.cronEditor.month.may": "mai", + "esUi.cronEditor.month.november": "novembre", + "esUi.cronEditor.month.october": "octobre", + "esUi.cronEditor.month.september": "septembre", + "esUi.cronEditor.textEveryLabel": "Chaque", + "esUi.forms.comboBoxField.placeHolderText": "Saisir, puis appuyer sur \"ENTRÉE\"", + "esUi.forms.fieldValidation.indexNameInvalidCharactersError": "Le nom de l'index contient {characterListLength, plural, one {le caractère non valide} other {les caractères non valides}} {characterList}.", + "esUi.forms.fieldValidation.indexNameSpacesError": "Le nom de l'index ne peut pas contenir d'espaces.", + "esUi.forms.fieldValidation.indexNameStartsWithDotError": "Le nom de l'index ne peut pas commencer par un point (.).", + "esUi.forms.fieldValidation.indexPatternInvalidCharactersError": "Le modèle d'indexation contient {characterListLength, plural, one {le caractère non valide} other {les caractères non valides}} {characterList}.", + "esUi.forms.fieldValidation.indexPatternSpacesError": "Le modèle d'indexation ne peut pas contenir d'espaces.", + "esUi.formWizard.backButtonLabel": "Retour", + "esUi.formWizard.nextButtonLabel": "Suivant", + "esUi.formWizard.saveButtonLabel": "Enregistrer", + "esUi.formWizard.savingButtonLabel": "Enregistrement en cours...", + "esUi.validation.string.invalidJSONError": "JSON non valide", + "expressionError.errorComponent.description": "Échec de l'expression avec le message :", + "expressionError.errorComponent.title": "Oups ! Échec de l'expression", + "expressionError.renderer.debug.displayName": "Débogage", + "expressionError.renderer.debug.helpDescription": "Présenter une sortie de débogage formatée {JSON}", + "expressionError.renderer.error.displayName": "Informations sur l'erreur", + "expressionError.renderer.error.helpDescription": "Présenter les données de l'erreur d'une manière utile pour les utilisateurs", + "expressionImage.functions.image.args.dataurlHelpText": "L'{URL} {https} ou l'{URL} de données {BASE64} d'une image.", + "expressionImage.functions.image.args.modeHelpText": "{contain} affiche l'image entière, mise à l’échelle. {cover} remplit le conteneur avec l'image, en rognant les côtés ou le bas si besoin. {stretch} redimensionne la hauteur et la largeur de l'image pour correspondre à 100 % du conteneur.", + "expressionImage.functions.image.invalidImageModeErrorMessage": "\"mode\" doit être défini sur \"{contain}\", \"{cover}\" ou \"{stretch}\".", + "expressionImage.functions.imageHelpText": "Affiche une image. Spécifiez une ressource d'image sous la forme d'une {URL} de données {BASE64}, ou saisissez une sous-expression.", + "expressionImage.renderer.image.displayName": "Image", + "expressionImage.renderer.image.helpDescription": "Présenter une image", + "expressionMetric.functions.metric.args.labelFontHelpText": "Les propriétés de la police {CSS} pour l'étiquette. Par exemple, {FONT_FAMILY} ou {FONT_WEIGHT}.", + "expressionMetric.functions.metric.args.labelHelpText": "Le texte décrivant l'indicateur.", + "expressionMetric.functions.metric.args.metricFontHelpText": "Les propriétés de la police {CSS} pour l'indicateur. Par exemple, {FONT_FAMILY} ou {FONT_WEIGHT}.", + "expressionMetric.functions.metric.args.metricFormatHelpText": "Une chaîne de format {NUMERALJS}. Par exemple, {example1} ou {example2}.", + "expressionMetric.functions.metricHelpText": "Affiche un nombre sur une étiquette.", + "expressionMetric.renderer.metric.displayName": "Indicateur", + "expressionMetric.renderer.metric.helpDescription": "Présenter un nombre sur une étiquette", + "expressionRepeatImage.error.repeatImage.missingMaxArgument": "{maxArgument} doit être défini si un {emptyImageArgument} est fourni.", + "expressionRepeatImage.functions.repeatImage.args.emptyImageHelpText": "Comble la différence entre les paramètres {CONTEXT} et {maxArg} pour l'élément avec cette image. Spécifiez une ressource d'image sous la forme d'une {URL} de données {BASE64}, ou saisissez une sous-expression.", + "expressionRepeatImage.functions.repeatImage.args.imageHelpText": "L'image à répéter. Spécifiez une ressource d'image sous la forme d'une {URL} de données {BASE64}, ou saisissez une sous-expression.", + "expressionRepeatImage.functions.repeatImage.args.maxHelpText": "Le nombre maximal de fois que l'image peut être répétée.", + "expressionRepeatImage.functions.repeatImage.args.sizeHelpText": "La hauteur ou largeur maximale de l'image, en pixels. Lorsque l'image est plus haute que large, cette fonction limite la hauteur.", + "expressionRepeatImage.functions.repeatImageHelpText": "Configure un élément de répétition d’image.", + "expressionRepeatImage.renderer.repeatImage.displayName": "Répétition d’image", + "expressionRepeatImage.renderer.repeatImage.helpDescription": "Présenter une répétition d’image basique", + "expressionRevealImage.functions.revealImage.args.emptyImageHelpText": "Une image d'arrière-plan facultative à révéler. Spécifiez une ressource d'image sous la forme d’une {URL} de données \"{BASE64}\", ou saisissez une sous-expression.", + "expressionRevealImage.functions.revealImage.args.imageHelpText": "L'image à révéler. Spécifiez une ressource d'image sous la forme d'une {URL} de données {BASE64}, ou saisissez une sous-expression.", + "expressionRevealImage.functions.revealImage.args.originHelpText": "La position à laquelle démarrer le remplissage de l'image. Par exemple, {list} ou {end}.", + "expressionRevealImage.functions.revealImage.invalidImageUrl": "URL d'image non valide : \"{imageUrl}\".", + "expressionRevealImage.functions.revealImage.invalidPercentErrorMessage": "Valeur non valide : \"{percent}\". Le pourcentage doit être compris entre 0 et 1.", + "expressionRevealImage.functions.revealImageHelpText": "Configure un élément de révélation d'image.", + "expressionRevealImage.renderer.revealImage.displayName": "Révélation d'image", + "expressionRevealImage.renderer.revealImage.helpDescription": "Révèle un pourcentage d'une image pour concevoir un graphique à jauge personnalisé.", + "expressions.defaultErrorRenderer.errorTitle": "Erreur dans la visualisation", + "expressions.execution.functionDisabled": "Fonction {fnName} désactivée.", + "expressions.execution.functionNotFound": "Fonction {fnName} introuvable.", + "expressions.functions.createTable.args.idsHelpText": "ID de colonne à générer dans l'ordre de position. L'ID représente la clé dans la ligne.", + "expressions.functions.createTable.args.nameHelpText": "Noms de colonne à générer dans l'ordre de position. Ces noms n'ont pas besoin d'être uniques et, en l’absence de noms, les ID sont utilisés par défaut.", + "expressions.functions.createTable.args.rowCountText": "Le nombre de lignes vides à ajouter au tableau, pour y attribuer une valeur plus tard", + "expressions.functions.createTableHelpText": "Crée une table de données avec une liste de colonnes, et une ou plusieurs lignes vides. Pour générer les lignes, utilisez {mapColumnFn} ou {mathColumnFn}.", + "expressions.functions.cumulativeSum.args.byHelpText": "Colonne par laquelle diviser le calcul de la somme cumulée", + "expressions.functions.cumulativeSum.args.inputColumnIdHelpText": "Colonne pour laquelle calculer la somme cumulée", + "expressions.functions.cumulativeSum.args.outputColumnIdHelpText": "Colonne dans laquelle stocker le résultat de la somme cumulée", + "expressions.functions.cumulativeSum.args.outputColumnNameHelpText": "Nom de la colonne dans laquelle stocker le résultat de la somme cumulée", + "expressions.functions.cumulativeSum.help": "Calcule la somme cumulée d'une colonne dans un tableau de données.", + "expressions.functions.derivative.args.byHelpText": "Colonne par laquelle diviser le calcul de la dérivée", + "expressions.functions.derivative.args.inputColumnIdHelpText": "Colonne pour laquelle calculer la dérivée", + "expressions.functions.derivative.args.outputColumnIdHelpText": "Colonne dans laquelle stocker le résultat de la dérivée", + "expressions.functions.derivative.args.outputColumnNameHelpText": "Nom de la colonne dans laquelle stocker le résultat de la dérivée", + "expressions.functions.derivative.help": "Calcule la dérivée d'une colonne dans un tableau de données.", + "expressions.functions.font.args.alignHelpText": "L'alignement horizontal du texte.", + "expressions.functions.font.args.colorHelpText": "La couleur du texte.", + "expressions.functions.font.args.familyHelpText": "Une chaîne de police Internet {css} acceptable", + "expressions.functions.font.args.italicHelpText": "Mettre le texte en italique ?", + "expressions.functions.font.args.lHeightHelpText": "La hauteur de la ligne en pixels", + "expressions.functions.font.args.sizeHelpText": "La taille de la police en pixels", + "expressions.functions.font.args.underlineHelpText": "Souligner le texte ?", + "expressions.functions.font.args.weightHelpText": "L’épaisseur de la police. Par exemple, {list} ou {end}.", + "expressions.functions.font.invalidFontWeightErrorMessage": "Épaisseur de police non valide : \"{weight}\"", + "expressions.functions.font.invalidTextAlignmentErrorMessage": "Alignement du texte non valide : \"{align}\"", + "expressions.functions.fontHelpText": "Créez un style de police.", + "expressions.functions.mapColumn.args.copyMetaFromHelpText": "Si défini, l'objet méta de l'ID de colonne spécifié est copié dans la colonne cible spécifiée. Si la colonne n'existe pas, un échec silencieux se produit.", + "expressions.functions.mapColumn.args.expressionHelpText": "Une expression qui est exécutée sur chaque ligne, fournie avec un contexte {DATATABLE} de ligne unique et retournant la valeur de la cellule.", + "expressions.functions.mapColumn.args.idHelpText": "Un ID facultatif de la colonne de résultat. Si aucun ID n'est fourni, l'ID est récupéré de la colonne existante par l'argument de nom fourni. S'il n'existe pas encore de colonne à ce nom, une nouvelle colonne avec ce nom et un ID identique est ajoutée au tableau.", + "expressions.functions.mapColumn.args.nameHelpText": "Le nom de la colonne produite. Les noms n'ont pas besoin d'être uniques.", + "expressions.functions.mapColumnHelpText": "Ajoute une colonne calculée comme le résultat d'autres colonnes. Des modifications ne sont apportées que si des arguments sont fournis. Voir également {alterColumnFn} et {staticColumnFn}.", + "expressions.functions.math.args.expressionHelpText": "Une expression {TINYMATH} évaluée. Voir {TINYMATH_URL}.", + "expressions.functions.math.args.onErrorHelpText": "Si l’évaluation {TINYMATH} échoue ou renvoie NaN, la valeur de retour est spécifiée par onError. Lors de la ''génération'', une exception est levée, terminant l'exécution de l'expression (par défaut).", + "expressions.functions.math.emptyDatatableErrorMessage": "Table de données vide", + "expressions.functions.math.emptyExpressionErrorMessage": "Expression vide", + "expressions.functions.math.executionFailedErrorMessage": "Échec d'exécution de l'expression mathématique. Vérifiez les noms des colonnes.", + "expressions.functions.math.tooManyResultsErrorMessage": "Les expressions doivent retourner un nombre unique. Essayez d'englober votre expression dans {mean} ou {sum}.", + "expressions.functions.mathColumn.args.copyMetaFromHelpText": "Si défini, l'objet méta de l'ID de colonne spécifié est copié dans la colonne cible spécifiée. Si la colonne n'existe pas, un échec silencieux se produit.", + "expressions.functions.mathColumn.args.idHelpText": "ID de la colonne produite. Doit être unique.", + "expressions.functions.mathColumn.args.nameHelpText": "Le nom de la colonne produite. Les noms n'ont pas besoin d'être uniques.", + "expressions.functions.mathColumn.arrayValueError": "Impossible de réaliser le calcul sur les valeurs du tableau à {name}", + "expressions.functions.mathColumn.uniqueIdError": "L'ID doit être unique.", + "expressions.functions.mathHelpText": "Interprète une expression mathématique {TINYMATH} à l'aide d'un {TYPE_NUMBER} ou d'une {DATATABLE} en tant que {CONTEXT}. Les colonnes {DATATABLE} peuvent être recherchées d’après leur nom. Si {CONTEXT} est un nombre, il est disponible en tant que {value}.", + "expressions.functions.movingAverage.args.byHelpText": "Colonne par laquelle diviser le calcul de la moyenne mobile", + "expressions.functions.movingAverage.args.inputColumnIdHelpText": "Colonne pour laquelle calculer la moyenne mobile", + "expressions.functions.movingAverage.args.outputColumnIdHelpText": "Colonne dans laquelle stocker le résultat de la moyenne mobile", + "expressions.functions.movingAverage.args.outputColumnNameHelpText": "Nom de la colonne dans laquelle stocker le résultat de la moyenne mobile", + "expressions.functions.movingAverage.args.windowHelpText": "La taille de la fenêtre à \"faire glisser\" le long de l'histogramme.", + "expressions.functions.movingAverage.help": "Calcule la moyenne mobile d'une colonne dans un tableau de données.", + "expressions.functions.overallMetric.args.byHelpText": "Colonne par laquelle diviser le calcul général", + "expressions.functions.overallMetric.args.inputColumnIdHelpText": "Colonne pour laquelle calculer l’indicateur général", + "expressions.functions.overallMetric.args.outputColumnIdHelpText": "Colonne dans laquelle stocker le résultat de l'indicateur général", + "expressions.functions.overallMetric.args.outputColumnNameHelpText": "Nom de la colonne dans laquelle stocker le résultat de l’indicateur général", + "expressions.functions.overallMetric.help": "Calcule la somme, le minimum, le maximum ou la moyenne générale d'une colonne dans un tableau de données.", + "expressions.functions.overallMetric.metricHelpText": "Indicateur à calculer", + "expressions.functions.seriesCalculations.columnConflictMessage": "L'ID de colonne de sortie {columnId} existe déjà. Veuillez choisir un autre ID de colonne.", + "expressions.functions.theme.args.defaultHelpText": "La valeur par défaut lorsqu’aucune information de thème n’est disponible.", + "expressions.functions.theme.args.variableHelpText": "Nom de la variable de thème à lire.", + "expressions.functions.themeHelpText": "Lit un paramètre de thème.", + "expressions.functions.uiSetting.args.default": "La valeur par défaut utilisée lorsque le paramètre n’est pas défini.", + "expressions.functions.uiSetting.args.parameter": "Le nom du paramètre.", + "expressions.functions.uiSetting.error.kibanaRequest": "Une requête Kibana est nécessaire pour obtenir les paramètres de l'interface utilisateur sur le serveur. Veuillez fournir un objet de requête pour les paramètres d'exécution de l'expression.", + "expressions.functions.uiSetting.error.parameter": "Paramètre \"{parameter}\" non valide.", + "expressions.functions.uiSetting.help": "Renvoie une valeur de paramètre de l'interface utilisateur.", + "expressions.functions.var.help": "Met à jour le contexte général de Kibana.", + "expressions.functions.var.name.help": "Spécifiez le nom de la variable.", + "expressions.functions.varset.help": "Met à jour le contexte général de Kibana.", + "expressions.functions.varset.name.help": "Spécifiez le nom de la variable.", + "expressions.functions.varset.val.help": "Spécifiez la valeur de la variable. Sinon, le contexte d'entrée est utilisé.", + "expressions.types.number.fromStringConversionErrorMessage": "Impossible de cataloguer la chaîne \"{string}\" en nombre", + "expressionShape.functions.progress.args.barColorHelpText": "La couleur de la barre d'arrière-plan.", + "expressionShape.functions.progress.args.barWeightHelpText": "L'épaisseur de la barre d'arrière-plan.", + "expressionShape.functions.progress.args.fontHelpText": "Les propriétés de la police {CSS} pour l'étiquette. Par exemple, {FONT_FAMILY} ou {FONT_WEIGHT}.", + "expressionShape.functions.progress.args.labelHelpText": "Pour afficher ou masquer l'étiquette, utilisez {BOOLEAN_TRUE} ou {BOOLEAN_FALSE}. Vous pouvez également spécifier une chaîne à afficher en tant qu'étiquette.", + "expressionShape.functions.progress.args.maxHelpText": "La valeur maximale de l'élément de progression.", + "expressionShape.functions.progress.args.shapeHelpText": "Sélectionnez {list} ou {end}.", + "expressionShape.functions.progress.args.valueColorHelpText": "La couleur de la barre de progression.", + "expressionShape.functions.progress.args.valueWeightHelpText": "L'épaisseur de la barre de progression.", + "expressionShape.functions.progress.invalidMaxValueErrorMessage": "Valeur {arg} non valide : \"{max, number}\" ; \"{arg}\" doit être supérieur à 0.", + "expressionShape.functions.progress.invalidValueErrorMessage": "Valeur non valide : \"{value, number}\". La valeur doit être comprise entre 0 et {max, number}.", + "expressionShape.functions.progressHelpText": "Configure un élément de progression.", + "expressionShape.functions.shape.args.borderHelpText": "Une couleur {SVG} pour la bordure de la forme.", + "expressionShape.functions.shape.args.borderWidthHelpText": "L'épaisseur de la bordure.", + "expressionShape.functions.shape.args.fillHelpText": "Une couleur {SVG} de remplissage de la forme.", + "expressionShape.functions.shape.args.maintainAspectHelpText": "Conserver le rapport d'origine de la forme ?", + "expressionShape.functions.shape.args.shapeHelpText": "Choisissez une forme.", + "expressionShape.functions.shape.invalidShapeErrorMessage": "Valeur non valide : \"{shape}\". Cette forme n'existe pas.", + "expressionShape.functions.shapeHelpText": "Crée une forme.", + "expressionShape.renderer.progress.displayName": "Progression", + "expressionShape.renderer.progress.helpDescription": "Présenter une progression basique", + "expressionShape.renderer.shape.displayName": "Forme", + "expressionShape.renderer.shape.helpDescription": "Présenter une forme basique", + "fieldFormats.advancedSettings.format.bytesFormat.numeralFormatLinkText": "Format numérique", + "fieldFormats.advancedSettings.format.bytesFormatText": "{numeralFormatLink} par défaut pour le format \"octets\"", + "fieldFormats.advancedSettings.format.bytesFormatTitle": "Format octets", + "fieldFormats.advancedSettings.format.currencyFormat.numeralFormatLinkText": "Format numérique", + "fieldFormats.advancedSettings.format.currencyFormatText": "{numeralFormatLink} par défaut pour le format \"devise\"", + "fieldFormats.advancedSettings.format.currencyFormatTitle": "Format devise", + "fieldFormats.advancedSettings.format.defaultTypeMapText": "Mapping du nom du format à utiliser par défaut pour chaque type de champ. Le format {defaultFormat} est utilisé lorsque le type de champ n'est pas mentionné explicitement.", + "fieldFormats.advancedSettings.format.defaultTypeMapTitle": "Nom du format du type de champ", + "fieldFormats.advancedSettings.format.formattingLocale.numeralLanguageLinkText": "Langage numérique", + "fieldFormats.advancedSettings.format.formattingLocaleText": "Paramètre régional {numeralLanguageLink}", + "fieldFormats.advancedSettings.format.formattingLocaleTitle": "Paramètre régional de format", + "fieldFormats.advancedSettings.format.numberFormat.numeralFormatLinkText": "Format numérique", + "fieldFormats.advancedSettings.format.numberFormatText": "{numeralFormatLink} par défaut pour le format \"nombre\"", + "fieldFormats.advancedSettings.format.numberFormatTitle": "Format nombre", + "fieldFormats.advancedSettings.format.percentFormat.numeralFormatLinkText": "Format numérique", + "fieldFormats.advancedSettings.format.percentFormatText": "{numeralFormatLink} par défaut pour le format \"pourcentage\"", + "fieldFormats.advancedSettings.format.percentFormatTitle": "Format pourcentage", + "fieldFormats.advancedSettings.shortenFieldsText": "Raccourcir les champs longs, par exemple f.b.baz plutôt que foo.bar.baz", + "fieldFormats.advancedSettings.shortenFieldsTitle": "Raccourcir les champs", + "fieldFormats.boolean.title": "Booléen", + "fieldFormats.bytes.title": "Octets", + "fieldFormats.color.title": "Couleur", + "fieldFormats.date_nanos.title": "Date nanos", + "fieldFormats.date.title": "Date", + "fieldFormats.duration.inputFormats.days": "Jours", + "fieldFormats.duration.inputFormats.hours": "Heures", + "fieldFormats.duration.inputFormats.microseconds": "Microsecondes", + "fieldFormats.duration.inputFormats.milliseconds": "Millisecondes", + "fieldFormats.duration.inputFormats.minutes": "Minutes", + "fieldFormats.duration.inputFormats.months": "Mois", + "fieldFormats.duration.inputFormats.nanoseconds": "Nanosecondes", + "fieldFormats.duration.inputFormats.picoseconds": "Picosecondes", + "fieldFormats.duration.inputFormats.seconds": "Secondes", + "fieldFormats.duration.inputFormats.weeks": "Semaines", + "fieldFormats.duration.inputFormats.years": "Années", + "fieldFormats.duration.negativeLabel": "moins", + "fieldFormats.duration.outputFormats.asDays": "Jours", + "fieldFormats.duration.outputFormats.asDays.short": "j", + "fieldFormats.duration.outputFormats.asHours": "Heures", + "fieldFormats.duration.outputFormats.asHours.short": "h", + "fieldFormats.duration.outputFormats.asMilliseconds": "Millisecondes", + "fieldFormats.duration.outputFormats.asMilliseconds.short": "ms", + "fieldFormats.duration.outputFormats.asMinutes": "Minutes", + "fieldFormats.duration.outputFormats.asMinutes.short": "min", + "fieldFormats.duration.outputFormats.asMonths": "Mois", + "fieldFormats.duration.outputFormats.asMonths.short": "mois", + "fieldFormats.duration.outputFormats.asSeconds": "Secondes", + "fieldFormats.duration.outputFormats.asSeconds.short": "s", + "fieldFormats.duration.outputFormats.asWeeks": "Semaines", + "fieldFormats.duration.outputFormats.asWeeks.short": "w", + "fieldFormats.duration.outputFormats.asYears": "Années", + "fieldFormats.duration.outputFormats.asYears.short": "y", + "fieldFormats.duration.outputFormats.humanize.approximate": "Lisible par l'humain (approximatif)", + "fieldFormats.duration.outputFormats.humanize.precise": "Lisible par l'humain (précis)", + "fieldFormats.duration.title": "Durée", + "fieldFormats.histogram.title": "Histogramme", + "fieldFormats.ip.title": "Adresse IP", + "fieldFormats.number.title": "Nombre", + "fieldFormats.percent.title": "Pourcentage", + "fieldFormats.relative_date.title": "Date relative", + "fieldFormats.static_lookup.title": "Recherche statique", + "fieldFormats.string.emptyLabel": "(vide)", + "fieldFormats.string.title": "Chaîne", + "fieldFormats.string.transformOptions.base64": "Décodage Base64", + "fieldFormats.string.transformOptions.lower": "Minuscule", + "fieldFormats.string.transformOptions.none": "- Aucune -", + "fieldFormats.string.transformOptions.short": "Points courts", + "fieldFormats.string.transformOptions.title": "Initiale majuscule", + "fieldFormats.string.transformOptions.upper": "Majuscule", + "fieldFormats.string.transformOptions.url": "Décodage paramètre URL", + "fieldFormats.truncated_string.title": "Chaîne tronquée", + "fieldFormats.url.title": "Url", + "fieldFormats.url.types.audio": "Audio", + "fieldFormats.url.types.img": "Image", + "fieldFormats.url.types.link": "Lien", + "flot.pie.unableToDrawLabelsInsideCanvasErrorMessage": "Impossible de dessiner un graphique avec les étiquettes contenues dans la toile", + "flot.time.aprLabel": "Avr", + "flot.time.augLabel": "Août", + "flot.time.decLabel": "Déc", + "flot.time.febLabel": "Févr", + "flot.time.friLabel": "Ven", + "flot.time.janLabel": "Jan", + "flot.time.julLabel": "Juil", + "flot.time.junLabel": "Juin", + "flot.time.marLabel": "Mars", + "flot.time.mayLabel": "Mai", + "flot.time.monLabel": "Lun", + "flot.time.novLabel": "Nov", + "flot.time.octLabel": "Oct", + "flot.time.satLabel": "Sam", + "flot.time.sepLabel": "Sept", + "flot.time.sunLabel": "Dim", + "flot.time.thuLabel": "Jeu", + "flot.time.tueLabel": "Mar", + "flot.time.wedLabel": "Mer", + "home.addData.addDataButtonLabel": "Ajouter vos données", + "home.addData.sampleDataButtonLabel": "Utiliser un exemple de données", + "home.addData.sectionTitle": "Ajoutez vos données pour commencer", + "home.addData.text": "Vous avez plusieurs options pour commencer à exploiter vos données. Vous pouvez collecter des données à partir d'une application ou d'un service, ou bien charger un fichier. Et si vous n'êtes pas encore prêt à utiliser vos propres données, utilisez notre exemple d’ensemble de données.", + "home.breadcrumbs.homeTitle": "Accueil", + "home.exploreButtonLabel": "Explorer par moi-même", + "home.exploreYourDataDescription": "Une fois toutes les étapes terminées, vous êtes prêt à explorer vos données.", + "home.header.title": "Bienvenue chez vous", + "home.letsStartDescription": "Ajoutez des données à votre cluster depuis n’importe quelle source, puis analysez-les et visualisez-les en temps réel. Utilisez nos solutions pour définir des recherches, observer votre écosystème et vous protéger contre les menaces de sécurité.", + "home.letsStartTitle": "Commencez par ajouter vos données", + "home.loadTutorials.requestFailedErrorMessage": "Échec de la requête avec le code de statut : {status}", + "home.loadTutorials.unableToLoadErrorMessage": "Impossible de charger les tutoriels", + "home.manageData.devToolsButtonLabel": "Outils de développement", + "home.manageData.sectionTitle": "Gestion", + "home.manageData.stackManagementButtonLabel": "Gestion de la suite", + "home.pageTitle": "Accueil", + "home.recentlyAccessed.recentlyViewedTitle": "Récemment consulté", + "home.sampleData.ecommerceSpec.ordersTitle": "[e-commerce] Commandes", + "home.sampleData.ecommerceSpec.promotionTrackingTitle": "[e-commerce] Suivi des promotions", + "home.sampleData.ecommerceSpec.revenueDashboardDescription": "Analyser des commandes et revenus e-commerce", + "home.sampleData.ecommerceSpec.revenueDashboardTitle": "[e-commerce] Tableau de bord des revenus", + "home.sampleData.ecommerceSpec.soldProductsPerDayTitle": "[e-commerce] Produits vendus par jour", + "home.sampleData.ecommerceSpecDescription": "Exemple de données, visualisations et tableaux de bord pour le suivi des commandes d’e-commerce.", + "home.sampleData.ecommerceSpecTitle": "Exemple de commandes d’e-commerce", + "home.sampleData.flightsSpec.airportConnectionsTitle": "[Vols] Connexions aéroportuaires (passage au-dessus d'un aéroport)", + "home.sampleData.flightsSpec.delayBucketsTitle": "[Vols] Compartiments retard", + "home.sampleData.flightsSpec.delaysAndCancellationsTitle": "[Vols] Retards et annulations", + "home.sampleData.flightsSpec.departuresCountMapTitle": "[Vols] Mappage du nombre de départs", + "home.sampleData.flightsSpec.destinationWeatherTitle": "[Vols] Météo à la destination", + "home.sampleData.flightsSpec.flightLogTitle": "[Vols] Journal de vol", + "home.sampleData.flightsSpec.globalFlightDashboardDescription": "Analyser des données aéroportuaires factices pour ES-Air, Logstash Airways, Kibana Airlines et JetBeats", + "home.sampleData.flightsSpec.globalFlightDashboardTitle": "[Vols] Tableau de bord des vols internationaux", + "home.sampleData.flightsSpecDescription": "Exemple de données, de visualisations et de tableaux de bord pour le monitoring des itinéraires de vol.", + "home.sampleData.flightsSpecTitle": "Exemple de données aéroportuaires", + "home.sampleData.logsSpec.bytesDistributionTitle": "[Logs] Distribution des octets", + "home.sampleData.logsSpec.discoverTitle": "[Logs] Visites", + "home.sampleData.logsSpec.goalsTitle": "[Logs] Objectifs", + "home.sampleData.logsSpec.heatmapTitle": "[Logs] Carte thermique des visiteurs uniques", + "home.sampleData.logsSpec.hostVisitsBytesTableTitle": "[Logs] Tableau des hôtes, visites et octets", + "home.sampleData.logsSpec.responseCodesOverTimeTitle": "[Logs] Codes de réponse sur la durée + annotations", + "home.sampleData.logsSpec.sourceAndDestinationSankeyChartTitle": "[Logs] Diagramme de Sankey source-destination", + "home.sampleData.logsSpec.visitorsMapTitle": "[Logs] Mappage des visiteurs", + "home.sampleData.logsSpec.webTrafficDescription": "Analyser des données de log factices relatives au trafic Internet du site d'Elastic", + "home.sampleData.logsSpec.webTrafficTitle": "[Logs] Trafic Internet", + "home.sampleData.logsSpecDescription": "Exemple de données, de visualisations et de tableaux de bord pour le monitoring des logs Internet.", + "home.sampleData.logsSpecTitle": "Exemple de logs Internet", + "home.sampleDataSet.installedLabel": "{name} installé", + "home.sampleDataSet.unableToInstallErrorMessage": "Impossible d'installer l'exemple d’ensemble de données : {name}.", + "home.sampleDataSet.unableToLoadListErrorMessage": "Impossible de charger la liste des exemples d’ensemble de données", + "home.sampleDataSet.unableToUninstallErrorMessage": "Impossible de désinstaller l'exemple d’ensemble de données : {name}.", + "home.sampleDataSet.uninstalledLabel": "{name} désinstallé", + "home.sampleDataSetCard.addButtonAriaLabel": "Ajouter {datasetName}", + "home.sampleDataSetCard.addButtonLabel": "Ajouter des données", + "home.sampleDataSetCard.addingButtonAriaLabel": "Ajout de {datasetName}", + "home.sampleDataSetCard.addingButtonLabel": "Ajout", + "home.sampleDataSetCard.dashboardLinkLabel": "Tableau de bord", + "home.sampleDataSetCard.default.addButtonAriaLabel": "Ajouter {datasetName}", + "home.sampleDataSetCard.default.addButtonLabel": "Ajouter des données", + "home.sampleDataSetCard.default.unableToVerifyErrorMessage": "Impossible de vérifier le statut de l'ensemble de données. Erreur : {statusMsg}.", + "home.sampleDataSetCard.removeButtonAriaLabel": "Supprimer {datasetName}", + "home.sampleDataSetCard.removeButtonLabel": "Supprimer", + "home.sampleDataSetCard.removingButtonAriaLabel": "Suppression de {datasetName}", + "home.sampleDataSetCard.removingButtonLabel": "Suppression", + "home.sampleDataSetCard.viewDataButtonAriaLabel": "Consulter {datasetName}", + "home.sampleDataSetCard.viewDataButtonLabel": "Consulter les données", + "home.solutionsSection.sectionTitle": "Choisir votre solution", + "home.tryButtonLabel": "Ajouter des données", + "home.tutorial.addDataToKibanaTitle": "Ajouter des données", + "home.tutorial.card.sampleDataDescription": "Commencez votre exploration de Kibana avec ces ensembles de données \"en un clic\".", + "home.tutorial.card.sampleDataTitle": "Exemple de données", + "home.tutorial.elasticCloudButtonLabel": "Elastic Cloud", + "home.tutorial.instruction_variant.fleet": "Elastic APM (bêta) dans Fleet", + "home.tutorial.instructionSet.checkStatusButtonLabel": "Vérifier le statut", + "home.tutorial.instructionSet.customizeLabel": "Personnaliser les extraits de code", + "home.tutorial.instructionSet.noDataLabel": "Aucune donnée trouvée", + "home.tutorial.instructionSet.statusCheckTitle": "Vérification du statut", + "home.tutorial.instructionSet.successLabel": "Réussite", + "home.tutorial.introduction.betaLabel": "Version bêta", + "home.tutorial.introduction.imageAltDescription": "Capture d'écran du tableau de bord principal.", + "home.tutorial.introduction.viewButtonLabel": "Consulter les champs exportés", + "home.tutorial.noTutorialLabel": "Tutoriel {tutorialId} introuvable", + "home.tutorial.savedObject.addedLabel": "{savedObjectsLength} objets enregistrés ont bien été ajoutés.", + "home.tutorial.savedObject.confirmButtonLabel": "Confirmer l'écrasement", + "home.tutorial.savedObject.defaultButtonLabel": "Charger des objets Kibana", + "home.tutorial.savedObject.installLabel": "Importe un modèle d'indexation, des visualisations et des tableaux de bord prédéfinis.", + "home.tutorial.savedObject.installStatusLabel": "{overwriteErrorsLength} objets sur {savedObjectsLength} existent déjà. Cliquez sur \"Confirmer l'écrasement\" pour importer et écraser les objets existants. Toute modification apportée aux objets sera perdue.", + "home.tutorial.savedObject.loadTitle": "Charger des objets Kibana", + "home.tutorial.savedObject.requestFailedErrorMessage": "Échec de la requête. Erreur : {message}.", + "home.tutorial.savedObject.unableToAddErrorMessage": "Impossible d'ajouter {errorsLength} objets Kibana sur {savedObjectsLength} . Erreur : {errorMessage}.", + "home.tutorial.selectionLegend": "Type de déploiement", + "home.tutorial.selfManagedButtonLabel": "Autogéré", + "home.tutorial.tabs.sampleDataTitle": "Exemple de données", + "home.tutorial.unexpectedStatusCheckStateErrorDescription": "État de vérification du statut {statusCheckState} inattendu", + "home.tutorial.unhandledInstructionTypeErrorDescription": "Type d'instructions {visibleInstructions} non pris en charge", + "home.tutorialDirectory.featureCatalogueDescription": "Importez des données à partir d'applications et de services populaires.", + "home.tutorialDirectory.featureCatalogueTitle": "Ajouter des données", + "home.tutorials.activemqLogs.artifacts.dashboards.linkLabel": "Événements d'audit ActiveMQ", + "home.tutorials.activemqLogs.longDescription": "Collectez les logs ActiveMQ avec Filebeat. [En savoir plus]({learnMoreLink}).", + "home.tutorials.activemqLogs.nameTitle": "Logs ActiveMQ", + "home.tutorials.activemqLogs.shortDescription": "Collectez les logs ActiveMQ avec Filebeat.", + "home.tutorials.activemqMetrics.artifacts.application.label": "Discover", + "home.tutorials.activemqMetrics.longDescription": "Le module Metricbeat ''activemq'' récupère les indicateurs de monitoring depuis les instances ActiveMQ. [En savoir plus]({learnMoreLink}).", + "home.tutorials.activemqMetrics.nameTitle": "Indicateurs ActiveMQ", + "home.tutorials.activemqMetrics.shortDescription": "Récupérez les indicateurs de monitoring depuis les instances ActiveMQ.", + "home.tutorials.aerospikeMetrics.artifacts.application.label": "Discover", + "home.tutorials.aerospikeMetrics.longDescription": "Le module Metricbeat ''aerospike'' récupère les indicateurs internes d’Aerospike. [En savoir plus]({learnMoreLink}).", + "home.tutorials.aerospikeMetrics.nameTitle": "Indicateurs Aerospike", + "home.tutorials.aerospikeMetrics.shortDescription": "Récupérez les indicateurs internes depuis le serveur Aerospike.", + "home.tutorials.apacheLogs.artifacts.dashboards.linkLabel": "Tableau de bord des logs Apache", + "home.tutorials.apacheLogs.longDescription": "Le module Filebeat ''apache'' analyse les logs d'accès et d'erreurs créés par le serveur HTTP Apache. [En savoir plus]({learnMoreLink}).", + "home.tutorials.apacheLogs.nameTitle": "Logs Apache", + "home.tutorials.apacheLogs.shortDescription": "Collectez et analysez les logs d'accès et d'erreurs créés par le serveur HTTP Apache.", + "home.tutorials.apacheMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs Apache", + "home.tutorials.apacheMetrics.longDescription": "Le module Metricbeat ''apache'' récupère les indicateurs internes depuis le serveur HTTP Apache 2. [En savoir plus]({learnMoreLink}).", + "home.tutorials.apacheMetrics.nameTitle": "Indicateurs Apache", + "home.tutorials.apacheMetrics.shortDescription": "Récupérez les indicateurs internes depuis le serveur HTTP Apache 2.", + "home.tutorials.auditbeat.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.auditbeat.longDescription": "Utilisez Auditbeat pour collecter les données d'audit de vos hôtes. Ces données incluent les processus, utilisateurs, connexions, informations de socket, accès aux fichiers et bien plus encore. [En savoir plus]({learnMoreLink}).", + "home.tutorials.auditbeat.nameTitle": "Auditbeat", + "home.tutorials.auditbeat.shortDescription": "Collectez des données d'audit de vos hôtes.", + "home.tutorials.auditdLogs.artifacts.dashboards.linkLabel": "Événements d'audit", + "home.tutorials.auditdLogs.longDescription": "Le module collecte et analyse les logs du démon d'audit (''auditd'') [En savoir plus]({learnMoreLink}).", + "home.tutorials.auditdLogs.nameTitle": "Logs auditd", + "home.tutorials.auditdLogs.shortDescription": "Collectez les logs du démon Linux auditd.", + "home.tutorials.awsLogs.artifacts.dashboards.linkLabel": "Tableau de bord du log d'accès au serveur AWS S3", + "home.tutorials.awsLogs.longDescription": "Collectez des logs AWS en les exportant vers un compartiment S3 configuré avec la notification SQS [En savoir plus]({learnMoreLink}).", + "home.tutorials.awsLogs.nameTitle": "Logs AWS S3", + "home.tutorials.awsLogs.shortDescription": "Collectez des logs AWS à partir du compartiment S3 avec Filebeat.", + "home.tutorials.awsMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs AWS", + "home.tutorials.awsMetrics.longDescription": "Le module Metricbeat ''aws'' récupère les indicateurs de monitoring depuis les API AWS et Cloudwatch. [En savoir plus]({learnMoreLink}).", + "home.tutorials.awsMetrics.nameTitle": "Indicateurs AWS", + "home.tutorials.awsMetrics.shortDescription": "Récupérez les indicateurs de monitoring pour les instances EC2 depuis les API AWS et Cloudwatch.", + "home.tutorials.azureLogs.artifacts.dashboards.linkLabel": "Tableau de bord des logs Azure", + "home.tutorials.azureLogs.longDescription": "Le module Filebeat ''azure'' collecte les logs d’activité et d’audit Azure. [Learn more]({learnMoreLink}).", + "home.tutorials.azureLogs.nameTitle": "Logs Azure", + "home.tutorials.azureLogs.shortDescription": "Collectez les logs d’activité et d’audit Azure.", + "home.tutorials.azureMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs Azure", + "home.tutorials.azureMetrics.longDescription": "Le module Metricbeat ''azure'' récupère les indicateurs de monitoring Azure. [En savoir plus]({learnMoreLink}).", + "home.tutorials.azureMetrics.nameTitle": "Indicateurs Azure", + "home.tutorials.azureMetrics.shortDescription": "Récupérez les indicateurs de monitoring Azure.", + "home.tutorials.barracudaLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.barracudaLogs.longDescription": "Ce module permet de recevoir les logs Barracuda Web Application Firewall par le biais de Syslog ou d’un fichier. [Learn more]({learnMoreLink}).", + "home.tutorials.barracudaLogs.nameTitle": "Logs Barracuda", + "home.tutorials.barracudaLogs.shortDescription": "Collectez les logs Barracuda Web Application Firewall par le biais de Syslog ou d’un fichier.", + "home.tutorials.bluecoatLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.bluecoatLogs.longDescription": "Ce module permet de recevoir les logs Blue Coat Director par le biais de Syslog ou d’un fichier. [Learn more]({learnMoreLink}).", + "home.tutorials.bluecoatLogs.nameTitle": "Logs Blue Coat Director", + "home.tutorials.bluecoatLogs.shortDescription": "Collectez les logs Blue Coat Director par le biais de Syslog ou d'un fichier.", + "home.tutorials.cefLogs.artifacts.dashboards.linkLabel": "Tableau de bord d'aperçu du réseau CEF", + "home.tutorials.cefLogs.longDescription": "Ce module permet de recevoir des données Common Event Format (CEF) par le biais de Syslog. Lorsque des messages sont reçus par le biais du protocole Syslog, l'entrée Syslog analyse l'en-tête et définit la valeur d'horodatage. Puis le processeur est appliqué pour analyser les données CEF. Les données décodées sont alors écrites dans un champ objet ''cef''. Enfin, tous les champs Elastic Common Schema (ECS) ayant des correspondances CEF sont renseignés. [En savoir plus]({learnMoreLink}).", + "home.tutorials.cefLogs.nameTitle": "Logs CEF", + "home.tutorials.cefLogs.shortDescription": "Collectez des logs Common Event Format (CEF) par le biais de Syslog.", + "home.tutorials.cephMetrics.artifacts.application.label": "Discover", + "home.tutorials.cephMetrics.longDescription": "Le module Metricbeat ''ceph'' récupère les indicateurs internes depuis Ceph. [En savoir plus]({learnMoreLink}).", + "home.tutorials.cephMetrics.nameTitle": "Indicateurs Ceph", + "home.tutorials.cephMetrics.shortDescription": "Récupérez les indicateurs internes depuis le serveur Ceph.", + "home.tutorials.checkpointLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.checkpointLogs.longDescription": "Il s'agit d'un module pour les logs de pare-feu Check Point. Il prend en charge les logs de l’exportateur de journaux au format Syslog. [Learn more]({learnMoreLink}).", + "home.tutorials.checkpointLogs.nameTitle": "Logs Check Point", + "home.tutorials.checkpointLogs.shortDescription": "Collectez des logs de pare-feu Check Point.", + "home.tutorials.ciscoLogs.artifacts.dashboards.linkLabel": "Tableau de bord de pare-feu ASA", + "home.tutorials.ciscoLogs.longDescription": "Il s'agit d'un module pour les logs de dispositifs réseau Cisco (ASA, FTD, IOS, Nexus). Il inclut les ensembles de fichiers suivants pour la réception des logs par le biais de Syslog ou d'un ficher. [En savoir plus]({learnMoreLink}).", + "home.tutorials.ciscoLogs.nameTitle": "Logs Cisco", + "home.tutorials.ciscoLogs.shortDescription": "Collectez les logs de dispositifs réseau Cisco par le biais de Syslog ou d'un fichier.", + "home.tutorials.cloudwatchLogs.longDescription": "Collectez les logs Cloudwatch en déployant Functionbeat à des fins d'exécution en tant que fonction AWS Lambda. [En savoir plus]({learnMoreLink}).", + "home.tutorials.cloudwatchLogs.nameTitle": "Logs Cloudwatch AWS", + "home.tutorials.cloudwatchLogs.shortDescription": "Collectez les logs Cloudwatch avec Functionbeat.", + "home.tutorials.cockroachdbMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs CockroachDB", + "home.tutorials.cockroachdbMetrics.longDescription": "Le module Metricbeat ''cockroachbd'' récupère les indicateurs de monitoring depuis CockroachDB. [En savoir plus]({learnMoreLink}).", + "home.tutorials.cockroachdbMetrics.nameTitle": "Indicateurs CockroachDB", + "home.tutorials.cockroachdbMetrics.shortDescription": "Récupérez les indicateurs de monitoring depuis le serveur CockroachDB.", + "home.tutorials.common.auditbeat.cloudInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.auditbeat.premCloudInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.auditbeat.premInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.auditbeatCloudInstructions.config.debTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.auditbeatCloudInstructions.config.debTitle": "Modifier la configuration", + "home.tutorials.common.auditbeatCloudInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.auditbeatCloudInstructions.config.osxTitle": "Modifier la configuration", + "home.tutorials.common.auditbeatCloudInstructions.config.rpmTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.auditbeatCloudInstructions.config.rpmTitle": "Modifier la configuration", + "home.tutorials.common.auditbeatCloudInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.auditbeatCloudInstructions.config.windowsTitle": "Modifier la configuration", + "home.tutorials.common.auditbeatInstructions.config.debTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.auditbeatInstructions.config.debTitle": "Modifier la configuration", + "home.tutorials.common.auditbeatInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.auditbeatInstructions.config.osxTitle": "Modifier la configuration", + "home.tutorials.common.auditbeatInstructions.config.rpmTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.auditbeatInstructions.config.rpmTitle": "Modifier la configuration", + "home.tutorials.common.auditbeatInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.auditbeatInstructions.config.windowsTitle": "Modifier la configuration", + "home.tutorials.common.auditbeatInstructions.install.debTextPost": "Vous cherchez les packages 32 bits ? Consultez la [page de téléchargement]({linkUrl}).", + "home.tutorials.common.auditbeatInstructions.install.debTextPre": "Vous utilisez Auditbeat pour la première fois ? Consultez le [guide de démarrage rapide]({linkUrl}).", + "home.tutorials.common.auditbeatInstructions.install.debTitle": "Télécharger et installer Auditbeat", + "home.tutorials.common.auditbeatInstructions.install.osxTextPre": "Vous utilisez Auditbeat pour la première fois ? Consultez le [guide de démarrage rapide]({linkUrl}).", + "home.tutorials.common.auditbeatInstructions.install.osxTitle": "Télécharger et installer Auditbeat", + "home.tutorials.common.auditbeatInstructions.install.rpmTextPost": "Vous cherchez les packages 32 bits ? Consultez la [page de téléchargement]({linkUrl}).", + "home.tutorials.common.auditbeatInstructions.install.rpmTextPre": "Vous utilisez Auditbeat pour la première fois ? Consultez le [guide de démarrage rapide]({linkUrl}).", + "home.tutorials.common.auditbeatInstructions.install.rpmTitle": "Télécharger et installer Auditbeat", + "home.tutorials.common.auditbeatInstructions.install.windowsTextPost": "Modifiez les paramètres sous {propertyName} dans le fichier {auditbeatPath} afin de pointer vers votre installation Elasticsearch.", + "home.tutorials.common.auditbeatInstructions.install.windowsTextPre": "Vous utilisez Auditbeat pour la première fois ? Consultez le [guide de démarrage rapide]({guideLinkUrl}).\n 1. Téléchargez le fichier .zip Auditbeat pour Windows via la page [Télécharger]({auditbeatLinkUrl}).\n 2. Extrayez le contenu du fichier compressé sous {folderPath}.\n 3. Renommez le répertoire \"{directoryName}\" en \"Auditbeat\".\n 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell.\n 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Auditbeat en tant que service Windows.", + "home.tutorials.common.auditbeatInstructions.install.windowsTitle": "Télécharger et installer Auditbeat", + "home.tutorials.common.auditbeatInstructions.start.debTextPre": "La commande ''setup'' charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", + "home.tutorials.common.auditbeatInstructions.start.debTitle": "Lancer Auditbeat", + "home.tutorials.common.auditbeatInstructions.start.osxTextPre": "La commande ''setup'' charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", + "home.tutorials.common.auditbeatInstructions.start.osxTitle": "Lancer Auditbeat", + "home.tutorials.common.auditbeatInstructions.start.rpmTextPre": "La commande ''setup'' charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", + "home.tutorials.common.auditbeatInstructions.start.rpmTitle": "Lancer Auditbeat", + "home.tutorials.common.auditbeatInstructions.start.windowsTextPre": "La commande ''setup'' charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", + "home.tutorials.common.auditbeatInstructions.start.windowsTitle": "Lancer Auditbeat", + "home.tutorials.common.auditbeatStatusCheck.buttonLabel": "Vérifier les données", + "home.tutorials.common.auditbeatStatusCheck.errorText": "Aucune donnée n'a encore été reçue.", + "home.tutorials.common.auditbeatStatusCheck.successText": "Des données ont été reçues.", + "home.tutorials.common.auditbeatStatusCheck.text": "Vérifier que des données sont reçues d'Auditbeat", + "home.tutorials.common.auditbeatStatusCheck.title": "Statut", + "home.tutorials.common.cloudInstructions.passwordAndResetLink": "Où {passwordTemplate} est le mot de passe de l'utilisateur ''elastic''.\\{#config.cloud.profileUrl\\}\n Mot de passe oublié ? [Réinitialiser dans Elastic Cloud](\\{config.cloud.baseUrl\\}\\{config.cloud.profileUrl\\}).\n \\{/config.cloud.profileUrl\\}", + "home.tutorials.common.filebeat.cloudInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.filebeat.premCloudInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.filebeat.premInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.filebeatCloudInstructions.config.debTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.filebeatCloudInstructions.config.debTitle": "Modifier la configuration", + "home.tutorials.common.filebeatCloudInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.filebeatCloudInstructions.config.osxTitle": "Modifier la configuration", + "home.tutorials.common.filebeatCloudInstructions.config.rpmTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.filebeatCloudInstructions.config.rpmTitle": "Modifier la configuration", + "home.tutorials.common.filebeatCloudInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.filebeatCloudInstructions.config.windowsTitle": "Modifier la configuration", + "home.tutorials.common.filebeatEnableInstructions.debTextPost": "Modifiez les paramètres dans le fichier ''/etc/filebeat/modules.d/{moduleName}.yml''.", + "home.tutorials.common.filebeatEnableInstructions.debTitle": "Activer et configurer le module {moduleName}", + "home.tutorials.common.filebeatEnableInstructions.osxTextPost": "Modifiez les paramètres dans le fichier ''modules.d/{moduleName}.yml''.", + "home.tutorials.common.filebeatEnableInstructions.osxTextPre": "Dans le répertoire d'installation, exécutez la commande suivante :", + "home.tutorials.common.filebeatEnableInstructions.osxTitle": "Activer et configurer le module {moduleName}", + "home.tutorials.common.filebeatEnableInstructions.rpmTextPost": "Modifiez les paramètres dans le fichier ''/etc/filebeat/modules.d/{moduleName}.yml''.", + "home.tutorials.common.filebeatEnableInstructions.rpmTitle": "Activer et configurer le module {moduleName}", + "home.tutorials.common.filebeatEnableInstructions.windowsTextPost": "Modifiez les paramètres dans le fichier ''modules.d/{moduleName}.yml''.", + "home.tutorials.common.filebeatEnableInstructions.windowsTextPre": "Dans le dossier {path}, exécutez la commande suivante :", + "home.tutorials.common.filebeatEnableInstructions.windowsTitle": "Activer et configurer le module {moduleName}", + "home.tutorials.common.filebeatInstructions.config.debTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.filebeatInstructions.config.debTitle": "Modifier la configuration", + "home.tutorials.common.filebeatInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.filebeatInstructions.config.osxTitle": "Modifier la configuration", + "home.tutorials.common.filebeatInstructions.config.rpmTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.filebeatInstructions.config.rpmTitle": "Modifier la configuration", + "home.tutorials.common.filebeatInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.filebeatInstructions.config.windowsTitle": "Modifier la configuration", + "home.tutorials.common.filebeatInstructions.install.debTextPost": "Vous cherchez les packages 32 bits ? Consultez la [page de téléchargement]({linkUrl}).", + "home.tutorials.common.filebeatInstructions.install.debTextPre": "Vous utilisez Filebeat pour la première fois ? Consultez le [guide de démarrage rapide]({linkUrl}).", + "home.tutorials.common.filebeatInstructions.install.debTitle": "Télécharger et installer Filebeat", + "home.tutorials.common.filebeatInstructions.install.osxTextPre": "Vous utilisez Filebeat pour la première fois ? Consultez le [guide de démarrage rapide]({linkUrl}).", + "home.tutorials.common.filebeatInstructions.install.osxTitle": "Télécharger et installer Filebeat", + "home.tutorials.common.filebeatInstructions.install.rpmTextPost": "Vous cherchez les packages 32 bits ? Consultez la [page de téléchargement]({linkUrl}).", + "home.tutorials.common.filebeatInstructions.install.rpmTextPre": "Vous utilisez Filebeat pour la première fois ? Consultez le [guide de démarrage rapide]({linkUrl}).", + "home.tutorials.common.filebeatInstructions.install.rpmTitle": "Télécharger et installer Filebeat", + "home.tutorials.common.filebeatInstructions.install.windowsTextPost": "Modifiez les paramètres sous {propertyName} dans le fichier {filebeatPath} afin de pointer vers votre installation Elasticsearch.", + "home.tutorials.common.filebeatInstructions.install.windowsTextPre": "Vous utilisez Filebeat pour la première fois ? Consultez le [guide de démarrage rapide]({guideLinkUrl}).\n 1. Téléchargez le fichier .zip Filebeat pour Windows via la page [Télécharger]({filebeatLinkUrl}).\n 2. Extrayez le contenu du fichier compressé sous {folderPath}.\n 3. Renommez le répertoire \"{directoryName}\" en \"Filebeat\".\n 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell.\n 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Filebeat en tant que service Windows.", + "home.tutorials.common.filebeatInstructions.install.windowsTitle": "Télécharger et installer Filebeat", + "home.tutorials.common.filebeatInstructions.start.debTextPre": "La commande ''setup'' charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", + "home.tutorials.common.filebeatInstructions.start.debTitle": "Lancer Filebeat", + "home.tutorials.common.filebeatInstructions.start.osxTextPre": "La commande ''setup'' charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", + "home.tutorials.common.filebeatInstructions.start.osxTitle": "Lancer Filebeat", + "home.tutorials.common.filebeatInstructions.start.rpmTextPre": "La commande ''setup'' charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", + "home.tutorials.common.filebeatInstructions.start.rpmTitle": "Lancer Filebeat", + "home.tutorials.common.filebeatInstructions.start.windowsTextPre": "La commande ''setup'' charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", + "home.tutorials.common.filebeatInstructions.start.windowsTitle": "Lancer Filebeat", + "home.tutorials.common.filebeatStatusCheck.buttonLabel": "Vérifier les données", + "home.tutorials.common.filebeatStatusCheck.errorText": "Aucune donnée n'a encore été reçue de ce module.", + "home.tutorials.common.filebeatStatusCheck.successText": "Des données ont été reçues de ce module.", + "home.tutorials.common.filebeatStatusCheck.text": "Vérifier que des données sont reçues du module Filebeat \"{moduleName}\"", + "home.tutorials.common.filebeatStatusCheck.title": "Statut du module", + "home.tutorials.common.functionbeat.cloudInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.functionbeat.premCloudInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.functionbeat.premInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.functionbeatAWSInstructions.textPost": "Où '''' et '''' sont vos informations d'identification et ''us-east-1'' est la région désirée.", + "home.tutorials.common.functionbeatAWSInstructions.textPre": "Définissez vos informations d'identification AWS dans l'environnement :", + "home.tutorials.common.functionbeatAWSInstructions.title": "Définir des informations d'identification AWS", + "home.tutorials.common.functionbeatCloudInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.functionbeatCloudInstructions.config.osxTitle": "Modifier la configuration", + "home.tutorials.common.functionbeatCloudInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.functionbeatCloudInstructions.config.windowsTitle": "Modifier la configuration", + "home.tutorials.common.functionbeatEnableOnPremInstructions.defaultTextPost": "Où '''' est le nom du groupe de logs à importer et '''' un nom de compartiment S3 valide pour la mise en œuvre du déploiement de Functionbeat.", + "home.tutorials.common.functionbeatEnableOnPremInstructions.defaultTitle": "Configurer le groupe de logs Cloudwatch", + "home.tutorials.common.functionbeatEnableOnPremInstructionsOSXLinux.textPre": "Modifiez les paramètres dans le fichier ''functionbeat.yml''.", + "home.tutorials.common.functionbeatEnableOnPremInstructionsWindows.textPre": "Modifiez les paramètres dans le fichier {path}.", + "home.tutorials.common.functionbeatInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.functionbeatInstructions.config.osxTitle": "Configurer le cluster Elastic", + "home.tutorials.common.functionbeatInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.functionbeatInstructions.config.windowsTitle": "Modifier la configuration", + "home.tutorials.common.functionbeatInstructions.deploy.osxTextPre": "Ceci permet d'installer Functionbeat en tant que fonction Lambda. La commande ''setup'' vérifie la configuration d'Elasticsearch et charge le modèle d'indexation Kibana. L'omission de cette commande est normalement sans risque.", + "home.tutorials.common.functionbeatInstructions.deploy.osxTitle": "Déployer Functionbeat en tant que fonction AWS Lambda", + "home.tutorials.common.functionbeatInstructions.deploy.windowsTextPre": "Ceci permet d'installer Functionbeat en tant que fonction Lambda. La commande ''setup'' vérifie la configuration d'Elasticsearch et charge le modèle d'indexation Kibana. L'omission de cette commande est normalement sans risque.", + "home.tutorials.common.functionbeatInstructions.deploy.windowsTitle": "Déployer Functionbeat en tant que fonction AWS Lambda", + "home.tutorials.common.functionbeatInstructions.install.linuxTextPre": "Vous utilisez Functionbeat pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", + "home.tutorials.common.functionbeatInstructions.install.linuxTitle": "Télécharger et installer Functionbeat", + "home.tutorials.common.functionbeatInstructions.install.osxTextPre": "Vous utilisez Functionbeat pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", + "home.tutorials.common.functionbeatInstructions.install.osxTitle": "Télécharger et installer Functionbeat", + "home.tutorials.common.functionbeatInstructions.install.windowsTextPre": "Vous utilisez Functionbeat pour la première fois ? Consultez le [guide de démarrage rapide]({functionbeatLink}).\n 1. Téléchargez le fichier .zip Functionbeat pour Windows via la page [Télécharger]({elasticLink}).\n 2. Extrayez le contenu du fichier compressé sous {folderPath}.\n 3. Renommez le répertoire \"{directoryName}\" en \"Functionbeat\".\n 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell.\n 5. Depuis l'invite PowerShell, accédez au répertoire Functionbeat :", + "home.tutorials.common.functionbeatInstructions.install.windowsTitle": "Télécharger et installer Functionbeat", + "home.tutorials.common.functionbeatStatusCheck.buttonLabel": "Vérifier les données", + "home.tutorials.common.functionbeatStatusCheck.errorText": "Aucune donnée n'a encore été reçue de Functionbeat.", + "home.tutorials.common.functionbeatStatusCheck.successText": "Des données ont été reçues de Functionbeat.", + "home.tutorials.common.functionbeatStatusCheck.text": "Vérifier que des données sont reçues de Functionbeat", + "home.tutorials.common.functionbeatStatusCheck.title": "Statut de Functionbeat", + "home.tutorials.common.heartbeat.cloudInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.heartbeat.premCloudInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.heartbeat.premInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.heartbeatCloudInstructions.config.debTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.heartbeatCloudInstructions.config.debTitle": "Modifier la configuration", + "home.tutorials.common.heartbeatCloudInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.heartbeatCloudInstructions.config.osxTitle": "Modifier la configuration", + "home.tutorials.common.heartbeatCloudInstructions.config.rpmTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.heartbeatCloudInstructions.config.rpmTitle": "Modifier la configuration", + "home.tutorials.common.heartbeatCloudInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.heartbeatCloudInstructions.config.windowsTitle": "Modifier la configuration", + "home.tutorials.common.heartbeatEnableCloudInstructions.debTextPre": "Modifiez le paramètre ''heartbeat.monitors'' dans le fichier ''heartbeat.yml''.", + "home.tutorials.common.heartbeatEnableCloudInstructions.defaultTextPost": "Pour plus d’informations sur comment configurer des moniteurs dans Heartbeat, consultez les [documents de configuration de Heartbeat.]({configureLink})", + "home.tutorials.common.heartbeatEnableCloudInstructions.defaultTitle": "Modifier la configuration – Ajouter des moniteurs", + "home.tutorials.common.heartbeatEnableCloudInstructions.osxTextPre": "Modifiez le paramètre ''heartbeat.monitors'' dans le fichier ''heartbeat.yml''.", + "home.tutorials.common.heartbeatEnableCloudInstructions.rpmTextPre": "Modifiez le paramètre ''heartbeat.monitors'' dans le fichier ''heartbeat.yml''.", + "home.tutorials.common.heartbeatEnableCloudInstructions.windowsTextPre": "Modifiez le paramètre ''heartbeat.monitors'' dans le fichier ''heartbeat.yml''.", + "home.tutorials.common.heartbeatEnableOnPremInstructions.debTextPre": "Modifiez le paramètre ''heartbeat.monitors'' dans le fichier ''heartbeat.yml''.", + "home.tutorials.common.heartbeatEnableOnPremInstructions.defaultTextPost": "Où {hostTemplate} est l’URL monitorée. Pour plus d’informations sur comment configurer des moniteurs dans Heartbeat, consultez les [documents de configuration de Heartbeat.]({configureLink})", + "home.tutorials.common.heartbeatEnableOnPremInstructions.defaultTitle": "Modifier la configuration – Ajouter des moniteurs", + "home.tutorials.common.heartbeatEnableOnPremInstructions.osxTextPre": "Modifiez le paramètre ''heartbeat.monitors'' dans le fichier ''heartbeat.yml''.", + "home.tutorials.common.heartbeatEnableOnPremInstructions.rpmTextPre": "Modifiez le paramètre ''heartbeat.monitors'' dans le fichier ''heartbeat.yml''.", + "home.tutorials.common.heartbeatEnableOnPremInstructions.windowsTextPre": "Modifiez le paramètre ''heartbeat.monitors'' dans le fichier ''heartbeat.yml''.", + "home.tutorials.common.heartbeatInstructions.config.debTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.heartbeatInstructions.config.debTitle": "Modifier la configuration", + "home.tutorials.common.heartbeatInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.heartbeatInstructions.config.osxTitle": "Modifier la configuration", + "home.tutorials.common.heartbeatInstructions.config.rpmTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.heartbeatInstructions.config.rpmTitle": "Modifier la configuration", + "home.tutorials.common.heartbeatInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.heartbeatInstructions.config.windowsTitle": "Modifier la configuration", + "home.tutorials.common.heartbeatInstructions.install.debTextPost": "Vous cherchez les packages 32 bits ? Consultez la [page de téléchargement]({link}).", + "home.tutorials.common.heartbeatInstructions.install.debTextPre": "Vous utilisez Heartbeat pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", + "home.tutorials.common.heartbeatInstructions.install.debTitle": "Télécharger et installer Heartbeat", + "home.tutorials.common.heartbeatInstructions.install.osxTextPre": "Vous utilisez Heartbeat pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", + "home.tutorials.common.heartbeatInstructions.install.osxTitle": "Télécharger et installer Heartbeat", + "home.tutorials.common.heartbeatInstructions.install.rpmTextPre": "Vous utilisez Heartbeat pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", + "home.tutorials.common.heartbeatInstructions.install.rpmTitle": "Télécharger et installer Heartbeat", + "home.tutorials.common.heartbeatInstructions.install.windowsTextPre": "Vous utilisez Heartbeat pour la première fois ? Consultez le [guide de démarrage rapide]({heartbeatLink}).\n 1. Téléchargez le fichier .zip Heartbeat pour Windows via la page [Télécharger]({elasticLink}).\n 2. Extrayez le contenu du fichier compressé sous {folderPath}.\n 3. Renommez le répertoire \"{directoryName}\" en \"Heartbeat\".\n 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell.\n 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Heartbeat en tant que service Windows.", + "home.tutorials.common.heartbeatInstructions.install.windowsTitle": "Télécharger et installer Heartbeat", + "home.tutorials.common.heartbeatInstructions.start.debTextPre": "La commande ''setup'' charge le modèle d'indexation Kibana.", + "home.tutorials.common.heartbeatInstructions.start.debTitle": "Lancer Heartbeat", + "home.tutorials.common.heartbeatInstructions.start.osxTextPre": "La commande ''setup'' charge le modèle d'indexation Kibana.", + "home.tutorials.common.heartbeatInstructions.start.osxTitle": "Lancer Heartbeat", + "home.tutorials.common.heartbeatInstructions.start.rpmTextPre": "La commande ''setup'' charge le modèle d'indexation Kibana.", + "home.tutorials.common.heartbeatInstructions.start.rpmTitle": "Lancer Heartbeat", + "home.tutorials.common.heartbeatInstructions.start.windowsTextPre": "La commande ''setup'' charge le modèle d'indexation Kibana.", + "home.tutorials.common.heartbeatInstructions.start.windowsTitle": "Lancer Heartbeat", + "home.tutorials.common.heartbeatStatusCheck.buttonLabel": "Vérifier les données", + "home.tutorials.common.heartbeatStatusCheck.errorText": "Aucune donnée n'a encore été reçue de Heartbeat.", + "home.tutorials.common.heartbeatStatusCheck.successText": "Des données ont été reçues de Heartbeat.", + "home.tutorials.common.heartbeatStatusCheck.text": "Vérifier que des données sont reçues de Heartbeat", + "home.tutorials.common.heartbeatStatusCheck.title": "Statut de Heartbeat", + "home.tutorials.common.logstashInstructions.install.java.osxTextPre": "Suivez les instructions d'installation [ici]({link}).", + "home.tutorials.common.logstashInstructions.install.java.osxTitle": "Télécharger et installer l'environnement d'exécution Java", + "home.tutorials.common.logstashInstructions.install.java.windowsTextPre": "Suivez les instructions d'installation [ici]({link}).", + "home.tutorials.common.logstashInstructions.install.java.windowsTitle": "Télécharger et installer l'environnement d'exécution Java", + "home.tutorials.common.logstashInstructions.install.logstash.osxTextPre": "Vous utilisez Logstash pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", + "home.tutorials.common.logstashInstructions.install.logstash.osxTitle": "Télécharger et installer Logstash", + "home.tutorials.common.logstashInstructions.install.logstash.windowsTextPre": "Vous utilisez Logstash pour la première fois ? Consultez le [guide de démarrage rapide]({logstashLink}).\n 1. [Téléchargez]({elasticLink}) le fichier .zip Logstash pour Windows.\n 2. Extrayez le contenu du fichier compressé.", + "home.tutorials.common.logstashInstructions.install.logstash.windowsTitle": "Télécharger et installer Logstash", + "home.tutorials.common.metricbeat.cloudInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.metricbeat.premCloudInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.metricbeat.premInstructions.gettingStarted.title": "Commencer", + "home.tutorials.common.metricbeatCloudInstructions.config.debTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.metricbeatCloudInstructions.config.debTitle": "Modifier la configuration", + "home.tutorials.common.metricbeatCloudInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.metricbeatCloudInstructions.config.osxTitle": "Modifier la configuration", + "home.tutorials.common.metricbeatCloudInstructions.config.rpmTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.metricbeatCloudInstructions.config.rpmTitle": "Modifier la configuration", + "home.tutorials.common.metricbeatCloudInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.metricbeatCloudInstructions.config.windowsTitle": "Modifier la configuration", + "home.tutorials.common.metricbeatEnableInstructions.debTextPost": "Modifiez les paramètres dans le fichier ''/etc/metricbeat/modules.d/{moduleName}.yml''.", + "home.tutorials.common.metricbeatEnableInstructions.debTitle": "Activer et configurer le module {moduleName}", + "home.tutorials.common.metricbeatEnableInstructions.osxTextPost": "Modifiez les paramètres dans le fichier ''modules.d/{moduleName}.yml''.", + "home.tutorials.common.metricbeatEnableInstructions.osxTextPre": "Dans le répertoire d'installation, exécutez la commande suivante :", + "home.tutorials.common.metricbeatEnableInstructions.osxTitle": "Activer et configurer le module {moduleName}", + "home.tutorials.common.metricbeatEnableInstructions.rpmTextPost": "Modifiez les paramètres dans le fichier ''/etc/metricbeat/modules.d/{moduleName}.yml''.", + "home.tutorials.common.metricbeatEnableInstructions.rpmTitle": "Activer et configurer le module {moduleName}", + "home.tutorials.common.metricbeatEnableInstructions.windowsTextPost": "Modifiez les paramètres dans le fichier ''modules.d/{moduleName}.yml''.", + "home.tutorials.common.metricbeatEnableInstructions.windowsTextPre": "Dans le dossier {path}, exécutez la commande suivante :", + "home.tutorials.common.metricbeatEnableInstructions.windowsTitle": "Activer et configurer le module {moduleName}", + "home.tutorials.common.metricbeatInstructions.config.debTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.metricbeatInstructions.config.debTitle": "Modifier la configuration", + "home.tutorials.common.metricbeatInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.metricbeatInstructions.config.osxTitle": "Modifier la configuration", + "home.tutorials.common.metricbeatInstructions.config.rpmTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.metricbeatInstructions.config.rpmTitle": "Modifier la configuration", + "home.tutorials.common.metricbeatInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.metricbeatInstructions.config.windowsTitle": "Modifier la configuration", + "home.tutorials.common.metricbeatInstructions.install.debTextPost": "Vous cherchez les packages 32 bits ? Consultez la [page de téléchargement]({link}).", + "home.tutorials.common.metricbeatInstructions.install.debTextPre": "Vous utilisez Metricbeat pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", + "home.tutorials.common.metricbeatInstructions.install.debTitle": "Télécharger et installer Metricbeat", + "home.tutorials.common.metricbeatInstructions.install.osxTextPre": "Vous utilisez Metricbeat pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", + "home.tutorials.common.metricbeatInstructions.install.osxTitle": "Télécharger et installer Metricbeat", + "home.tutorials.common.metricbeatInstructions.install.rpmTextPre": "Vous utilisez Metricbeat pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", + "home.tutorials.common.metricbeatInstructions.install.rpmTitle": "Télécharger et installer Metricbeat", + "home.tutorials.common.metricbeatInstructions.install.windowsTextPost": "Modifiez les paramètres sous ''output.elasticsearch'' dans le fichier {path} afin de pointer vers votre installation Elasticsearch.", + "home.tutorials.common.metricbeatInstructions.install.windowsTextPre": "Vous utilisez Metricbeat pour la première fois ? Consultez le [guide de démarrage rapide]({metricbeatLink}).\n 1. Téléchargez le fichier .zip Metricbeat pour Windows via la page [Télécharger]({elasticLink}).\n 2. Extrayez le contenu du fichier compressé sous {folderPath}.\n 3. Renommez le répertoire \"{directoryName}\" en \"Metricbeat\".\n 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell.\n 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Metricbeat en tant que service Windows.", + "home.tutorials.common.metricbeatInstructions.install.windowsTitle": "Télécharger et installer Metricbeat", + "home.tutorials.common.metricbeatInstructions.start.debTextPre": "La commande ''setup'' charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", + "home.tutorials.common.metricbeatInstructions.start.debTitle": "Lancer Metricbeat", + "home.tutorials.common.metricbeatInstructions.start.osxTextPre": "La commande ''setup'' charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", + "home.tutorials.common.metricbeatInstructions.start.osxTitle": "Lancer Metricbeat", + "home.tutorials.common.metricbeatInstructions.start.rpmTextPre": "La commande ''setup'' charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", + "home.tutorials.common.metricbeatInstructions.start.rpmTitle": "Lancer Metricbeat", + "home.tutorials.common.metricbeatInstructions.start.windowsTextPre": "La commande ''setup'' charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", + "home.tutorials.common.metricbeatInstructions.start.windowsTitle": "Lancer Metricbeat", + "home.tutorials.common.metricbeatStatusCheck.buttonLabel": "Vérifier les données", + "home.tutorials.common.metricbeatStatusCheck.errorText": "Aucune donnée n'a encore été reçue de ce module.", + "home.tutorials.common.metricbeatStatusCheck.successText": "Des données ont été reçues de ce module.", + "home.tutorials.common.metricbeatStatusCheck.text": "Vérifier que des données sont reçues du module Metricbeat \"{moduleName}\"", + "home.tutorials.common.metricbeatStatusCheck.title": "Statut du module", + "home.tutorials.common.premCloudInstructions.option1.textPre": "Rendez-vous sur [Elastic Cloud]({link}). Enregistrez-vous si vous n'avez pas encore de compte. Un essai gratuit de 14 jours est disponible.\n\nConnectez-vous à la console Elastic Cloud.\n\nPour créer un cluster, dans la console Elastic Cloud :\n 1. Sélectionnez **Créer un déploiement** et spécifiez le **Nom du déploiement**.\n 2. Modifiez les autres options de déploiement selon les besoins (sinon, les valeurs par défaut sont très bien pour commencer).\n 3. Cliquer sur **Créer un déploiement**\n 4. Attendre la fin de la création du déploiement\n 5. Accéder à la nouvelle instance cloud Kibana et suivre les instructions de la page d'accueil de Kibana", + "home.tutorials.common.premCloudInstructions.option1.title": "Option 1 : essayer dans Elastic Cloud", + "home.tutorials.common.premCloudInstructions.option2.textPre": "Si vous exécutez cette instance Kibana sur une instance Elasticsearch hébergée, passez à la configuration manuelle.\n\nEnregistrez le point de terminaison **Elasticsearch** en tant que {urlTemplate} et le cluster **Mot de passe** en tant que {passwordTemplate} pour les conserver.", + "home.tutorials.common.premCloudInstructions.option2.title": "Option 2 : connecter un Kibana local à une instance cloud", + "home.tutorials.common.winlogbeat.cloudInstructions.gettingStarted.title": "Premiers pas", + "home.tutorials.common.winlogbeat.premCloudInstructions.gettingStarted.title": "Premiers pas", + "home.tutorials.common.winlogbeat.premInstructions.gettingStarted.title": "Premiers pas", + "home.tutorials.common.winlogbeatCloudInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", + "home.tutorials.common.winlogbeatCloudInstructions.config.windowsTitle": "Modifier la configuration", + "home.tutorials.common.winlogbeatInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion :", + "home.tutorials.common.winlogbeatInstructions.config.windowsTitle": "Modifier la configuration", + "home.tutorials.common.winlogbeatInstructions.install.windowsTextPost": "Modifiez les paramètres sous \"output.elasticsearch\" dans le fichier {path} afin de pointer vers votre installation Elasticsearch.", + "home.tutorials.common.winlogbeatInstructions.install.windowsTextPre": "Vous utilisez Winlogbeat pour la première fois ? Consultez le [guide de démarrage rapide]({winlogbeatLink}).\n 1. Téléchargez le fichier .zip Winlogbeat pour Windows via la page [Télécharger]({elasticLink}).\n 2. Extrayez le contenu du fichier compressé sous {folderPath}.\n 3. Renommez le répertoire \"{directoryName}\" en \"Winlogbeat\".\n 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell.\n 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Winlogbeat en tant que service Windows.", + "home.tutorials.common.winlogbeatInstructions.install.windowsTitle": "Télécharger et installer Winlogbeat", + "home.tutorials.common.winlogbeatInstructions.start.windowsTextPre": "La commande \"setup\" charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", + "home.tutorials.common.winlogbeatInstructions.start.windowsTitle": "Lancer Winlogbeat", + "home.tutorials.common.winlogbeatStatusCheck.buttonLabel": "Vérifier les données", + "home.tutorials.common.winlogbeatStatusCheck.errorText": "Aucune donnée n'a encore été reçue.", + "home.tutorials.common.winlogbeatStatusCheck.successText": "Des données ont été reçues.", + "home.tutorials.common.winlogbeatStatusCheck.text": "Vérifier que des données sont reçues de Winlogbeat", + "home.tutorials.common.winlogbeatStatusCheck.title": "Statut du module", + "home.tutorials.consulMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs Consul", + "home.tutorials.consulMetrics.longDescription": "Le module Metricbeat \"consul\" récupère des indicateurs de monitoring depuis Consul. [En savoir plus]({learnMoreLink}).", + "home.tutorials.consulMetrics.nameTitle": "Indicateurs Consul", + "home.tutorials.consulMetrics.shortDescription": "Récupérez des indicateurs de monitoring depuis le serveur Consul.", + "home.tutorials.corednsLogs.artifacts.dashboards.linkLabel": "Aperçu de [Filebeat CoreDNS]", + "home.tutorials.corednsLogs.longDescription": "Il s'agit d'un module Filebeat pour CoreDNS. Celui-ci prend en charge les déploiements CoreDNS autonomes et les déploiements CoreDNS dans Kubernetes. [En savoir plus]({learnMoreLink}).", + "home.tutorials.corednsLogs.nameTitle": "Logs CoreDNS", + "home.tutorials.corednsLogs.shortDescription": "Collectez les logs CoreDNS.", + "home.tutorials.corednsMetrics.artifacts.application.label": "Discover", + "home.tutorials.corednsMetrics.longDescription": "Le module Metricbeat \"coredns\" récupère des indicateurs de monitoring depuis CoreDNS. [En savoir plus]({learnMoreLink}).", + "home.tutorials.corednsMetrics.nameTitle": "Indicateurs CoreDNS", + "home.tutorials.corednsMetrics.shortDescription": "Récupérez des indicateurs de monitoring depuis le serveur CoreDNS.", + "home.tutorials.couchbaseMetrics.artifacts.application.label": "Discover", + "home.tutorials.couchbaseMetrics.longDescription": "Le module Metricbeat \"couchbase\" récupère des indicateurs internes depuis Couchbase. [En savoir plus]({learnMoreLink}).", + "home.tutorials.couchbaseMetrics.nameTitle": "Indicateurs Couchbase", + "home.tutorials.couchbaseMetrics.shortDescription": "Récupérez des indicateurs internes depuis Couchbase.", + "home.tutorials.couchdbMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs CouchDB", + "home.tutorials.couchdbMetrics.longDescription": "Le module Metricbeat \"couchdb\" récupère des indicateurs de monitoring depuis CouchDB. [En savoir plus]({learnMoreLink}).", + "home.tutorials.couchdbMetrics.nameTitle": "Indicateurs CouchDB", + "home.tutorials.couchdbMetrics.shortDescription": "Récupérez des indicateurs de monitoring depuis le serveur CouchdB.", + "home.tutorials.crowdstrikeLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.crowdstrikeLogs.longDescription": "Il s'agit du module Filebeat pour CrowdStrike Falcon utilisant le [connecteur SIEM](https://www.crowdstrike.com/blog/tech-center/integrate-with-your-siem) Falcon. Ce module collecte ces données, les convertit en ECS et les ingère pour les afficher dans le SIEM. Par défaut, le connecteur SIEM Falcon génère les données d'événement de l'API de streaming Falcon au format JSON. [En savoir plus]({learnMoreLink}).", + "home.tutorials.crowdstrikeLogs.nameTitle": "Logs CrowdStrike", + "home.tutorials.crowdstrikeLogs.shortDescription": "Collectez des logs CrowdStrike Falcon à l'aide du connecteur SIEM Falcon.", + "home.tutorials.cylanceLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.cylanceLogs.longDescription": "Ce module permet de recevoir des logs CylancePROTECT par le biais de Syslog ou d'un fichier. [En savoir plus]({learnMoreLink}).", + "home.tutorials.cylanceLogs.nameTitle": "Logs CylancePROTECT", + "home.tutorials.cylanceLogs.shortDescription": "Collectez des logs CylancePROTECT par le biais de Syslog ou d’un fichier.", + "home.tutorials.dockerMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs Docker", + "home.tutorials.dockerMetrics.longDescription": "Le module Metricbeat \"docker\" récupère des indicateurs depuis le serveur Docker. [En savoir plus]({learnMoreLink}).", + "home.tutorials.dockerMetrics.nameTitle": "Indicateurs Docker", + "home.tutorials.dockerMetrics.shortDescription": "Récupérez des indicateurs concernant vos conteneurs Docker.", + "home.tutorials.dropwizardMetrics.artifacts.application.label": "Discover", + "home.tutorials.dropwizardMetrics.longDescription": "Le module Metricbeat \"dropwizard\" récupère des indicateurs internes depuis l'application Java Dropwizard. [En savoir plus]({learnMoreLink}).", + "home.tutorials.dropwizardMetrics.nameTitle": "Indicateurs Dropwizard", + "home.tutorials.dropwizardMetrics.shortDescription": "Récupérez des indicateurs internes depuis l'application Java Dropwizard.", + "home.tutorials.elasticsearchLogs.artifacts.application.label": "Discover", + "home.tutorials.elasticsearchLogs.longDescription": "Le module Filebeat \"elasticsearch\" analyse les logs créés par Elasticsearch. [En savoir plus]({learnMoreLink}).", + "home.tutorials.elasticsearchLogs.nameTitle": "Logs Elasticsearch", + "home.tutorials.elasticsearchLogs.shortDescription": "Collectez et analysez les logs créés par Elasticsearch.", + "home.tutorials.elasticsearchMetrics.artifacts.application.label": "Discover", + "home.tutorials.elasticsearchMetrics.longDescription": "Le module Metricbeat \"elasticsearch\" récupère des indicateurs internes depuis Elasticsearch. [En savoir plus]({learnMoreLink}).", + "home.tutorials.elasticsearchMetrics.nameTitle": "Indicateurs Elasticsearch", + "home.tutorials.elasticsearchMetrics.shortDescription": "Récupérez des indicateurs internes depuis Elasticsearch.", + "home.tutorials.envoyproxyLogs.artifacts.dashboards.linkLabel": "Aperçu d'Envoy Proxy", + "home.tutorials.envoyproxyLogs.longDescription": "Il s'agit d'un module Filebeat pour le log d'accès à Envoy Proxy (https://www.envoyproxy.io/docs/envoy/v1.10.0/configuration/access_log). Celui-ci prend en charge les déploiements autonomes et les déploiements Envoy Proxy dans Kubernetes. [Learn more]({learnMoreLink}).", + "home.tutorials.envoyproxyLogs.nameTitle": "Logs Envoy Proxy", + "home.tutorials.envoyproxyLogs.shortDescription": "Collectez des logs Envoy Proxy.", + "home.tutorials.envoyproxyMetrics.longDescription": "Le module Metricbeat \"envoyproxy\" récupère des indicateurs de monitoring depuis Envoy Proxy. [En savoir plus]({learnMoreLink}).", + "home.tutorials.envoyproxyMetrics.nameTitle": "Indicateurs Envoy Proxy", + "home.tutorials.envoyproxyMetrics.shortDescription": "Récupérez des indicateurs de monitoring depuis Envoy Proxy.", + "home.tutorials.etcdMetrics.artifacts.application.label": "Discover", + "home.tutorials.etcdMetrics.longDescription": "Le module Metricbeat \"etcd\" récupère des indicateurs internes depuis Etcd. [En savoir plus]({learnMoreLink}).", + "home.tutorials.etcdMetrics.nameTitle": "Indicateurs Etcd", + "home.tutorials.etcdMetrics.shortDescription": "Récupérez des indicateurs internes depuis le serveur Etcd.", + "home.tutorials.f5Logs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.f5Logs.longDescription": "Ce module permet de recevoir des logs Big-IP Access Policy Manager par le biais de Syslog ou d'un fichier. [En savoir plus]({learnMoreLink}).", + "home.tutorials.f5Logs.nameTitle": "Logs F5", + "home.tutorials.f5Logs.shortDescription": "Collectez des logs F5 Big-IP Access Policy Manager par le biais de Syslog ou d’un fichier.", + "home.tutorials.fortinetLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.fortinetLogs.longDescription": "Il s'agit d'un module pour les logs Fortinet FortiOS envoyés au format Syslog. [En savoir plus]({learnMoreLink}).", + "home.tutorials.fortinetLogs.nameTitle": "Logs Fortinet", + "home.tutorials.fortinetLogs.shortDescription": "Collectez des logs Fortinet FortiOS par le biais de Syslog.", + "home.tutorials.gcpLogs.artifacts.dashboards.linkLabel": "Tableau de bord des logs d'audit", + "home.tutorials.gcpLogs.longDescription": "Il s'agit d'un module pour les logs Google Cloud. Il prend en charge la lecture des logs d'audit, de flux VPC et de pare-feu qui ont été exportés depuis Stackdriver dans un récepteur de rubriques Google Pub/Sub. [En savoir plus]({learnMoreLink}).", + "home.tutorials.gcpLogs.nameTitle": "Logs Google Cloud", + "home.tutorials.gcpLogs.shortDescription": "Collectez des logs d'audit, de pare-feu et de flux VPC Google Cloud.", + "home.tutorials.gcpMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs Google Cloud", + "home.tutorials.gcpMetrics.longDescription": "Le module Metricbeat \"gcp\" récupère des indicateurs de monitoring depuis Google Cloud Platform à l'aide de l'API de monitoring Stackdriver. [En savoir plus]({learnMoreLink}).", + "home.tutorials.gcpMetrics.nameTitle": "Indicateurs Google Cloud", + "home.tutorials.gcpMetrics.shortDescription": "Récupérez des indicateurs de monitoring depuis Google Cloud Platform à l'aide de l'API de monitoring Stackdriver.", + "home.tutorials.golangMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs Golang", + "home.tutorials.golangMetrics.longDescription": "Le module Metricbeat \"{moduleName}\" récupère des indicateurs internes depuis une application Golang. [En savoir plus]({learnMoreLink}).", + "home.tutorials.golangMetrics.nameTitle": "Indicateurs Golang", + "home.tutorials.golangMetrics.shortDescription": "Récupérez des indicateurs internes depuis une application Golang.", + "home.tutorials.gsuiteLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.gsuiteLogs.longDescription": "Il s'agit d'un module pour l'ingestion de données depuis les différentes API de rapports d'audit GSuite. [En savoir plus]({learnMoreLink}).", + "home.tutorials.gsuiteLogs.nameTitle": "Logs GSuite", + "home.tutorials.gsuiteLogs.shortDescription": "Collectez des rapports d'activité GSuite.", + "home.tutorials.haproxyLogs.artifacts.dashboards.linkLabel": "Aperçu de HAProxy", + "home.tutorials.haproxyLogs.longDescription": "Le module collecte et analyse les logs d'un processus (\"haproxy\") [En savoir plus]({learnMoreLink}).", + "home.tutorials.haproxyLogs.nameTitle": "Logs HAProxy", + "home.tutorials.haproxyLogs.shortDescription": "Collectez des logs HAProxy.", + "home.tutorials.haproxyMetrics.artifacts.application.label": "Discover", + "home.tutorials.haproxyMetrics.longDescription": "Le module Metricbeat \"haproxy\" récupère des indicateurs internes depuis HAProxy. [En savoir plus]({learnMoreLink}).", + "home.tutorials.haproxyMetrics.nameTitle": "Indicateurs HAProxy", + "home.tutorials.haproxyMetrics.shortDescription": "Récupérez des indicateurs internes depuis le serveur HAProxy.", + "home.tutorials.ibmmqLogs.artifacts.dashboards.linkLabel": "Événements IBM MQ", + "home.tutorials.ibmmqLogs.longDescription": "Collectez des logs IBM MQ avec Filebeat. [En savoir plus]({learnMoreLink}).", + "home.tutorials.ibmmqLogs.nameTitle": "Logs IBM MQ", + "home.tutorials.ibmmqLogs.shortDescription": "Collectez des logs IBM MQ avec Filebeat.", + "home.tutorials.ibmmqMetrics.artifacts.application.label": "Discover", + "home.tutorials.ibmmqMetrics.longDescription": "Le module Metricbeat \"ibmmq\" récupère des indicateurs de monitoring depuis les instances IBM MQ. [En savoir plus]({learnMoreLink}).", + "home.tutorials.ibmmqMetrics.nameTitle": "Indicateurs IBM MQ", + "home.tutorials.ibmmqMetrics.shortDescription": "Récupérez des indicateurs de monitoring depuis les instances IBM MQ.", + "home.tutorials.icingaLogs.artifacts.dashboards.linkLabel": "Log principal Icinga", + "home.tutorials.icingaLogs.longDescription": "Le module analyse le log principal et les logs de débogage et de démarrage d'[Icinga](https://www.icinga.com/products/icinga-2/). [En savoir plus]({learnMoreLink}).", + "home.tutorials.icingaLogs.nameTitle": "Logs Icinga", + "home.tutorials.icingaLogs.shortDescription": "Collectez le log principal et les logs de débogage et de démarrage d'Icinga.", + "home.tutorials.iisLogs.artifacts.dashboards.linkLabel": "Tableau de bord des logs IIS", + "home.tutorials.iisLogs.longDescription": "Le module Filebeat \"iis\" analyse les logs d'accès et d'erreurs créés par le serveur HTTP IIS. [En savoir plus]({learnMoreLink}).", + "home.tutorials.iisLogs.nameTitle": "Logs IIS", + "home.tutorials.iisLogs.shortDescription": "Collectez et analysez les logs d'accès et d'erreurs créés par le serveur HTTP IIS.", + "home.tutorials.iisMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs IIS", + "home.tutorials.iisMetrics.longDescription": "Le module Metricbeat \"iis\" collecte les indicateurs du serveur IIS ainsi que des sites web et des pools d'applications en cours d'exécution. [En savoir plus]({learnMoreLink}).", + "home.tutorials.iisMetrics.nameTitle": "Indicateurs IIS", + "home.tutorials.iisMetrics.shortDescription": "Collectez les indicateurs en lien avec le serveur IIS.", + "home.tutorials.impervaLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.impervaLogs.longDescription": "Ce module permet de recevoir des logs Imperva SecureSphere par le biais de Syslog ou d'un fichier. [En savoir plus]({learnMoreLink}).", + "home.tutorials.impervaLogs.nameTitle": "Logs Imperva", + "home.tutorials.impervaLogs.shortDescription": "Collectez des logs Imperva SecureSphere par le biais de Syslog ou d’un fichier.", + "home.tutorials.infobloxLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.infobloxLogs.longDescription": "Ce module permet de recevoir des logs Infoblox NIOS par le biais de Syslog ou d'un fichier. [En savoir plus]({learnMoreLink}).", + "home.tutorials.infobloxLogs.nameTitle": "Logs Infoblox", + "home.tutorials.infobloxLogs.shortDescription": "Collectez des logs Infoblox NIOS par le biais de Syslog ou d’un fichier.", + "home.tutorials.iptablesLogs.artifacts.dashboards.linkLabel": "Aperçu d'Iptables", + "home.tutorials.iptablesLogs.longDescription": "Il s'agit d'un module pour les logs iptables et ip6tables. Il analyse les logs reçus via le réseau par le biais de Syslog ou d’un fichier. En outre, il comprend le préfixe ajouté par certains pare-feux Ubiquiti qui contient le nom de l'ensemble de règles, le numéro de règle et l'action effectuée sur le trafic (autoriser/refuser). [En savoir plus]({learnMoreLink}).", + "home.tutorials.iptablesLogs.nameTitle": "Logs Iptables", + "home.tutorials.iptablesLogs.shortDescription": "Collectez des logs iptables et ip6tables.", + "home.tutorials.juniperLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.juniperLogs.longDescription": "Ce module permet de recevoir des logs Juniper JUNOS par le biais de Syslog ou d'un fichier. [En savoir plus]({learnMoreLink}).", + "home.tutorials.juniperLogs.nameTitle": "Logs Juniper", + "home.tutorials.juniperLogs.shortDescription": "Collectez des logs Juniper JUNOS par le biais de Syslog ou d’un fichier.", + "home.tutorials.kafkaLogs.artifacts.dashboards.linkLabel": "Tableau de bord des logs Kafka", + "home.tutorials.kafkaLogs.longDescription": "Le module Filebeat \"kafka\" analyse les logs créés par Kafka. [En savoir plus]({learnMoreLink}).", + "home.tutorials.kafkaLogs.nameTitle": "Logs Kafka", + "home.tutorials.kafkaLogs.shortDescription": "Collectez et analysez les logs créés par Kafka.", + "home.tutorials.kafkaMetrics.artifacts.application.label": "Discover", + "home.tutorials.kafkaMetrics.longDescription": "Le module Metricbeat \"kafka\" récupère des indicateurs internes depuis Kafka. [En savoir plus]({learnMoreLink}).", + "home.tutorials.kafkaMetrics.nameTitle": "Indicateurs Kafka", + "home.tutorials.kafkaMetrics.shortDescription": "Récupérez des indicateurs internes depuis le serveur Kafka.", + "home.tutorials.kibanaLogs.artifacts.application.label": "Discover", + "home.tutorials.kibanaLogs.longDescription": "Il s'agit du module Kibana. [En savoir plus]({learnMoreLink}).", + "home.tutorials.kibanaLogs.nameTitle": "Logs Kibana", + "home.tutorials.kibanaLogs.shortDescription": "Collectez des logs Kibana.", + "home.tutorials.kibanaMetrics.artifacts.application.label": "Discover", + "home.tutorials.kibanaMetrics.longDescription": "Le module Metricbeat \"kibana\" récupère des indicateurs internes depuis Kibana. [En savoir plus]({learnMoreLink}).", + "home.tutorials.kibanaMetrics.nameTitle": "Indicateurs Kibana", + "home.tutorials.kibanaMetrics.shortDescription": "Récupérez des indicateurs internes depuis Kibana.", + "home.tutorials.kubernetesMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs Kubernetes", + "home.tutorials.kubernetesMetrics.longDescription": "Le module Metricbeat \"kubernetes\" récupère des indicateurs depuis les API Kubernetes. [En savoir plus]({learnMoreLink}).", + "home.tutorials.kubernetesMetrics.nameTitle": "Indicateurs Kubernetes", + "home.tutorials.kubernetesMetrics.shortDescription": "Récupérez des indicateurs depuis votre installation Kubernetes.", + "home.tutorials.logstashLogs.artifacts.dashboards.linkLabel": "Logs Logstash", + "home.tutorials.logstashLogs.longDescription": "Le module analyse les logs standard et le log de requêtes lentes Logstash. Il prend en charge les formats texte brut et JSON. [En savoir plus]({learnMoreLink}).", + "home.tutorials.logstashLogs.nameTitle": "Logs Logstash", + "home.tutorials.logstashLogs.shortDescription": "Collectez le log principal et le log de requêtes lentes Logstash.", + "home.tutorials.logstashMetrics.artifacts.application.label": "Discover", + "home.tutorials.logstashMetrics.longDescription": "Le module Metricbeat \"{moduleName}\" récupère des indicateurs internes depuis un serveur Logstash. [En savoir plus]({learnMoreLink}).", + "home.tutorials.logstashMetrics.nameTitle": "Indicateurs Logstash", + "home.tutorials.logstashMetrics.shortDescription": "Récupérez des indicateurs internes depuis un serveur Logstash.", + "home.tutorials.memcachedMetrics.artifacts.application.label": "Discover", + "home.tutorials.memcachedMetrics.longDescription": "Le module Metricbeat \"memcached\" récupère des indicateurs internes depuis Memcached. [En savoir plus]({learnMoreLink}).", + "home.tutorials.memcachedMetrics.nameTitle": "Indicateurs Memcached", + "home.tutorials.memcachedMetrics.shortDescription": "Récupérez des indicateurs internes depuis le serveur Memcached.", + "home.tutorials.microsoftLogs.artifacts.dashboards.linkLabel": "Aperçu de Microsoft ATP", + "home.tutorials.microsoftLogs.longDescription": "Collectez des alertes Microsoft Defender ATP pour les utiliser avec Elastic Security [En savoir plus]({learnMoreLink}).", + "home.tutorials.microsoftLogs.nameTitle": "Logs Microsoft Defender ATP", + "home.tutorials.microsoftLogs.shortDescription": "Collectez des alertes Microsoft Defender ATP.", + "home.tutorials.mispLogs.artifacts.dashboards.linkLabel": "Aperçu de MISP", + "home.tutorials.mispLogs.longDescription": "Il s'agit d'un module Filebeat pour la lecture des informations de Threat Intelligence depuis la plateforme MISP (https://www.circl.lu/doc/misp/). Il utilise l'entrée httpjson pour accéder à l'interface d'API REST MISP. [En savoir plus]({learnMoreLink}).", + "home.tutorials.mispLogs.nameTitle": "Logs de Threat Intelligence MISP", + "home.tutorials.mispLogs.shortDescription": "Collectez des données de Threat Intelligence MISP avec Filebeat.", + "home.tutorials.mongodbLogs.artifacts.dashboards.linkLabel": "Aperçu de MongoDB", + "home.tutorials.mongodbLogs.longDescription": "Le module collecte et analyse les logs créés par [MongoDB](https://www.mongodb.com/). [En savoir plus]({learnMoreLink}).", + "home.tutorials.mongodbLogs.nameTitle": "Logs MongoDB", + "home.tutorials.mongodbLogs.shortDescription": "Collectez des logs MongoDB.", + "home.tutorials.mongodbMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs MongoDB", + "home.tutorials.mongodbMetrics.longDescription": "Le module Metricbeat \"mongodb\" récupère des indicateurs internes depuis le serveur MongoDB. [En savoir plus]({learnMoreLink}).", + "home.tutorials.mongodbMetrics.nameTitle": "Indicateurs MongoDB", + "home.tutorials.mongodbMetrics.shortDescription": "Récupérez des indicateurs internes depuis MongoDB.", + "home.tutorials.mssqlLogs.artifacts.application.label": "Discover", + "home.tutorials.mssqlLogs.longDescription": "Le module analyse les logs d'erreurs créés par MSSQL. [En savoir plus]({learnMoreLink}).", + "home.tutorials.mssqlLogs.nameTitle": "Logs MSSQL", + "home.tutorials.mssqlLogs.shortDescription": "Collectez des logs MSSQL.", + "home.tutorials.mssqlMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs Microsoft SQL Server", + "home.tutorials.mssqlMetrics.longDescription": "Le module Metricbeat \"mssql\" récupère des indicateurs de monitoring, de logs et de performances depuis une instance Microsoft SQL Server. [En savoir plus]({learnMoreLink}).", + "home.tutorials.mssqlMetrics.nameTitle": "Indicateurs Microsoft SQL Server", + "home.tutorials.mssqlMetrics.shortDescription": "Récupérez des indicateurs de monitoring depuis une instance Microsoft SQL Server.", + "home.tutorials.muninMetrics.artifacts.application.label": "Discover", + "home.tutorials.muninMetrics.longDescription": "Le module Metricbeat \"munin\" récupère des indicateurs internes depuis Munin. [En savoir plus]({learnMoreLink}).", + "home.tutorials.muninMetrics.nameTitle": "Indicateurs Munin", + "home.tutorials.muninMetrics.shortDescription": "Récupérez des indicateurs internes depuis le serveur Munin.", + "home.tutorials.mysqlLogs.artifacts.dashboards.linkLabel": "Tableau de bord des logs MySQL", + "home.tutorials.mysqlLogs.longDescription": "Le module Filebeat \"mysql\" analyse les logs d'erreurs et de requêtes lentes créés par MySQL. [En savoir plus]({learnMoreLink}).", + "home.tutorials.mysqlLogs.nameTitle": "Logs MySQL", + "home.tutorials.mysqlLogs.shortDescription": "Collectez et analysez les logs d'erreurs et de requêtes lentes créés par MySQL.", + "home.tutorials.mysqlMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs MySQL", + "home.tutorials.mysqlMetrics.longDescription": "Le module Metricbeat \"mysql\" récupère des indicateurs internes depuis le serveur MySQL. [En savoir plus]({learnMoreLink}).", + "home.tutorials.mysqlMetrics.nameTitle": "Indicateurs MySQL", + "home.tutorials.mysqlMetrics.shortDescription": "Récupérez des indicateurs internes depuis MySQL.", + "home.tutorials.natsLogs.artifacts.dashboards.linkLabel": "Tableau de bord des logs NATS", + "home.tutorials.natsLogs.longDescription": "Le module Filebeat \"nats\" analyse les logs créés par NATS. [En savoir plus]({learnMoreLink}).", + "home.tutorials.natsLogs.nameTitle": "Logs NATS", + "home.tutorials.natsLogs.shortDescription": "Collectez et analysez les logs créés par NATS.", + "home.tutorials.natsMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs NATS", + "home.tutorials.natsMetrics.longDescription": "Le module Metricbeat \"nats\" récupère des indicateurs de monitoring depuis NATS. [En savoir plus]({learnMoreLink}).", + "home.tutorials.natsMetrics.nameTitle": "Indicateurs NATS", + "home.tutorials.natsMetrics.shortDescription": "Récupérez des indicateurs de monitoring depuis le serveur NATS.", + "home.tutorials.netflowLogs.artifacts.dashboards.linkLabel": "Aperçu de Netflow", + "home.tutorials.netflowLogs.longDescription": "Ce module permet de recevoir des enregistrements de flux NetFlow et IPFIX via UDP. Cette entrée prend en charge les versions 1, 5, 6, 7, 8 et 9 de NetFlow ainsi qu'IPFIX. Pour les versions de NetFlow antérieures à la version 9, les champs sont automatiquement mappés vers NetFlow v9. [En savoir plus]({learnMoreLink})", + "home.tutorials.netflowLogs.nameTitle": "Collecteur IPFIX/NetFlow", + "home.tutorials.netflowLogs.shortDescription": "Collectez des enregistrements de flux NetFlow et IPFIX.", + "home.tutorials.netscoutLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.netscoutLogs.longDescription": "Ce module permet de recevoir des logs Arbor Peakflow SP par le biais de Syslog ou d'un fichier. [En savoir plus]({learnMoreLink}).", + "home.tutorials.netscoutLogs.nameTitle": "Logs Arbor Peakflow", + "home.tutorials.netscoutLogs.shortDescription": "Collectez des logs Netscout Arbor Peakflow SP par le biais de Syslog ou d’un fichier.", + "home.tutorials.nginxLogs.artifacts.dashboards.linkLabel": "Tableau de bord des logs Nginx", + "home.tutorials.nginxLogs.longDescription": "Le module Filebeat \"nginx\" analyse les logs d'accès et d'erreurs créés par le serveur HTTP Nginx. [En savoir plus]({learnMoreLink}).", + "home.tutorials.nginxLogs.nameTitle": "Logs Nginx", + "home.tutorials.nginxLogs.shortDescription": "Collectez et analysez les logs d'accès et d'erreurs créés par le serveur HTTP Nginx.", + "home.tutorials.nginxMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs Nginx", + "home.tutorials.nginxMetrics.longDescription": "Le module Metricbeat \"nginx\" récupère des indicateurs internes depuis le serveur HTTP Nginx. Le module récupère les données de statut du serveur depuis la page web générée par {statusModuleLink}, qui doit être activé dans votre installation Nginx. [En savoir plus]({learnMoreLink}).", + "home.tutorials.nginxMetrics.nameTitle": "Indicateurs Nginx", + "home.tutorials.nginxMetrics.shortDescription": "Récupérez des indicateurs internes depuis le serveur HTTP Nginx.", + "home.tutorials.o365Logs.artifacts.dashboards.linkLabel": "Tableau de bord des audits O365", + "home.tutorials.o365Logs.longDescription": "Il s'agit d'un module pour les logs Office 365 reçus via l'un des points de terminaison d'API Office 365. Actuellement, il prend en charge les actions et les événements utilisateur, administrateur, système et de politique depuis les logs d’activité Office 365 et Azure AD exposés par l'API d’activité de gestion Office 365. [En savoir plus]({learnMoreLink}).", + "home.tutorials.o365Logs.nameTitle": "Logs Office 365", + "home.tutorials.o365Logs.shortDescription": "Collectez les logs d'activité Office 365 via l'API Office 365.", + "home.tutorials.oktaLogs.artifacts.dashboards.linkLabel": "Aperçu d'Okta", + "home.tutorials.oktaLogs.longDescription": "Le module Okta collecte les événements de l'[API Okta](https://developer.okta.com/docs/reference/). Plus précisément, il prend en charge la lecture depuis l'[API de log système Okta](https://developer.okta.com/docs/reference/api/system-log/). [En savoir plus]({learnMoreLink}).", + "home.tutorials.oktaLogs.nameTitle": "Logs Okta", + "home.tutorials.oktaLogs.shortDescription": "Collectez le log système Okta via l'API Okta.", + "home.tutorials.openmetricsMetrics.longDescription": "Le module Metricbeat \"openmetrics\" récupère des indicateurs depuis un point de terminaison fournissant des indicateurs au format OpenMetrics. [En savoir plus]({learnMoreLink}).", + "home.tutorials.openmetricsMetrics.nameTitle": "Indicateurs OpenMetrics", + "home.tutorials.openmetricsMetrics.shortDescription": "Récupérez des indicateurs depuis un point de terminaison fournissant des indicateurs au format OpenMetrics.", + "home.tutorials.oracleMetrics.artifacts.application.label": "Discover", + "home.tutorials.oracleMetrics.longDescription": "Le module Metricbeat \"{moduleName}\" récupère des indicateurs internes depuis un serveur Oracle. [En savoir plus]({learnMoreLink}).", + "home.tutorials.oracleMetrics.nameTitle": "Indicateurs Oracle", + "home.tutorials.oracleMetrics.shortDescription": "Récupérez des indicateurs internes depuis un serveur Oracle.", + "home.tutorials.osqueryLogs.artifacts.dashboards.linkLabel": "Pack de conformité osquery", + "home.tutorials.osqueryLogs.longDescription": "Le module collecte et décode les logs de résultats écrits par [osqueryd](https://osquery.readthedocs.io/en/latest/introduction/using-osqueryd/) au format JSON. Pour configurer \"osqueryd\", suivez les instructions d'installation d'osquery pour votre système d'exploitation et configurez le pilote de logging \"filesystem\" (celui par défaut). Assurez-vous que les horodatages UTC sont activés. [En savoir plus]({learnMoreLink}).", + "home.tutorials.osqueryLogs.nameTitle": "Logs osquery", + "home.tutorials.osqueryLogs.shortDescription": "Collectez des logs osquery au format JSON.", + "home.tutorials.panwLogs.artifacts.dashboards.linkLabel": "Flux de réseau PANW", + "home.tutorials.panwLogs.longDescription": "Il s'agit d'un module pour les logs de monitoring des pare-feux Palo Alto Networks PAN-OS reçus par le biais de Syslog ou lus depuis un fichier. Actuellement, il prend en charge les messages de type Trafic et Menaces. [En savoir plus]({learnMoreLink}).", + "home.tutorials.panwLogs.nameTitle": "Logs Palo Alto Networks PAN-OS", + "home.tutorials.panwLogs.shortDescription": "Collectez des logs Palo Alto Networks PAN-OS relatifs aux menaces et au trafic par le biais de Syslog ou d’un fichier log.", + "home.tutorials.phpFpmMetrics.longDescription": "Le module Metricbeat \"php_fpm\" récupère des indicateurs internes depuis le serveur PHP-FPM. [En savoir plus]({learnMoreLink}).", + "home.tutorials.phpFpmMetrics.nameTitle": "Indicateurs PHP-FPM", + "home.tutorials.phpFpmMetrics.shortDescription": "Récupérez des indicateurs internes depuis PHP-FPM.", + "home.tutorials.postgresqlLogs.artifacts.dashboards.linkLabel": "Tableau de bord des logs PostgreSQL", + "home.tutorials.postgresqlLogs.longDescription": "Le module Filebeat \"postgresql\" analyse les logs d'erreurs et de requêtes lentes créés par PostgreSQL. [En savoir plus]({learnMoreLink}).", + "home.tutorials.postgresqlLogs.nameTitle": "Logs PostgreSQL", + "home.tutorials.postgresqlLogs.shortDescription": "Collectez et analysez les logs d'erreurs et de requêtes lentes créés par PostgreSQL.", + "home.tutorials.postgresqlMetrics.longDescription": "Le module Metricbeat \"postgresql\" récupère des indicateurs internes depuis le serveur PostgreSQL. [En savoir plus]({learnMoreLink}).", + "home.tutorials.postgresqlMetrics.nameTitle": "Indicateurs PostgreSQL", + "home.tutorials.postgresqlMetrics.shortDescription": "Récupérez des indicateurs internes depuis PostgreSQL.", + "home.tutorials.prometheusMetrics.artifacts.application.label": "Discover", + "home.tutorials.prometheusMetrics.longDescription": "Le module Metricbeat \"{moduleName}\" récupère des indicateurs depuis le point de terminaison Prometheus. [En savoir plus]({learnMoreLink}).", + "home.tutorials.prometheusMetrics.nameTitle": "Indicateurs Prometheus", + "home.tutorials.prometheusMetrics.shortDescription": "Récupérez des indicateurs depuis un exportateur Prometheus.", + "home.tutorials.rabbitmqLogs.artifacts.application.label": "Discover", + "home.tutorials.rabbitmqLogs.longDescription": "Ce module permet d'analyser les [fichiers log RabbitMQ](https://www.rabbitmq.com/logging.html). [En savoir plus]({learnMoreLink}).", + "home.tutorials.rabbitmqLogs.nameTitle": "Logs RabbitMQ", + "home.tutorials.rabbitmqLogs.shortDescription": "Collectez des logs RabbitMQ.", + "home.tutorials.rabbitmqMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs RabbitMQ", + "home.tutorials.rabbitmqMetrics.longDescription": "Le module Metricbeat \"rabbitmq\" récupère des indicateurs internes depuis le serveur RabbitMQ. [En savoir plus]({learnMoreLink}).", + "home.tutorials.rabbitmqMetrics.nameTitle": "Indicateurs RabbitMQ", + "home.tutorials.rabbitmqMetrics.shortDescription": "Récupérez des indicateurs internes depuis le serveur RabbitMQ.", + "home.tutorials.radwareLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.radwareLogs.longDescription": "Ce module permet de recevoir des logs Radware DefensePro par le biais de Syslog ou d'un fichier. [En savoir plus]({learnMoreLink}).", + "home.tutorials.radwareLogs.nameTitle": "Logs Radware DefensePro", + "home.tutorials.radwareLogs.shortDescription": "Collectez des logs Radware DefensePro par le biais de Syslog ou d’un fichier.", + "home.tutorials.redisenterpriseMetrics.artifacts.application.label": "Discover", + "home.tutorials.redisenterpriseMetrics.longDescription": "Le module Metricbeat \"redisenterprise\" récupère des indicateurs de monitoring depuis le serveur Redis Enterprise. [En savoir plus]({learnMoreLink}).", + "home.tutorials.redisenterpriseMetrics.nameTitle": "Indicateurs Redis Enterprise", + "home.tutorials.redisenterpriseMetrics.shortDescription": "Récupérez des indicateurs de monitoring depuis le serveur Redis Enterprise.", + "home.tutorials.redisLogs.artifacts.dashboards.linkLabel": "Tableau de bord des logs Redis", + "home.tutorials.redisLogs.longDescription": "Le module Filebeat \"redis\" analyse les logs d'erreurs et de requêtes lentes créés par Redis. Pour que Redis écrive des logs d'erreurs, assurez-vous que l'option \"logfile\" est définie sur \"redis-server.log\" dans le fichier de configuration Redis. Les logs de requêtes lentes sont lus directement depuis Redis via la commande \"SLOWLOG\". Pour que Redis enregistre des logs de requêtes lentes, assurez-vous que l'option \"slowlog-log-slower-than\" est activée. Notez que l'ensemble de fichiers \"slowlog\" est expérimental. [En savoir plus]({learnMoreLink}).", + "home.tutorials.redisLogs.nameTitle": "Logs Redis", + "home.tutorials.redisLogs.shortDescription": "Collectez et analysez les logs d'erreurs et de requêtes lentes créés par Redis.", + "home.tutorials.redisMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs Redis", + "home.tutorials.redisMetrics.longDescription": "Le module Metricbeat \"redis\" récupère des indicateurs internes depuis le serveur Redis. [En savoir plus]({learnMoreLink}).", + "home.tutorials.redisMetrics.nameTitle": "Indicateurs Redis", + "home.tutorials.redisMetrics.shortDescription": "Récupérez des indicateurs internes depuis Redis.", + "home.tutorials.santaLogs.artifacts.dashboards.linkLabel": "Aperçu de Santa", + "home.tutorials.santaLogs.longDescription": "Le module collecte et analyse les logs de [Google Santa](https://github.com/google/santa), un outil de sécurité pour macOS qui monitore les exécutions de processus et est capable de mettre en liste noire/blanche des fichiers binaires. [En savoir plus]({learnMoreLink}).", + "home.tutorials.santaLogs.nameTitle": "Logs Google Santa", + "home.tutorials.santaLogs.shortDescription": "Collectez des logs Google Santa relatifs aux exécutions de processus sur MacOS.", + "home.tutorials.sonicwallLogs.longDescription": "Ce module permet de recevoir des logs Sonicwall FW par le biais de Syslog ou d'un fichier. [En savoir plus]({learnMoreLink}).", + "home.tutorials.sonicwallLogs.nameTitle": "Logs Sonicwall FW", + "home.tutorials.sonicwallLogs.shortDescription": "Collectez des logs Sonicwall FW par le biais de Syslog ou d’un fichier.", + "home.tutorials.sophosLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.sophosLogs.longDescription": "Il s'agit d'un module pour les produits Sophos. Actuellement, il prend en charge les logs XG SFOS envoyés au format Syslog. [En savoir plus]({learnMoreLink}).", + "home.tutorials.sophosLogs.nameTitle": "Logs Sophos", + "home.tutorials.sophosLogs.shortDescription": "Collectez des logs Sophos XG SFOS par le biais de Syslog.", + "home.tutorials.squidLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.squidLogs.longDescription": "Ce module permet de recevoir des logs Squid par le biais de Syslog ou d'un fichier. [En savoir plus]({learnMoreLink}).", + "home.tutorials.squidLogs.nameTitle": "Logs Squid", + "home.tutorials.squidLogs.shortDescription": "Collectez des logs Squid par le biais de Syslog ou d’un fichier.", + "home.tutorials.stanMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs Stan", + "home.tutorials.stanMetrics.longDescription": "Le module Metricbeat \"stan\" récupère des indicateurs de monitoring depuis STAN. [En savoir plus]({learnMoreLink}).", + "home.tutorials.stanMetrics.nameTitle": "Indicateurs STAN", + "home.tutorials.stanMetrics.shortDescription": "Récupérez des indicateurs de monitoring depuis le serveur STAN.", + "home.tutorials.statsdMetrics.longDescription": "Le module Metricbeat \"statsd\" récupère des indicateurs de monitoring depuis statsd. [En savoir plus]({learnMoreLink}).", + "home.tutorials.statsdMetrics.nameTitle": "Indicateurs statsd", + "home.tutorials.statsdMetrics.shortDescription": "Récupérez des indicateurs de monitoring depuis statsd.", + "home.tutorials.suricataLogs.artifacts.dashboards.linkLabel": "Aperçu des événements Suricata", + "home.tutorials.suricataLogs.longDescription": "Il s'agit d'un module pour le log IDS/IPS/NSM Suricata. Il analyse les logs qui sont au [format JSON Suricata Eve](https://suricata.readthedocs.io/en/latest/output/eve/eve-json-format.html). [En savoir plus]({learnMoreLink}).", + "home.tutorials.suricataLogs.nameTitle": "Logs Suricata", + "home.tutorials.suricataLogs.shortDescription": "Collectez des logs IDS/IPS/NSM Suricata.", + "home.tutorials.systemLogs.artifacts.dashboards.linkLabel": "Tableau de bord Syslog système", + "home.tutorials.systemLogs.longDescription": "Le module collecte et analyse les logs créés par le service de logging système des distributions basées sur Unix/Linux communes. [En savoir plus]({learnMoreLink}).", + "home.tutorials.systemLogs.nameTitle": "Logs système", + "home.tutorials.systemLogs.shortDescription": "Collectez des logs système des distributions basées sur Unix/Linux communes.", + "home.tutorials.systemMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs système", + "home.tutorials.systemMetrics.longDescription": "Le module Metricbeat \"system\" collecte des statistiques relatives au CPU, à la mémoire, au réseau et au disque depuis l'hôte. Il collecte des statistiques au niveau du système et des statistiques par processus et système de fichiers. [En savoir plus]({learnMoreLink}).", + "home.tutorials.systemMetrics.nameTitle": "Indicateurs système", + "home.tutorials.systemMetrics.shortDescription": "Collectez des statistiques relatives au CPU, à la mémoire, au réseau et au disque depuis l'hôte.", + "home.tutorials.tomcatLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.tomcatLogs.longDescription": "Ce module permet de recevoir des logs Apache Tomcat par le biais de Syslog ou d'un fichier. [En savoir plus]({learnMoreLink}).", + "home.tutorials.tomcatLogs.nameTitle": "Logs Tomcat", + "home.tutorials.tomcatLogs.shortDescription": "Collectez des logs Apache Tomcat par le biais de Syslog ou d’un fichier.", + "home.tutorials.traefikLogs.artifacts.dashboards.linkLabel": "Logs d'accès Traefik", + "home.tutorials.traefikLogs.longDescription": "Le module analyse les logs d'accès créés par [Traefik](https://traefik.io/). [En savoir plus]({learnMoreLink}).", + "home.tutorials.traefikLogs.nameTitle": "Logs Traefik", + "home.tutorials.traefikLogs.shortDescription": "Collectez des logs d'accès Traefik.", + "home.tutorials.traefikMetrics.longDescription": "Le module Metricbeat \"traefik\" récupère des indicateurs de monitoring depuis Traefik. [En savoir plus]({learnMoreLink}).", + "home.tutorials.traefikMetrics.nameTitle": "Indicateurs Traefik", + "home.tutorials.traefikMetrics.shortDescription": "Récupérez des indicateurs de monitoring depuis Traefik.", + "home.tutorials.uptimeMonitors.artifacts.dashboards.linkLabel": "Application Uptime", + "home.tutorials.uptimeMonitors.longDescription": "Monitorez la disponibilité des services grâce à une détection active. À partir d'une liste d'URL, Heartbeat pose cette question toute simple : Êtes-vous actif ? [En savoir plus]({learnMoreLink}).", + "home.tutorials.uptimeMonitors.nameTitle": "Monitorings Uptime", + "home.tutorials.uptimeMonitors.shortDescription": "Monitorer la disponibilité des services", + "home.tutorials.uwsgiMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs uWSGI", + "home.tutorials.uwsgiMetrics.longDescription": "Le module Metricbeat \"uwsgi\" récupère des indicateurs internes depuis le serveur uWSGI. [En savoir plus]({learnMoreLink}).", + "home.tutorials.uwsgiMetrics.nameTitle": "Indicateurs uWSGI", + "home.tutorials.uwsgiMetrics.shortDescription": "Récupérez des indicateurs internes depuis le serveur uWSGI.", + "home.tutorials.vsphereMetrics.artifacts.application.label": "Discover", + "home.tutorials.vsphereMetrics.longDescription": "Le module Metricbeat \"vsphere\" récupère des indicateurs internes depuis un cluster vSphere. [En savoir plus]({learnMoreLink}).", + "home.tutorials.vsphereMetrics.nameTitle": "Indicateurs vSphere", + "home.tutorials.vsphereMetrics.shortDescription": "Récupérez des indicateurs internes depuis vSphere.", + "home.tutorials.windowsEventLogs.artifacts.application.label": "Application SIEM", + "home.tutorials.windowsEventLogs.longDescription": "Utilisez Winlogbeat pour collecter des logs depuis le log des événements Windows. [En savoir plus]({learnMoreLink}).", + "home.tutorials.windowsEventLogs.nameTitle": "Log des événements Windows", + "home.tutorials.windowsEventLogs.shortDescription": "Récupérez des logs depuis le log des événements Windows.", + "home.tutorials.windowsMetrics.artifacts.application.label": "Discover", + "home.tutorials.windowsMetrics.longDescription": "Le module Metricbeat \"windows\" récupère des indicateurs internes depuis Windows. [En savoir plus]({learnMoreLink}).", + "home.tutorials.windowsMetrics.nameTitle": "Indicateurs Windows", + "home.tutorials.windowsMetrics.shortDescription": "Récupérez des indicateurs internes depuis Windows.", + "home.tutorials.zeekLogs.artifacts.dashboards.linkLabel": "Aperçu de Zeek", + "home.tutorials.zeekLogs.longDescription": "Il s'agit d'un module pour Zeek, anciennement appelé Bro. Il analyse les logs qui sont au [format JSON Zeek](https://www.zeek.org/manual/release/logs/index.html). [En savoir plus]({learnMoreLink}).", + "home.tutorials.zeekLogs.nameTitle": "Logs Zeek", + "home.tutorials.zeekLogs.shortDescription": "Collectez les logs de monitoring de la sécurité réseau Zeek.", + "home.tutorials.zookeeperMetrics.artifacts.application.label": "Discover", + "home.tutorials.zookeeperMetrics.longDescription": "Le module Metricbeat \"{moduleName}\" récupère des indicateurs internes depuis un serveur Zookeeper. [En savoir plus]({learnMoreLink}).", + "home.tutorials.zookeeperMetrics.nameTitle": "Indicateurs Zookeeper", + "home.tutorials.zookeeperMetrics.shortDescription": "Récupérez des indicateurs internes depuis un serveur Zookeeper.", + "home.tutorials.zscalerLogs.artifacts.dashboards.linkLabel": "Application Security", + "home.tutorials.zscalerLogs.longDescription": "Ce module permet de recevoir des logs Zscaler NSS par le biais de Syslog ou d'un fichier. [En savoir plus]({learnMoreLink}).", + "home.tutorials.zscalerLogs.nameTitle": "Logs Zscaler", + "home.tutorials.zscalerLogs.shortDescription": "Ce module permet de recevoir des logs Zscaler NSS par le biais de Syslog ou d'un fichier.", + "home.welcomeTitle": "Bienvenue dans Elastic", + "indexPatternEditor.aliasLabel": "Alias", + "indexPatternEditor.createIndex.noMatch": "Le nom doit correspondre à au moins un flux de données, index ou alias d'index.", + "indexPatternEditor.createIndexPattern.emptyState.checkDataButton": "Rechercher de nouvelles données", + "indexPatternEditor.createIndexPattern.emptyState.haveData": "Vous pensez avoir déjà des données ?", + "indexPatternEditor.createIndexPattern.emptyState.integrationCardDescription": "Ajoutez des données depuis diverses sources.", + "indexPatternEditor.createIndexPattern.emptyState.integrationCardTitle": "Ajouter une intégration", + "indexPatternEditor.createIndexPattern.emptyState.learnMore": "Envie d'en savoir plus ?", + "indexPatternEditor.createIndexPattern.emptyState.noDataTitle": "Vous êtes prêt à essayer Kibana ? Tout d'abord, vous avez besoin de données.", + "indexPatternEditor.createIndexPattern.emptyState.readDocs": "Lire la documentation", + "indexPatternEditor.createIndexPattern.emptyState.sampleDataCardDescription": "Chargez un ensemble de données et un tableau de bord Kibana.", + "indexPatternEditor.createIndexPattern.emptyState.sampleDataCardTitle": "Ajouter un exemple de données", + "indexPatternEditor.createIndexPattern.emptyState.uploadCardDescription": "Importez un fichier CSV, NDJSON ou log.", + "indexPatternEditor.createIndexPattern.emptyState.uploadCardTitle": "Charger un fichier", + "indexPatternEditor.createIndexPattern.stepTime.noTimeFieldOptionLabel": "--- Je ne souhaite pas utiliser le filtre temporel ---", + "indexPatternEditor.dataStreamLabel": "Flux de données", + "indexPatternEditor.editor.emptyPrompt.flyoutCloseButtonLabel": "Fermer", + "indexPatternEditor.editor.flyoutCloseButtonLabel": "Fermer", + "indexPatternEditor.editor.flyoutSaveButtonLabel": "Créer un modèle d'indexation", + "indexPatternEditor.editor.form.advancedSettings.hideButtonLabel": "Masquer les paramètres avancés", + "indexPatternEditor.editor.form.advancedSettings.showButtonLabel": "Afficher les paramètres avancés", + "indexPatternEditor.editor.form.allowHiddenLabel": "Autoriser les index masqués et système", + "indexPatternEditor.editor.form.customIdHelp": "Kibana fournit un identifiant unique pour chaque modèle d'indexation, ou vous pouvez en créer un vous-même.", + "indexPatternEditor.editor.form.customIdLabel": "ID de modèle d'indexation personnalisé", + "indexPatternEditor.editor.form.noTimeFieldsLabel": "Aucun flux de données, index ni alias d'index correspondant ne dispose d'un champ d'horodatage.", + "indexPatternEditor.editor.form.runtimeType.placeholderLabel": "Sélectionner un champ d'horodatage", + "indexPatternEditor.editor.form.timeFieldHelp": "Sélectionnez le champ d'horodatage à utiliser avec le filtre temporel global.", + "indexPatternEditor.editor.form.timeFieldLabel": "Champ d'horodatage", + "indexPatternEditor.editor.form.timestampFieldHelp": "Sélectionnez le champ d'horodatage à utiliser avec le filtre temporel global.", + "indexPatternEditor.editor.form.timestampSelectAriaLabel": "Champ d'horodatage", + "indexPatternEditor.editor.form.titleLabel": "Nom", + "indexPatternEditor.editor.form.TypeLabel": "Type de modèle d'indexation", + "indexPatternEditor.editor.form.typeSelectAriaLabel": "Champ Type", + "indexPatternEditor.emptyIndexPatternPrompt.documentation": "Lire la documentation", + "indexPatternEditor.emptyIndexPatternPrompt.learnMore": "Envie d'en savoir plus ?", + "indexPatternEditor.emptyIndexPatternPrompt.youHaveData": "Vous avez des données dans Elasticsearch.", + "indexPatternEditor.form.allowHiddenAriaLabel": "Autoriser les index masqués et système", + "indexPatternEditor.form.customIndexPatternIdLabel": "ID de modèle d'indexation personnalisé", + "indexPatternEditor.form.titleAriaLabel": "Champ de titre", + "indexPatternEditor.frozenLabel": "Gelé", + "indexPatternEditor.indexLabel": "Index", + "indexPatternEditor.loadingHeader": "Recherche d'index correspondants…", + "indexPatternEditor.pagingLabel": "Lignes par page : {perPage}", + "indexPatternEditor.requireTimestampOption.ValidationErrorMessage": "Sélectionnez un champ d'horodatage.", + "indexPatternEditor.rollup.uncaughtError": "Erreur de modèle d'indexation de cumul : {error}", + "indexPatternEditor.rollupIndexPattern.warning.title": "Fonctionnalité bêta", + "indexPatternEditor.rollupLabel": "Cumul", + "indexPatternEditor.saved": "\"{indexPatternTitle}\" enregistré", + "indexPatternEditor.status.matchAnyLabel.matchAnyDetail": "Votre modèle d'indexation peut correspondre à {sourceCount, plural, one {# source} other {# sources} }.", + "indexPatternEditor.status.noSystemIndicesLabel": "Aucun flux de données, index ni alias d'index ne correspond à votre modèle d'indexation.", + "indexPatternEditor.status.noSystemIndicesWithPromptLabel": "Aucun flux de données, index ni alias d'index ne correspond à votre modèle d'indexation.", + "indexPatternEditor.status.notMatchLabel.allIndicesLabel": "{indicesLength, plural, one {# source} other {# sources} }", + "indexPatternEditor.status.notMatchLabel.notMatchDetail": "Le modèle d'indexation spécifié ne correspond à aucun flux de données, index ni alias d'index. Vous pouvez faire correspondre {strongIndices}.", + "indexPatternEditor.status.notMatchLabel.notMatchNoIndicesDetail": "Le modèle d'indexation spécifié ne correspond à aucun flux de données, index ni alias d'index.", + "indexPatternEditor.status.partialMatchLabel.partialMatchDetail": "Votre modèle d'indexation ne correspond à aucun flux de données, index ni alias d'index, mais {strongIndices} {matchedIndicesLength, plural, one {est semblable} other {sont semblables} }.", + "indexPatternEditor.status.partialMatchLabel.strongIndicesLabel": "{matchedIndicesLength, plural, one {source} other {# sources} }", + "indexPatternEditor.status.successLabel.successDetail": "Votre modèle d'indexation correspond à {sourceCount} {sourceCount, plural, one {source} other {sources} }.", + "indexPatternEditor.title": "Créer un modèle d'indexation", + "indexPatternEditor.typeSelect.betaLabel": "Bêta", + "indexPatternEditor.typeSelect.rollup": "Cumul", + "indexPatternEditor.typeSelect.rollupDescription": "Effectuer des agrégations limitées à partir de données résumées", + "indexPatternEditor.typeSelect.rollupTitle": "Modèle d'indexation de cumul", + "indexPatternEditor.typeSelect.standard": "Standard", + "indexPatternEditor.typeSelect.standardDescription": "Effectuer des agrégations complètes à partir de n'importe quelles données", + "indexPatternEditor.typeSelect.standardTitle": "Modèle d'indexation standard", + "indexPatternEditor.validations.titleHelpText": "Utilisez un astérisque (*) pour faire correspondre plusieurs caractères. Les espaces et les caractères , /, ?, \", <, >, | ne sont pas autorisés.", + "indexPatternEditor.validations.titleIsRequiredErrorMessage": "Nom obligatoire.", + "indexPatternFieldEditor.cancelField.confirmationModal.cancelButtonLabel": "Annuler", + "indexPatternFieldEditor.cancelField.confirmationModal.description": "Les modifications apportées à votre champ seront ignorées. Voulez-vous vraiment continuer ?", + "indexPatternFieldEditor.cancelField.confirmationModal.title": "Ignorer les modifications", + "indexPatternFieldEditor.color.actions": "Actions", + "indexPatternFieldEditor.color.addColorButton": "Ajouter une couleur", + "indexPatternFieldEditor.color.backgroundLabel": "Couleur d'arrière-plan", + "indexPatternFieldEditor.color.deleteAria": "Supprimer", + "indexPatternFieldEditor.color.deleteTitle": "Supprimer le format de couleur", + "indexPatternFieldEditor.color.exampleLabel": "Exemple", + "indexPatternFieldEditor.color.patternLabel": "Modèle (expression régulière)", + "indexPatternFieldEditor.color.rangeLabel": "Plage (min:max)", + "indexPatternFieldEditor.color.textColorLabel": "Couleur du texte", + "indexPatternFieldEditor.createField.flyoutAriaLabel": "Créer un champ", + "indexPatternFieldEditor.date.documentationLabel": "Documentation", + "indexPatternFieldEditor.date.momentLabel": "Modèle de format Moment.js (par défaut : {defaultPattern})", + "indexPatternFieldEditor.defaultErrorMessage": "Une erreur s'est produite lors de l'utilisation de cette configuration de format : {message}.", + "indexPatternFieldEditor.defaultFormatDropDown": "- Par défaut -", + "indexPatternFieldEditor.defaultFormatHeader": "Format (par défaut : {defaultFormat})", + "indexPatternFieldEditor.deleteField.savedHeader": "\"{fieldName}\" enregistré", + "indexPatternFieldEditor.deleteRuntimeField.confirmationModal.cancelButtonLabel": "Annuler", + "indexPatternFieldEditor.deleteRuntimeField.confirmationModal.removeButtonLabel": "Supprimer le champ", + "indexPatternFieldEditor.deleteRuntimeField.confirmationModal.removeMultipleButtonLabel": "Supprimer les champs", + "indexPatternFieldEditor.deleteRuntimeField.confirmationModal.saveButtonLabel": "Enregistrer les modifications", + "indexPatternFieldEditor.deleteRuntimeField.confirmModal.deleteMultipleTitle": "Supprimer {count} champs", + "indexPatternFieldEditor.deleteRuntimeField.confirmModal.deleteSingleTitle": "Supprimer le champ \"{name}\"", + "indexPatternFieldEditor.deleteRuntimeField.confirmModal.multipleDeletionDescription": "Vous êtes sur le point de supprimer les champs d'exécution suivants :", + "indexPatternFieldEditor.deleteRuntimeField.confirmModal.typeConfirm": "Saisissez REMOVE pour confirmer.", + "indexPatternFieldEditor.deleteRuntimeField.confirmModal.warningChangingFields": "Modifier le nom ou le type peut affecter les recherches et les visualisations utilisant ce champ.", + "indexPatternFieldEditor.deleteRuntimeField.confirmModal.warningRemovingFields": "Supprimer un champ peut affecter les recherches et les visualisations utilisant ce champ.", + "indexPatternFieldEditor.duration.decimalPlacesLabel": "Décimales", + "indexPatternFieldEditor.duration.includeSpace": "Inclure un espace entre le suffixe et la valeur", + "indexPatternFieldEditor.duration.inputFormatLabel": "Format d'entrée", + "indexPatternFieldEditor.duration.outputFormatLabel": "Format de sortie", + "indexPatternFieldEditor.duration.showSuffixLabel": "Afficher le suffixe", + "indexPatternFieldEditor.duration.showSuffixLabel.short": "Utiliser un suffixe court", + "indexPatternFieldEditor.durationErrorMessage": "Le nombre de décimales doit être compris entre 0 et 20.", + "indexPatternFieldEditor.editField.flyoutAriaLabel": "Modifier le champ {fieldName}", + "indexPatternFieldEditor.editor.flyoutCancelButtonLabel": "Annuler", + "indexPatternFieldEditor.editor.flyoutDefaultTitle": "Créer un champ", + "indexPatternFieldEditor.editor.flyoutEditFieldSubtitle": "Modèle d'indexation : {patternName}", + "indexPatternFieldEditor.editor.flyoutEditFieldTitle": "Modifier le champ \"{fieldName}\"", + "indexPatternFieldEditor.editor.flyoutSaveButtonLabel": "Enregistrer", + "indexPatternFieldEditor.editor.form.advancedSettings.hideButtonLabel": "Masquer les paramètres avancés", + "indexPatternFieldEditor.editor.form.advancedSettings.showButtonLabel": "Afficher les paramètres avancés", + "indexPatternFieldEditor.editor.form.changeWarning": "Modifier le nom ou le type peut affecter les recherches et les visualisations utilisant ce champ.", + "indexPatternFieldEditor.editor.form.customLabelDescription": "Créez une étiquette à afficher à la place du nom du champ dans Discover, Maps et Visualize. Utile pour raccourcir un nom de champ long. Les requêtes et les filtres utilisent le nom de champ d'origine.", + "indexPatternFieldEditor.editor.form.customLabelLabel": "Étiquette personnalisée", + "indexPatternFieldEditor.editor.form.customLabelTitle": "Définir une étiquette personnalisée", + "indexPatternFieldEditor.editor.form.defineFieldLabel": "Définir un script", + "indexPatternFieldEditor.editor.form.fieldShadowingCalloutDescription": "Ce champ partage le nom d'un champ mappé. Les valeurs de ce champ seront renvoyées dans les résultats de recherche.", + "indexPatternFieldEditor.editor.form.fieldShadowingCalloutTitle": "Masquage de champ", + "indexPatternFieldEditor.editor.form.formatDescription": "Définissez votre format de prédilection pour l'affichage de la valeur. Changer le format peut avoir un impact sur la valeur et empêcher la mise en surbrillance dans Discover.", + "indexPatternFieldEditor.editor.form.formatTitle": "Définir le format", + "indexPatternFieldEditor.editor.form.nameAriaLabel": "Champ Nom", + "indexPatternFieldEditor.editor.form.nameLabel": "Nom", + "indexPatternFieldEditor.editor.form.popularityDescription": "Définissez la popularité pour que le champ apparaisse plus haut ou plus bas dans la liste des champs. Par défaut, Discover classe les champs du plus souvent sélectionné au moins souvent sélectionné.", + "indexPatternFieldEditor.editor.form.popularityLabel": "Popularité", + "indexPatternFieldEditor.editor.form.popularityTitle": "Définir la popularité", + "indexPatternFieldEditor.editor.form.runtimeType.placeholderLabel": "Sélectionner un type", + "indexPatternFieldEditor.editor.form.runtimeTypeLabel": "Type", + "indexPatternFieldEditor.editor.form.script.learnMoreLinkText": "En savoir plus sur la syntaxe de script.", + "indexPatternFieldEditor.editor.form.scriptEditor.compileErrorMessage": "Erreur lors de la compilation du script Painless", + "indexPatternFieldEditor.editor.form.scriptEditorAriaLabel": "Éditeur de script", + "indexPatternFieldEditor.editor.form.source.scriptFieldHelpText": "Les champs d'exécution sans script récupèrent les valeurs de {source}. Si un champ n'existe pas dans _source, la recherche ne renvoie pas de valeur. {learnMoreLink}", + "indexPatternFieldEditor.editor.form.typeSelectAriaLabel": "Sélection du type", + "indexPatternFieldEditor.editor.form.validations.customLabelIsRequiredErrorMessage": "Spécifiez une étiquette pour le champ.", + "indexPatternFieldEditor.editor.form.validations.nameIsRequiredErrorMessage": "Nom obligatoire.", + "indexPatternFieldEditor.editor.form.validations.popularityGreaterThan0ErrorMessage": "La popularité doit être définie sur 0 ou plus.", + "indexPatternFieldEditor.editor.form.validations.popularityIsRequiredErrorMessage": "Spécifiez la popularité du champ.", + "indexPatternFieldEditor.editor.form.validations.scriptIsRequiredErrorMessage": "Un script est obligatoire pour définir la valeur du champ.", + "indexPatternFieldEditor.editor.form.valueDescription": "Définissez une valeur pour le champ au lieu de la récupérer à partir du champ portant le même nom dans {source}.", + "indexPatternFieldEditor.editor.form.valueTitle": "Définir la valeur", + "indexPatternFieldEditor.editor.runtimeFieldsEditor.existRuntimeFieldNamesValidationErrorMessage": "Un champ portant ce nom existe déjà.", + "indexPatternFieldEditor.fieldPreview.documentIdField.label": "ID du document", + "indexPatternFieldEditor.fieldPreview.documentIdField.loadDocumentsFromCluster": "Charger des documents depuis le cluster", + "indexPatternFieldEditor.fieldPreview.documentNav.nextArialabel": "Document suivant", + "indexPatternFieldEditor.fieldPreview.documentNav.previousArialabel": "Document précédent", + "indexPatternFieldEditor.fieldPreview.emptyPromptDescription": "Saisissez le nom d'un champ existant ou définissez un script pour afficher un aperçu de la sortie calculée.", + "indexPatternFieldEditor.fieldPreview.emptyPromptTitle": "Aperçu", + "indexPatternFieldEditor.fieldPreview.error.documentNotFoundDescription": "ID du document introuvable", + "indexPatternFieldEditor.fieldPreview.errorCallout.title": "Erreur d'aperçu", + "indexPatternFieldEditor.fieldPreview.errorTitle": "Échec du chargement de l'aperçu du champ", + "indexPatternFieldEditor.fieldPreview.filterFieldsPlaceholder": "Champs de filtre", + "indexPatternFieldEditor.fieldPreview.pinFieldButtonLabel": "Épingler le champ", + "indexPatternFieldEditor.fieldPreview.searchResult.emptyPrompt.clearSearchButtonLabel": "Effacer la recherche", + "indexPatternFieldEditor.fieldPreview.searchResult.emptyPromptTitle": "Aucun champ correspondant dans ce modèle d'indexation", + "indexPatternFieldEditor.fieldPreview.showLessFieldsButtonLabel": "Afficher moins", + "indexPatternFieldEditor.fieldPreview.showMoreFieldsButtonLabel": "Afficher plus", + "indexPatternFieldEditor.fieldPreview.subTitle": "Depuis : {from}", + "indexPatternFieldEditor.fieldPreview.subTitle.customData": "Données personnalisées", + "indexPatternFieldEditor.fieldPreview.title": "Aperçu", + "indexPatternFieldEditor.fieldPreview.updatingPreviewLabel": "Mise à jour en cours...", + "indexPatternFieldEditor.fieldPreview.viewImageButtonLabel": "Afficher l'image", + "indexPatternFieldEditor.formatHeader": "Format", + "indexPatternFieldEditor.histogram.histogramAsNumberLabel": "Format de nombre agrégé", + "indexPatternFieldEditor.histogram.numeralLabel": "Modèle de format numérique (facultatif)", + "indexPatternFieldEditor.histogram.subFormat.bytes": "Octets", + "indexPatternFieldEditor.histogram.subFormat.number": "Nombre", + "indexPatternFieldEditor.histogram.subFormat.percent": "Pourcentage", + "indexPatternFieldEditor.noSuchFieldName": "Champ \"{fieldName}\" introuvable dans le modèle d'indexation", + "indexPatternFieldEditor.number.documentationLabel": "Documentation", + "indexPatternFieldEditor.number.numeralLabel": "Modèle de format Numeral.js (par défaut : {defaultPattern})", + "indexPatternFieldEditor.samples.inputHeader": "Entrée", + "indexPatternFieldEditor.samples.outputHeader": "Sortie", + "indexPatternFieldEditor.samplesHeader": "Exemples", + "indexPatternFieldEditor.save.deleteErrorTitle": "Impossible d'enregistrer la suppression du champ", + "indexPatternFieldEditor.save.errorTitle": "Impossible d'enregistrer la modification du champ", + "indexPatternFieldEditor.saveRuntimeField.confirmationModal.cancelButtonLabel": "Annuler", + "indexPatternFieldEditor.saveRuntimeField.confirmModal.title": "Enregistrer les modifications apportées à \"{name}\"", + "indexPatternFieldEditor.saveRuntimeField.confirmModal.typeConfirm": "Saisissez CHANGE pour continuer.", + "indexPatternFieldEditor.staticLookup.actions": "actions", + "indexPatternFieldEditor.staticLookup.addEntryButton": "Ajouter une entrée", + "indexPatternFieldEditor.staticLookup.deleteAria": "Supprimer", + "indexPatternFieldEditor.staticLookup.deleteTitle": "Supprimer l’entrée", + "indexPatternFieldEditor.staticLookup.keyLabel": "Clé", + "indexPatternFieldEditor.staticLookup.leaveBlankPlaceholder": "Laisser vide pour conserver la valeur telle quelle", + "indexPatternFieldEditor.staticLookup.unknownKeyLabel": "Valeur pour clé inconnue", + "indexPatternFieldEditor.staticLookup.valueLabel": "Valeur", + "indexPatternFieldEditor.string.transformLabel": "Transformer", + "indexPatternFieldEditor.truncate.lengthLabel": "Longueur du champ", + "indexPatternFieldEditor.url.heightLabel": "Hauteur", + "indexPatternFieldEditor.url.labelTemplateHelpText": "Aide sur le modèle d'étiquette", + "indexPatternFieldEditor.url.labelTemplateLabel": "Modèle d'étiquette", + "indexPatternFieldEditor.url.offLabel": "Off", + "indexPatternFieldEditor.url.onLabel": "On", + "indexPatternFieldEditor.url.openTabLabel": "Ouvrir dans un nouvel onglet", + "indexPatternFieldEditor.url.template.helpLinkText": "Aide sur le modèle d'URL", + "indexPatternFieldEditor.url.typeLabel": "Type", + "indexPatternFieldEditor.url.urlTemplateLabel": "Modèle d'URL", + "indexPatternFieldEditor.url.widthLabel": "Largeur", + "indexPatternManagement.actions.cancelButton": "Annuler", + "indexPatternManagement.actions.createButton": "Créer un champ", + "indexPatternManagement.actions.deleteButton": "Supprimer", + "indexPatternManagement.actions.saveButton": "Enregistrer le champ", + "indexPatternManagement.createHeader": "Créer un champ scripté", + "indexPatternManagement.customLabel": "Étiquette personnalisée", + "indexPatternManagement.defaultFormatDropDown": "- Par défaut -", + "indexPatternManagement.defaultFormatHeader": "Format (par défaut : {defaultFormat})", + "indexPatternManagement.deleteField.cancelButton": "Annuler", + "indexPatternManagement.deleteField.deleteButton": "Supprimer", + "indexPatternManagement.deleteField.deletedHeader": "\"’{fieldName}\" supprimé", + "indexPatternManagement.deleteField.savedHeader": "\"{fieldName}\" enregistré", + "indexPatternManagement.deleteFieldHeader": "Supprimer le champ \"{fieldName}\"", + "indexPatternManagement.deleteFieldLabel": "Il est impossible de récupérer un champ supprimé.{separator}Voulez-vous vraiment continuer ?", + "indexPatternManagement.disabledCallOutHeader": "Scripts désactivés", + "indexPatternManagement.disabledCallOutLabel": "Tous les scripts en ligne ont été désactivés dans Elasticsearch. Vous devez activer les scripts en ligne pour au moins un langage afin d'utiliser des champs scriptés dans Kibana.", + "indexPatternManagement.editHeader": "Modifier {fieldName}", + "indexPatternManagement.editIndexPattern.deleteButton": "Supprimer", + "indexPatternManagement.editIndexPattern.deprecation": "Les champs scriptés sont déclassés. Utilisez {runtimeDocs} à la place.", + "indexPatternManagement.editIndexPattern.fields.addFieldButtonLabel": "Ajouter un champ", + "indexPatternManagement.editIndexPattern.fields.filterAria": "Filtrer les types de champ", + "indexPatternManagement.editIndexPattern.fields.filterPlaceholder": "Rechercher", + "indexPatternManagement.editIndexPattern.fields.searchAria": "Rechercher des champs", + "indexPatternManagement.editIndexPattern.fields.table.additionalInfoAriaLabel": "Informations supplémentaires sur le champ", + "indexPatternManagement.editIndexPattern.fields.table.aggregatableDescription": "Ces champs peuvent être utilisés dans des agrégations de visualisations.", + "indexPatternManagement.editIndexPattern.fields.table.aggregatableLabel": "Regroupable", + "indexPatternManagement.editIndexPattern.fields.table.customLabelTooltip": "Une étiquette personnalisée pour le champ.", + "indexPatternManagement.editIndexPattern.fields.table.deleteDescription": "Supprimer", + "indexPatternManagement.editIndexPattern.fields.table.deleteLabel": "Supprimer", + "indexPatternManagement.editIndexPattern.fields.table.editDescription": "Modifier", + "indexPatternManagement.editIndexPattern.fields.table.editLabel": "Modifier", + "indexPatternManagement.editIndexPattern.fields.table.excludedDescription": "Champs exclus de _source lors de la récupération", + "indexPatternManagement.editIndexPattern.fields.table.excludedLabel": "Exclu", + "indexPatternManagement.editIndexPattern.fields.table.formatHeader": "Format", + "indexPatternManagement.editIndexPattern.fields.table.isAggregatableAria": "Est regroupable", + "indexPatternManagement.editIndexPattern.fields.table.isExcludedAria": "Est exclu", + "indexPatternManagement.editIndexPattern.fields.table.isSearchableAria": "Est interrogeable", + "indexPatternManagement.editIndexPattern.fields.table.nameHeader": "Nom", + "indexPatternManagement.editIndexPattern.fields.table.primaryTimeAriaLabel": "Champ temporel principal", + "indexPatternManagement.editIndexPattern.fields.table.primaryTimeTooltip": "Ce champ représente l'heure à laquelle les événements se sont produits.", + "indexPatternManagement.editIndexPattern.fields.table.runtimeIconTipTitle": "Champ d'exécution", + "indexPatternManagement.editIndexPattern.fields.table.searchableDescription": "Ces champs peuvent être utilisés dans la barre de filtre.", + "indexPatternManagement.editIndexPattern.fields.table.searchableHeader": "Interrogeable", + "indexPatternManagement.editIndexPattern.fields.table.typeHeader": "Type", + "indexPatternManagement.editIndexPattern.list.DateHistogramDelaySummary": "retard : {delay},", + "indexPatternManagement.editIndexPattern.list.dateHistogramSummary": "{aggName} (intervalle : {interval}, {delay} {time_zone})", + "indexPatternManagement.editIndexPattern.list.defaultIndexPatternListName": "Par défaut", + "indexPatternManagement.editIndexPattern.list.histogramSummary": "{aggName} (intervalle : {interval})", + "indexPatternManagement.editIndexPattern.list.rollupIndexPatternListName": "Cumul", + "indexPatternManagement.editIndexPattern.mappingConflictHeader": "Conflit de mapping", + "indexPatternManagement.editIndexPattern.mappingConflictLabel": "{conflictFieldsLength, plural, one {Un champ est défini} other {# champs sont définis}} avec plusieurs types (chaîne, entier, etc.) dans les différents index qui correspondent à ce modèle. Vous pourrez peut-être utiliser ce ou ces champs en conflit dans certaines parties de Kibana, mais ils ne seront pas disponibles pour les fonctions qui nécessitent que Kibana connaisse leur type. Pour corriger ce problème, vous devrez réindexer vos données.", + "indexPatternManagement.editIndexPattern.scripted.addFieldButton": "Ajouter un champ scripté", + "indexPatternManagement.editIndexPattern.scripted.deleteField.cancelButton": "Annuler", + "indexPatternManagement.editIndexPattern.scripted.deleteField.deleteButton": "Supprimer", + "indexPatternManagement.editIndexPattern.scripted.deleteFieldLabel": "Supprimer le champ scripté \"{fieldName}\" ?", + "indexPatternManagement.editIndexPattern.scripted.deprecationLangHeader": "Langages déclassés en cours d'utilisation", + "indexPatternManagement.editIndexPattern.scripted.deprecationLangLabel.deprecationLangDetail": "Les langages déclassés suivants sont en cours d'utilisation : {deprecatedLangsInUse}. La prise en charge de ces langages sera supprimée dans la prochaine version majeure de Kibana et d'Elasticsearch. Convertissez vos champs scriptés en {link} pour éviter tout problème.", + "indexPatternManagement.editIndexPattern.scripted.deprecationLangLabel.painlessDescription": "Painless", + "indexPatternManagement.editIndexPattern.scripted.newFieldPlaceholder": "Nouveau champ scripté", + "indexPatternManagement.editIndexPattern.scripted.table.deleteDescription": "Supprimer ce champ", + "indexPatternManagement.editIndexPattern.scripted.table.deleteHeader": "Supprimer", + "indexPatternManagement.editIndexPattern.scripted.table.editDescription": "Modifier ce champ", + "indexPatternManagement.editIndexPattern.scripted.table.editHeader": "Modifier", + "indexPatternManagement.editIndexPattern.scripted.table.formatDescription": "Format utilisé pour le champ", + "indexPatternManagement.editIndexPattern.scripted.table.formatHeader": "Format", + "indexPatternManagement.editIndexPattern.scripted.table.langDescription": "Langage utilisé pour le champ", + "indexPatternManagement.editIndexPattern.scripted.table.langHeader": "Lang", + "indexPatternManagement.editIndexPattern.scripted.table.nameDescription": "Nom du champ", + "indexPatternManagement.editIndexPattern.scripted.table.nameHeader": "Nom", + "indexPatternManagement.editIndexPattern.scripted.table.scriptDescription": "Script pour le champ", + "indexPatternManagement.editIndexPattern.scripted.table.scriptHeader": "Script", + "indexPatternManagement.editIndexPattern.scriptedLabel": "Les champs scriptés peuvent être utilisés dans des visualisations et affichés dans des documents. Ils ne peuvent cependant pas faire l'objet d'une recherche.", + "indexPatternManagement.editIndexPattern.source.addButtonLabel": "Ajouter", + "indexPatternManagement.editIndexPattern.source.deleteFilter.cancelButtonLabel": "Annuler", + "indexPatternManagement.editIndexPattern.source.deleteFilter.deleteButtonLabel": "Supprimer", + "indexPatternManagement.editIndexPattern.source.deleteSourceFilterLabel": "Supprimer le filtre de champ \"{value}\" ?", + "indexPatternManagement.editIndexPattern.source.noteLabel": "Notez que les champs multiples apparaîtront incorrectement comme des correspondances dans le tableau ci-dessous. Ces filtres ne s'appliquent qu'aux champs dans le document source d'origine. Par conséquent, les champs multiples ne sont pas réellement filtrés.", + "indexPatternManagement.editIndexPattern.source.table.cancelAria": "Annuler", + "indexPatternManagement.editIndexPattern.source.table.deleteAria": "Supprimer", + "indexPatternManagement.editIndexPattern.source.table.editAria": "Modifier", + "indexPatternManagement.editIndexPattern.source.table.filterDescription": "Nom du filtre", + "indexPatternManagement.editIndexPattern.source.table.filterHeader": "Filtre", + "indexPatternManagement.editIndexPattern.source.table.matchesDescription": "Langage utilisé pour le champ", + "indexPatternManagement.editIndexPattern.source.table.matchesHeader": "Correspondances", + "indexPatternManagement.editIndexPattern.source.table.notMatchedLabel": "Le filtre source ne correspond à aucun champ connu.", + "indexPatternManagement.editIndexPattern.source.table.saveAria": "Enregistrer", + "indexPatternManagement.editIndexPattern.sourceLabel": "Les filtres de champ peuvent être utilisés pour exclure un ou plusieurs champs lors de la récupération d'un document. Cela se produit lors de l'affichage d'un document dans l'application Discover ou avec un tableau affichant les résultats d'une recherche enregistrée dans l'application Dashboard. Si vous avez des documents avec des champs de grande taille ou peu importants, il pourrait être utile de filtrer ces champs à ce niveau plus bas.", + "indexPatternManagement.editIndexPattern.sourcePlaceholder": "filtre de champ, accepte les caractères génériques (par ex. \"utilisateur*\" pour filtrer les champs commençant par \"utilisateur\")", + "indexPatternManagement.editIndexPattern.tabs.fieldsHeader": "Champs", + "indexPatternManagement.editIndexPattern.tabs.scriptedHeader": "Champs scriptés", + "indexPatternManagement.editIndexPattern.tabs.sourceHeader": "Filtres de champ", + "indexPatternManagement.editIndexPattern.timeFilterHeader": "Champ temporel : \"{timeFieldName}\"", + "indexPatternManagement.editIndexPattern.timeFilterLabel.mappingAPILink": "mappings de champ", + "indexPatternManagement.editIndexPattern.timeFilterLabel.timeFilterDetail": "Affichez et modifiez les champs dans {indexPatternTitle}. Les attributs de champ tels que le type et le niveau de recherche sont basés sur {mappingAPILink} dans Elasticsearch.", + "indexPatternManagement.fieldTypeConflict": "Conflit de type de champ", + "indexPatternManagement.formatHeader": "Format", + "indexPatternManagement.formatLabel": "La mise en forme vous permet de contrôler la façon dont des valeurs spécifiques sont affichées. Cela peut également entraîner une modification complète des valeurs et empêcher la mise en surbrillance dans Discover de fonctionner.", + "indexPatternManagement.header.runtimeLink": "champs d'exécution", + "indexPatternManagement.indexNameLabel": "Nom des index", + "indexPatternManagement.indexPatterns.badge.readOnly.text": "Lecture seule", + "indexPatternManagement.indexPatterns.createFieldBreadcrumb": "Créer un champ", + "indexPatternManagement.labelHelpText": "Définissez une étiquette personnalisée à utiliser lorsque ce champ est affiché dans Discover, Maps et Visualize. Actuellement, les requêtes et les filtres ne prennent pas en charge les étiquettes personnalisées et utilisent le nom d'origine des champs.", + "indexPatternManagement.languageLabel": "Langage", + "indexPatternManagement.mappingConflictLabel.mappingConflictDetail": "{mappingConflict} Vous avez déjà un champ nommé {fieldName}. Si vous donnez le même nom à votre champ scripté, vous ne pourrez pas interroger les deux champs en même temps.", + "indexPatternManagement.mappingConflictLabel.mappingConflictLabel": "Conflit de mapping :", + "indexPatternManagement.multiTypeLabelDesc": "Le type de ce champ varie selon les index. Il n'est pas disponible pour de nombreuses fonctions d'analyse. Les index par type sont les suivants :", + "indexPatternManagement.nameErrorMessage": "Nom obligatoire", + "indexPatternManagement.nameLabel": "Nom", + "indexPatternManagement.namePlaceholder": "Nouveau champ scripté", + "indexPatternManagement.popularityLabel": "Popularité", + "indexPatternManagement.script.accessWithLabel": "Accédez aux champs avec {code}.", + "indexPatternManagement.script.getHelpLabel": "Obtenez de l'aide pour la syntaxe et prévisualisez les résultats de votre script.", + "indexPatternManagement.scriptedFieldsDeprecatedBody": "Pour profiter de plus de flexibilité et de la prise en charge des scripts Painless, utilisez {runtimeDocs}.", + "indexPatternManagement.scriptedFieldsDeprecatedTitle": "Les champs scriptés sont déclassés.", + "indexPatternManagement.scriptingLanguages.errorFetchingToastDescription": "Erreur lors de l'obtention des langages de script disponibles à partir d'Elasticsearch", + "indexPatternManagement.scriptInvalidErrorMessage": "Script non valide. Voir l'aperçu du script pour plus de détails.", + "indexPatternManagement.scriptLabel": "Script", + "indexPatternManagement.scriptRequiredErrorMessage": "Script obligatoire", + "indexPatternManagement.syntax.default.formatLabel": "doc['some_field'].value", + "indexPatternManagement.syntax.defaultLabel.defaultDetail": "Par défaut, les champs scriptés Kibana emploient {painless}, un langage de script simple et sécurisé spécialement conçu pour Elasticsearch. Pour accéder aux valeurs du document, utilisez le format suivant :", + "indexPatternManagement.syntax.defaultLabel.painlessLink": "Painless", + "indexPatternManagement.syntax.kibanaLabel": "Kibana impose actuellement une limitation spéciale sur les scripts Painless. Ils ne peuvent pas contenir de fonctions nommées.", + "indexPatternManagement.syntax.lucene.commonLabel.commonDetail": "Vous venez d'une ancienne version de Kibana ? Les expressions {lucene} que vous connaissez et adorez sont toujours disponibles. Les expressions Lucene ressemblent beaucoup à du JavaScript, mais elles se limitent aux opérations arithmétiques de base, aux opérations au niveau du bit et aux opérations de comparaison.", + "indexPatternManagement.syntax.lucene.commonLabel.luceneLink": "Expressions Lucene", + "indexPatternManagement.syntax.lucene.limits.fieldsLabel": "Les champs stockés ne sont pas disponibles.", + "indexPatternManagement.syntax.lucene.limits.sparseLabel": "Si un champ est clairsemé (seuls certains documents contiennent une valeur), les documents où ce champ est vide auront une valeur de 0.", + "indexPatternManagement.syntax.lucene.limits.typesLabel": "Seuls les champs numériques, booléens, de date et de point géographique sont accessibles.", + "indexPatternManagement.syntax.lucene.limitsLabel": "L'utilisation d’expressions Lucene implique quelques limitations :", + "indexPatternManagement.syntax.lucene.operations.arithmeticLabel": "Opérateurs arithmétiques : {operators}", + "indexPatternManagement.syntax.lucene.operations.bitwiseLabel": "Opérateurs au niveau du bit : {operators}", + "indexPatternManagement.syntax.lucene.operations.booleanLabel": "Opérateurs booléens (y compris l'opérateur ternaire) : {operators}", + "indexPatternManagement.syntax.lucene.operations.comparisonLabel": "Opérateurs de comparaison : {operators}", + "indexPatternManagement.syntax.lucene.operations.distanceLabel": "Fonctions de distance : {operators}", + "indexPatternManagement.syntax.lucene.operations.mathLabel": "Fonctions mathématiques communes : {operators}", + "indexPatternManagement.syntax.lucene.operations.miscellaneousLabel": "Fonctions diverses : {operators}", + "indexPatternManagement.syntax.lucene.operations.trigLabel": "Fonctions de bibliothèque trigonométrique : {operators}", + "indexPatternManagement.syntax.lucene.operationsLabel": "Voici toutes les opérations disponibles pour les expressions Lucene :", + "indexPatternManagement.syntax.painlessLabel.javaAPIsLink": "API Java natives", + "indexPatternManagement.syntax.painlessLabel.painlessDetail": "Painless est un langage puissant, mais facile à utiliser. Il donne accès à de nombreuses {javaAPIs}. Lisez-en plus sur sa {syntax} et découvrez tout ce que vous devez savoir en un rien de temps !", + "indexPatternManagement.syntax.painlessLabel.syntaxLink": "syntaxe", + "indexPatternManagement.syntaxHeader": "Syntaxe", + "indexPatternManagement.testScript.errorMessage": "Votre script présente une erreur.", + "indexPatternManagement.testScript.fieldsLabel": "Champs supplémentaires", + "indexPatternManagement.testScript.fieldsPlaceholder": "Sélectionner…", + "indexPatternManagement.testScript.instructions": "Exécutez votre script pour prévisualiser les 10 premiers résultats. Vous pouvez également sélectionner des champs supplémentaires à inclure dans les résultats pour obtenir plus de contexte ou ajouter une requête pour filtrer des documents spécifiques.", + "indexPatternManagement.testScript.resultsLabel": "10 premiers résultats", + "indexPatternManagement.testScript.resultsTitle": "Prévisualiser les résultats", + "indexPatternManagement.testScript.submitButtonLabel": "Exécuter le script", + "indexPatternManagement.typeLabel": "Type", + "indexPatternManagement.warningCallOutLabel.callOutDetail": "Familiarisez-vous avec les {scripFields} et les {scriptsInAggregation} avant d'utiliser cette fonctionnalité. Les champs scriptés peuvent être utilisés pour afficher et agréger les valeurs calculées. Dès lors, ils peuvent être très lents et, s'ils ne sont pas faits correctement, ils peuvent rendre Kibana inutilisable.", + "indexPatternManagement.warningCallOutLabel.runtimeLink": "champs d'exécution", + "indexPatternManagement.warningCallOutLabel.scripFieldsLink": "champs scriptés", + "indexPatternManagement.warningCallOutLabel.scriptsInAggregationLink": "scripts en agrégations", + "indexPatternManagement.warningHeader": "Avertissement de déclassement :", + "indexPatternManagement.warningLabel.painlessLinkLabel": "Painless", + "indexPatternManagement.warningLabel.warningDetail": "{language} est déclassé et ne sera plus pris en charge dans la prochaine version majeure de Kibana et d'Elasticsearch. Nous recommandons d'utiliser {painlessLink} pour les nouveaux champs scriptés.", + "inputControl.control.noIndexPatternTooltip": "Impossible de localiser l'ID du modèle d'indexation : {indexPatternId}.", + "inputControl.control.notInitializedTooltip": "Le contrôle n'a pas été initialisé.", + "inputControl.control.noValuesDisableTooltip": "Le filtrage se produit sur le champ \"{fieldName}\", qui n'existe dans aucun document du modèle d'indexation \"{indexPatternName}\". Sélectionnez un champ différent ou des documents d'index qui contiennent des valeurs pour ce champ.", + "inputControl.editor.controlEditor.controlLabel": "Contrôler l'étiquette", + "inputControl.editor.controlEditor.moveControlDownAriaLabel": "Abaisser le contrôle", + "inputControl.editor.controlEditor.moveControlUpAriaLabel": "Remonter le contrôle", + "inputControl.editor.controlEditor.removeControlAriaLabel": "Retirer le contrôle", + "inputControl.editor.controlsTab.addButtonLabel": "Ajouter", + "inputControl.editor.controlsTab.select.addControlAriaLabel": "Ajouter un contrôle", + "inputControl.editor.controlsTab.select.controlTypeAriaLabel": "Choisir le type de contrôle", + "inputControl.editor.controlsTab.select.listDropDownOptionLabel": "Liste des options", + "inputControl.editor.controlsTab.select.rangeDropDownOptionLabel": "Curseur de plage", + "inputControl.editor.fieldSelect.fieldLabel": "Champ", + "inputControl.editor.fieldSelect.selectFieldPlaceholder": "Sélectionner un champ…", + "inputControl.editor.indexPatternSelect.patternLabel": "Modèle d'indexation", + "inputControl.editor.indexPatternSelect.patternPlaceholder": "Sélectionner un modèle d'indexation…", + "inputControl.editor.listControl.dynamicOptions.stringFieldDescription": "Uniquement disponible pour les champs de type chaîne", + "inputControl.editor.listControl.dynamicOptions.updateDescription": "Mettre à jour les options en réponse aux informations fournies par l'utilisateur", + "inputControl.editor.listControl.dynamicOptionsLabel": "Options dynamiques", + "inputControl.editor.listControl.multiselectDescription": "Permettre une sélection multiple", + "inputControl.editor.listControl.multiselectLabel": "Sélection multiple", + "inputControl.editor.listControl.parentDescription": "Les options sont basées sur la valeur du contrôle parent. Désactivé si le parent n'est pas défini.", + "inputControl.editor.listControl.parentLabel": "Contrôle parent", + "inputControl.editor.listControl.sizeDescription": "Nombre d'options", + "inputControl.editor.listControl.sizeLabel": "Taille", + "inputControl.editor.optionsTab.pinFiltersLabel": "Épingler les filtres pour toutes les applications", + "inputControl.editor.optionsTab.updateFilterLabel": "Mettre à jour les filtres Kibana à chaque modification", + "inputControl.editor.optionsTab.useTimeFilterLabel": "Utiliser le filtre temporel", + "inputControl.editor.rangeControl.decimalPlacesLabel": "Décimales", + "inputControl.editor.rangeControl.stepSizeLabel": "Taille de l'étape", + "inputControl.function.help": "Visualisation du contrôle d'entrée", + "inputControl.listControl.disableTooltip": "Désactivé jusqu'à ce que \"{label}\" soit défini.", + "inputControl.listControl.unableToFetchTooltip": "Impossible de récupérer les termes. Erreur : {errorMessage}.", + "inputControl.rangeControl.unableToFetchTooltip": "Impossible de récupérer les valeurs min. et max. de la plage. Erreur : {errorMessage}.", + "inputControl.register.controlsDescription": "Ajoutez des menus déroulants et des curseurs de plage à votre tableau de bord.", + "inputControl.register.controlsTitle": "Contrôles", + "inputControl.register.tabs.controlsTitle": "Contrôles", + "inputControl.register.tabs.optionsTitle": "Options", + "inputControl.vis.inputControlVis.applyChangesButtonLabel": "Appliquer les modifications", + "inputControl.vis.inputControlVis.cancelChangesButtonLabel": "Annuler les modifications", + "inputControl.vis.inputControlVis.clearFormButtonLabel": "Effacer le formulaire", + "inputControl.vis.listControl.partialResultsWarningMessage": "La liste des termes peut être incomplète, car la requête prend trop de temps. Ajustez les paramètres de saisie semi-automatique dans le fichier kibana.yml pour obtenir des résultats complets.", + "inputControl.vis.listControl.selectPlaceholder": "Sélectionner…", + "inputControl.vis.listControl.selectTextPlaceholder": "Sélectionner…", + "inspector.closeButton": "Fermer l'inspecteur", + "inspector.reqTimestampDescription": "Heure de début de la requête", + "inspector.reqTimestampKey": "Horodatage de la requête", + "inspector.requests.copyToClipboardLabel": "Copier dans le presse-papiers", + "inspector.requests.descriptionRowIconAriaLabel": "Description", + "inspector.requests.failedLabel": " (échec)", + "inspector.requests.noRequestsLoggedDescription.elementHasNotLoggedAnyRequestsText": "L'élément n'a pas (encore) consigné de requêtes.", + "inspector.requests.noRequestsLoggedDescription.whatDoesItUsuallyMeanText": "Cela signifie généralement qu'il n'était pas nécessaire de récupérer des données ou que l'élément n'a pas encore commencé à récupérer des données.", + "inspector.requests.noRequestsLoggedTitle": "Aucune requête consignée", + "inspector.requests.requestFailedTooltipTitle": "Échec de la requête", + "inspector.requests.requestInProgressAriaLabel": "Requête en cours", + "inspector.requests.requestsDescriptionTooltip": "Voir les requêtes qui ont collecté les données", + "inspector.requests.requestsTitle": "Requêtes", + "inspector.requests.requestSucceededTooltipTitle": "Requête réussie", + "inspector.requests.requestTabLabel": "Requête", + "inspector.requests.requestTimeLabel": "{requestTime}ms", + "inspector.requests.requestTooltipDescription": "Durée totale qu'a nécessité la requête.", + "inspector.requests.requestWasMadeDescription": "{requestsCount, plural, one {# requête a été effectuée} other {# requêtes ont été effectuées} }{failedRequests}", + "inspector.requests.requestWasMadeDescription.requestHadFailureText": ", {failedCount} a/ont échoué.", + "inspector.requests.responseTabLabel": "Réponse", + "inspector.requests.searchSessionId": "ID de la session de recherche : {searchSessionId}", + "inspector.requests.statisticsTabLabel": "Statistiques", + "inspector.title": "Inspecteur", + "inspector.view": "Vue : {viewName}", + "kibana_utils.history.savedObjectIsMissingNotificationMessage": "L'objet enregistré est manquant.", + "kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "Impossible de restaurer complètement l'URL. Assurez-vous d'utiliser la fonctionnalité de partage.", + "kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "Kibana n'est pas en mesure de stocker des éléments d'historique dans votre session, car le stockage est arrivé à saturation et il ne semble pas y avoir d'éléments pouvant être supprimés sans risque.\n\nCe problème peut généralement être corrigé en passant à un nouvel onglet, mais il peut être causé par un problème plus important. Si ce message s'affiche régulièrement, veuillez nous en faire part sur {gitHubIssuesUrl}.", + "kibana_utils.stateManagement.url.restoreUrlErrorTitle": "Erreur lors de la restauration de l'état depuis l'URL.", + "kibana_utils.stateManagement.url.saveStateInUrlErrorTitle": "Erreur lors de l'enregistrement de l'état dans l'URL.", + "kibana-react.dualRangeControl.maxInputAriaLabel": "Maximum de la plage", + "kibana-react.dualRangeControl.minInputAriaLabel": "Minimum de la plage", + "kibana-react.dualRangeControl.mustSetBothErrorMessage": "Les valeurs inférieure et supérieure doivent être définies.", + "kibana-react.dualRangeControl.outsideOfRangeErrorMessage": "Les valeurs doivent être comprises entre {min} et {max}, inclus.", + "kibana-react.dualRangeControl.upperValidErrorMessage": "La valeur supérieure doit être supérieure ou égale à la valeur inférieure.", + "kibana-react.exitFullScreenButton.exitFullScreenModeButtonAriaLabel": "Quitter le mode Plein écran", + "kibana-react.exitFullScreenButton.exitFullScreenModeButtonText": "Quitter le plein écran", + "kibana-react.exitFullScreenButton.fullScreenModeDescription": "En mode Plein écran, appuyez sur Échap pour quitter.", + "kibana-react.kbnOverviewPageHeader.devToolsButtonLabel": "Outils de développement", + "kibana-react.kbnOverviewPageHeader.stackManagementButtonLabel": "Gérer", + "kibana-react.kibanaCodeEditor.ariaLabel": "Éditeur de code", + "kibana-react.kibanaCodeEditor.enterKeyLabel": "Entrée", + "kibana-react.kibanaCodeEditor.escapeKeyLabel": "Échap", + "kibana-react.kibanaCodeEditor.startEditing": "Appuyez sur {key} pour modifier.", + "kibana-react.kibanaCodeEditor.startEditingReadOnly": "Appuyez sur {key} pour interagir avec le code.", + "kibana-react.kibanaCodeEditor.stopEditing": "Appuyez sur {key} pour arrêter la modification.", + "kibana-react.kibanaCodeEditor.stopEditingReadOnly": "Appuyez sur {key} pour arrêter l'interaction.", + "kibana-react.mountPointPortal.errorMessage": "Erreur lors du rendu du contenu du portail.", + "kibana-react.noDataPage.cantDecide": "Vous ne savez pas quoi utiliser ? {link}", + "kibana-react.noDataPage.cantDecide.link": "Consultez la documentation pour en savoir plus.", + "kibana-react.noDataPage.elasticAgentCard.description": "Utilisez Elastic Agent pour collecter de manière simple et unifiée les données de vos machines.", + "kibana-react.noDataPage.elasticAgentCard.title": "Ajouter Elastic Agent", + "kibana-react.noDataPage.intro": "Ajoutez vos données pour commencer, ou {link} sur {solution}.", + "kibana-react.noDataPage.intro.link": "en savoir plus", + "kibana-react.noDataPage.noDataPage.recommended": "Recommandé", + "kibana-react.noDataPage.welcomeTitle": "Bienvenue dans Elastic {solution}.", + "kibana-react.pageFooter.changeDefaultRouteSuccessToast": "Page de destination mise à jour", + "kibana-react.pageFooter.changeHomeRouteLink": "Afficher une page différente à la connexion", + "kibana-react.pageFooter.makeDefaultRouteLink": "Choisir comme page de destination", + "kibana-react.solutionNav.collapsibleLabel": "Réduire la navigation latérale", + "kibana-react.solutionNav.mobileTitleText": "Menu {solutionName}", + "kibana-react.solutionNav.openLabel": "Ouvrir la navigation latérale", + "kibana-react.tableListView.listing.createNewItemButtonLabel": "Créer {entityName}", + "kibana-react.tableListView.listing.deleteButtonMessage": "Supprimer {itemCount} {entityName}", + "kibana-react.tableListView.listing.deleteConfirmModalDescription": "Vous ne pourrez pas récupérer les {entityNamePlural} supprimés.", + "kibana-react.tableListView.listing.deleteSelectedConfirmModal.title": "Supprimer {itemCount} {entityName} ?", + "kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.cancelButtonLabel": "Annuler", + "kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabel": "Supprimer", + "kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabelDeleting": "Suppression", + "kibana-react.tableListView.listing.fetchErrorDescription": "Le listing {entityName} n'a pas pu être récupéré : {message}.", + "kibana-react.tableListView.listing.fetchErrorTitle": "Échec de la récupération du listing", + "kibana-react.tableListView.listing.listingLimitExceeded.advancedSettingsLinkText": "Paramètres avancés", + "kibana-react.tableListView.listing.listingLimitExceededDescription": "Vous avez {totalItems} {entityNamePlural}, mais votre paramètre {listingLimitText} empêche le tableau ci-dessous d'en afficher plus de {listingLimitValue}. Vous pouvez modifier ce paramètre sous {advancedSettingsLink}.", + "kibana-react.tableListView.listing.listingLimitExceededTitle": "Limite de listing dépassée", + "kibana-react.tableListView.listing.table.actionTitle": "Actions", + "kibana-react.tableListView.listing.table.editActionDescription": "Modifier", + "kibana-react.tableListView.listing.table.editActionName": "Modifier", + "kibana-react.tableListView.listing.unableToDeleteDangerMessage": "Impossible de supprimer la/le/les {entityName}(s)", + "kibanaOverview.addData.sampleDataButtonLabel": "Essayer l’exemple de données", + "kibanaOverview.addData.sectionTitle": "Ingérer des données", + "kibanaOverview.apps.title": "Explorer les applications", + "kibanaOverview.breadcrumbs.title": "Analytique", + "kibanaOverview.header.title": "Analytique", + "kibanaOverview.kibana.solution.description": "Explorez, visualisez et analysez vos données à l'aide d'une puissante suite d'outils et d'applications analytiques.", + "kibanaOverview.kibana.solution.title": "Analytique", + "kibanaOverview.manageData.sectionTitle": "Gérer vos données", + "kibanaOverview.more.title": "Toujours plus avec Elastic", + "kibanaOverview.news.title": "Nouveautés", + "kibanaOverview.noDataConfig.solutionName": "Analytique", + "lists.exceptions.doesNotExistOperatorLabel": "n'existe pas", + "lists.exceptions.existsOperatorLabel": "existe", + "lists.exceptions.isInListOperatorLabel": "est dans la liste", + "lists.exceptions.isNotInListOperatorLabel": "n'est pas dans la liste", + "lists.exceptions.isNotOneOfOperatorLabel": "n'est pas l'une des options suivantes", + "lists.exceptions.isNotOperatorLabel": "n'est pas", + "lists.exceptions.isOneOfOperatorLabel": "est l'une des options suivantes", + "lists.exceptions.isOperatorLabel": "est", + "management.breadcrumb": "Gestion de la Suite", + "management.landing.header": "Bienvenue dans Gestion de la Suite {version}", + "management.landing.subhead": "Gérez vos index, modèles d'indexation, objets enregistrés, paramètres Kibana et plus encore.", + "management.landing.text": "Vous trouverez une liste complète des applications dans le menu de gauche.", + "management.nav.label": "Gestion", + "management.sections.dataTip": "Gérez les données et les sauvegardes de vos clusters.", + "management.sections.dataTitle": "Données", + "management.sections.ingestTip": "Gérez la manière dont les données sont transformées et chargées dans le cluster.", + "management.sections.ingestTitle": "Ingestion", + "management.sections.insightsAndAlertingTip": "Gérez le mode de détection des changements dans vos données.", + "management.sections.insightsAndAlertingTitle": "Alertes et informations exploitables", + "management.sections.kibanaTip": "Personnalisez Kibana et gérez les objets enregistrés.", + "management.sections.kibanaTitle": "Kibana", + "management.sections.section.tip": "Contrôlez l'accès aux fonctionnalités et aux données.", + "management.sections.section.title": "Sécurité", + "management.sections.stackTip": "Gérez votre licence et mettez la Suite à niveau.", + "management.sections.stackTitle": "Suite", + "management.stackManagement.managementDescription": "La console centrale de gestion de la Suite Elastic.", + "management.stackManagement.managementLabel": "Gestion de la Suite", + "management.stackManagement.title": "Gestion de la Suite", + "monaco.painlessLanguage.autocomplete.docKeywordDescription": "Accéder à une valeur de champ dans un script au moyen de la syntaxe doc['field_name']", + "monaco.painlessLanguage.autocomplete.emitKeywordDescription": "Émettre une valeur sans rien renvoyer", + "monaco.painlessLanguage.autocomplete.fieldValueDescription": "Récupérer la valeur du champ \"{fieldName}\"", + "monaco.painlessLanguage.autocomplete.paramsKeywordDescription": "Accéder aux variables transmises dans le script", + "newsfeed.emptyPrompt.noNewsText": "Si votre instance Kibana n'a pas accès à Internet, demandez à votre administrateur de désactiver cette fonctionnalité. Sinon, nous continuerons d'essayer de récupérer les actualités.", + "newsfeed.emptyPrompt.noNewsTitle": "Pas d'actualités ?", + "newsfeed.flyoutList.closeButtonLabel": "Fermer", + "newsfeed.flyoutList.versionTextLabel": "{version}", + "newsfeed.flyoutList.whatsNewTitle": "Nouveautés Elastic", + "newsfeed.headerButton.readAriaLabel": "Menu du fil d'actualités – Tous les éléments lus", + "newsfeed.headerButton.unreadAriaLabel": "Menu du fil d'actualités – Éléments non lus disponibles", + "newsfeed.loadingPrompt.gettingNewsText": "Obtention des dernières actualités…", + "presentationUtil.dashboardPicker.searchDashboardPlaceholder": "Recherche dans les tableaux de bord…", + "presentationUtil.labs.components.browserSwitchHelp": "Active l'atelier pour ce navigateur et persiste après sa fermeture.", + "presentationUtil.labs.components.browserSwitchName": "Navigateur", + "presentationUtil.labs.components.calloutHelp": "Actualiser pour appliquer les modifications", + "presentationUtil.labs.components.closeButtonLabel": "Fermer", + "presentationUtil.labs.components.descriptionMessage": "Essayez nos fonctionnalités expérimentales ou en cours.", + "presentationUtil.labs.components.disabledStatusMessage": "Par défaut : {status}", + "presentationUtil.labs.components.enabledStatusMessage": "Par défaut : {status}", + "presentationUtil.labs.components.kibanaSwitchHelp": "Active cet atelier pour tous les utilisateurs Kibana.", + "presentationUtil.labs.components.kibanaSwitchName": "Kibana", + "presentationUtil.labs.components.labFlagsLabel": "Indicateurs d'atelier", + "presentationUtil.labs.components.noProjectsinSolutionMessage": "Aucun atelier actuellement dans {solutionName}.", + "presentationUtil.labs.components.noProjectsMessage": "Aucun atelier actuellement disponible.", + "presentationUtil.labs.components.overrideFlagsLabel": "Remplacements", + "presentationUtil.labs.components.overridenIconTipLabel": "Valeur par défaut remplacée", + "presentationUtil.labs.components.resetToDefaultLabel": "Réinitialiser aux valeurs par défaut", + "presentationUtil.labs.components.sessionSwitchHelp": "Active l’atelier pour cette session de navigateur afin de le réinitialiser lors de sa fermeture.", + "presentationUtil.labs.components.sessionSwitchName": "Session", + "presentationUtil.labs.components.titleLabel": "Ateliers", + "presentationUtil.labs.enableDeferBelowFoldProjectDescription": "Les panneaux sous \"le pli\" (la zone masquée en dessous de la fenêtre accessible en faisant défiler), ne se chargeront pas immédiatement, mais seulement lorsqu'ils entreront dans la fenêtre d'affichage.", + "presentationUtil.labs.enableDeferBelowFoldProjectName": "Différer le chargement des panneaux sous \"le pli\"", + "presentationUtil.saveModalDashboard.addToDashboardLabel": "Ajouter au tableau de bord", + "presentationUtil.saveModalDashboard.dashboardInfoTooltip": "Les éléments ajoutés à la bibliothèque Visualize sont disponibles pour tous les tableaux de bord. Les modifications apportées à un élément de bibliothèque sont répercutées partout où il est utilisé.", + "presentationUtil.saveModalDashboard.existingDashboardOptionLabel": "Existant", + "presentationUtil.saveModalDashboard.libraryOptionLabel": "Ajouter à la bibliothèque", + "presentationUtil.saveModalDashboard.newDashboardOptionLabel": "Nouveau", + "presentationUtil.saveModalDashboard.noDashboardOptionLabel": "Aucun", + "presentationUtil.saveModalDashboard.saveAndGoToDashboardLabel": "Enregistrer et accéder au tableau de bord", + "presentationUtil.saveModalDashboard.saveLabel": "Enregistrer", + "presentationUtil.saveModalDashboard.saveToLibraryLabel": "Enregistrer et ajouter à la bibliothèque", + "presentationUtil.solutionToolbar.editorMenuButtonLabel": "Tous les éditeurs", + "presentationUtil.solutionToolbar.libraryButtonLabel": "Ajouter depuis la bibliothèque", + "presentationUtil.solutionToolbar.quickButton.ariaButtonLabel": "Créer {createType}", + "presentationUtil.solutionToolbar.quickButton.legendLabel": "Création rapide", + "savedObjects.advancedSettings.listingLimitText": "Nombre d'objets à récupérer pour les pages de listing", + "savedObjects.advancedSettings.listingLimitTitle": "Limite de listing d’objets", + "savedObjects.advancedSettings.perPageText": "Nombre d'objets à afficher par page dans la boîte de dialogue de chargement", + "savedObjects.advancedSettings.perPageTitle": "Objets par page", + "savedObjects.confirmModal.cancelButtonLabel": "Annuler", + "savedObjects.confirmModal.overwriteButtonLabel": "Écraser", + "savedObjects.confirmModal.overwriteConfirmationMessage": "Êtes-vous sûr de vouloir écraser {title} ?", + "savedObjects.confirmModal.overwriteTitle": "Écraser {name} ?", + "savedObjects.confirmModal.saveDuplicateButtonLabel": "Enregistrer {name}", + "savedObjects.confirmModal.saveDuplicateConfirmationMessage": "Il y a déjà une occurrence de {name} avec le titre \"{title}\". Voulez-vous tout de même enregistrer ?", + "savedObjects.finder.filterButtonLabel": "Types", + "savedObjects.finder.searchPlaceholder": "Rechercher…", + "savedObjects.finder.sortAsc": "Croissant", + "savedObjects.finder.sortAuto": "Meilleure correspondance", + "savedObjects.finder.sortButtonLabel": "Trier", + "savedObjects.finder.sortDesc": "Décroissant", + "savedObjects.overwriteRejectedDescription": "La confirmation d'écrasement a été rejetée.", + "savedObjects.saveDuplicateRejectedDescription": "La confirmation d'enregistrement avec un doublon de titre a été rejetée.", + "savedObjects.saveModal.cancelButtonLabel": "Annuler", + "savedObjects.saveModal.descriptionLabel": "Description", + "savedObjects.saveModal.duplicateTitleDescription": "L'enregistrement de \"{title}\" crée un doublon de titre.", + "savedObjects.saveModal.duplicateTitleLabel": "Ce {objectType} existe déjà.", + "savedObjects.saveModal.saveAsNewLabel": "Enregistrer en tant que nouveau {objectType}", + "savedObjects.saveModal.saveButtonLabel": "Enregistrer", + "savedObjects.saveModal.saveTitle": "Enregistrer {objectType}", + "savedObjects.saveModal.titleLabel": "Titre", + "savedObjects.saveModalOrigin.addToOriginLabel": "Ajouter", + "savedObjects.saveModalOrigin.originAfterSavingSwitchLabel": "{originVerb} à {origin} après l'enregistrement", + "savedObjects.saveModalOrigin.returnToOriginLabel": "Renvoyer", + "savedObjects.saveModalOrigin.saveAndReturnLabel": "Enregistrer et renvoyer", + "savedObjectsManagement.breadcrumb.index": "Objets enregistrés", + "savedObjectsManagement.deleteConfirm.modalDeleteButtonLabel": "Supprimer", + "savedObjectsManagement.deleteConfirm.modalDescription": "Cette action supprime définitivement l'objet de Kibana.", + "savedObjectsManagement.deleteConfirm.modalTitle": "Supprimer \"{title}\" ?", + "savedObjectsManagement.deleteSavedObjectsConfirmModalDescription": "Cette action supprimera les objets enregistrés suivants :", + "savedObjectsManagement.importSummary.createdCountHeader": "{createdCount} nouveau(x)", + "savedObjectsManagement.importSummary.createdOutcomeLabel": "Créé", + "savedObjectsManagement.importSummary.errorCountHeader": "{errorCount} erreur(s)", + "savedObjectsManagement.importSummary.errorOutcomeLabel": "{errorMessage}", + "savedObjectsManagement.importSummary.headerLabel": "{importCount, plural, one {1 objet importé} other {# objets importés}}", + "savedObjectsManagement.importSummary.overwrittenCountHeader": "{overwrittenCount} écrasé(s)", + "savedObjectsManagement.importSummary.overwrittenOutcomeLabel": "Écrasé", + "savedObjectsManagement.importSummary.warnings.defaultButtonLabel": "Go", + "savedObjectsManagement.managementSectionLabel": "Objets enregistrés", + "savedObjectsManagement.objects.savedObjectsDescription": "Importez, exportez et gérez vos recherches enregistrées, vos visualisations et vos tableaux de bord.", + "savedObjectsManagement.objects.savedObjectsTitle": "Objets enregistrés", + "savedObjectsManagement.objectsTable.deleteConfirmModal.cannotDeleteCallout.title": "Certains objets ne peuvent pas être supprimés.", + "savedObjectsManagement.objectsTable.deleteConfirmModal.sharedObjectsCallout.content": "Les objets partagés sont supprimés de tous les espaces dans lesquels ils se trouvent.", + "savedObjectsManagement.objectsTable.deleteConfirmModal.sharedObjectsCallout.title": "{sharedObjectsCount, plural, one {# objet enregistré est partagé} other {# de vos objets enregistrés sont partagés}}.", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.cancelButtonLabel": "Annuler", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.deleteButtonLabel": "Supprimer {objectsCount, plural, one {# objet} other {# objets}}", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.idColumnName": "ID", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName": "Titre", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.typeColumnName": "Type", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModalTitle": "Supprimer les objets enregistrés", + "savedObjectsManagement.objectsTable.export.successNotification": "Votre fichier est en cours de téléchargement en arrière-plan.", + "savedObjectsManagement.objectsTable.export.successWithExcludedObjectsNotification": "Votre fichier est en cours de téléchargement en arrière-plan. Certains objets ont été exclus de l'export. Vous trouverez la liste des objets exclus à la dernière ligne du fichier exporté.", + "savedObjectsManagement.objectsTable.export.successWithMissingRefsNotification": "Votre fichier est en cours de téléchargement en arrière-plan. Certains objets associés sont introuvables. Vous trouverez la liste des objets manquants à la dernière ligne du fichier exporté.", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModal.cancelButtonLabel": "Annuler", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModal.exportAllButtonLabel": "Exporter tout", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModal.exportOptionsLabel": "Options", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModal.includeReferencesDeepLabel": "Inclure les objets associés", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModalDescription": "Sélectionner les types d'objet à exporter", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModalTitle": "Exporter {filteredItemCount, plural, one {# objet} other {# objets}}", + "savedObjectsManagement.objectsTable.flyout.errorCalloutTitle": "Désolé, une erreur est survenue.", + "savedObjectsManagement.objectsTable.flyout.import.cancelButtonLabel": "Annuler", + "savedObjectsManagement.objectsTable.flyout.import.confirmButtonLabel": "Importer", + "savedObjectsManagement.objectsTable.flyout.importFileErrorMessage": "Impossible de traiter le fichier en raison d'une erreur : \"{error}\".", + "savedObjectsManagement.objectsTable.flyout.importPromptText": "Importer", + "savedObjectsManagement.objectsTable.flyout.importSavedObjectTitle": "Importer les objets enregistrés", + "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmAllChangesButtonLabel": "Confirmer toutes les modifications", + "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmButtonLabel": "Terminé", + "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsCalloutLinkText": "créer un nouveau modèle d'indexation", + "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsDescription": "Les objets enregistrés suivants utilisent des modèles d'indexation qui n'existent pas. Veuillez sélectionner les modèles d'indexation que vous souhaitez réassocier aux objets. Vous pouvez {indexPatternLink} si nécessaire.", + "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsTitle": "Conflits de modèle d'indexation", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription": "Nombre d'objets concernés", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName": "Décompte", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription": "ID du modèle d'indexation", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdName": "ID", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnNewIndexPatternName": "Nouveau modèle d'indexation", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsDescription": "Exemple d'objets concernés", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsName": "Exemple d'objets concernés", + "savedObjectsManagement.objectsTable.flyout.selectFileToImportFormRowLabel": "Sélectionner un fichier à importer", + "savedObjectsManagement.objectsTable.header.exportButtonLabel": "Exporter {filteredCount, plural, one{# objet} other {# objets}}", + "savedObjectsManagement.objectsTable.header.importButtonLabel": "Importer", + "savedObjectsManagement.objectsTable.header.refreshButtonLabel": "Actualiser", + "savedObjectsManagement.objectsTable.header.savedObjectsTitle": "Objets enregistrés", + "savedObjectsManagement.objectsTable.howToDeleteSavedObjectsDescription": "Gérez et partagez vos objets enregistrés. Pour modifier les données sous-jacentes d'un objet, accédez à l’application associée.", + "savedObjectsManagement.objectsTable.importModeControl.createNewCopies.disabledText": "Vérifiez si les objets ont déjà été copiés ou importés.", + "savedObjectsManagement.objectsTable.importModeControl.createNewCopies.disabledTitle": "Rechercher les objets existants", + "savedObjectsManagement.objectsTable.importModeControl.createNewCopies.enabledText": "Utilisez cette option pour créer une ou plusieurs copies de l'objet.", + "savedObjectsManagement.objectsTable.importModeControl.createNewCopies.enabledTitle": "Créer de nouveaux objets avec des ID aléatoires", + "savedObjectsManagement.objectsTable.importModeControl.importOptionsTitle": "Options d'importation", + "savedObjectsManagement.objectsTable.importModeControl.overwrite.disabledLabel": "Demander une action en cas de conflit", + "savedObjectsManagement.objectsTable.importModeControl.overwrite.enabledLabel": "Écraser automatiquement les conflits", + "savedObjectsManagement.objectsTable.importSummary.unsupportedTypeError": "Type d'objet non pris en charge", + "savedObjectsManagement.objectsTable.overwriteModal.body.ambiguousConflict": "\"{title}\" est en conflit avec plusieurs objets existants. En écraser un ?", + "savedObjectsManagement.objectsTable.overwriteModal.body.conflict": "\"{title}\" est en conflit avec un objet existant. L'écraser ?", + "savedObjectsManagement.objectsTable.overwriteModal.cancelButtonText": "Ignorer", + "savedObjectsManagement.objectsTable.overwriteModal.overwriteButtonText": "Écraser", + "savedObjectsManagement.objectsTable.overwriteModal.selectControlLabel": "ID d'objet", + "savedObjectsManagement.objectsTable.overwriteModal.title": "Écraser {type} ?", + "savedObjectsManagement.objectsTable.relationships.columnActions.inspectActionDescription": "Inspecter cet objet enregistré", + "savedObjectsManagement.objectsTable.relationships.columnActions.inspectActionName": "Inspecter", + "savedObjectsManagement.objectsTable.relationships.columnActionsName": "Actions", + "savedObjectsManagement.objectsTable.relationships.columnErrorDescription": "Erreur rencontrée avec la relation", + "savedObjectsManagement.objectsTable.relationships.columnErrorName": "Erreur", + "savedObjectsManagement.objectsTable.relationships.columnIdDescription": "ID de l'objet enregistré", + "savedObjectsManagement.objectsTable.relationships.columnIdName": "ID", + "savedObjectsManagement.objectsTable.relationships.columnRelationship.childAsValue": "Enfant", + "savedObjectsManagement.objectsTable.relationships.columnRelationship.parentAsValue": "Parent", + "savedObjectsManagement.objectsTable.relationships.columnRelationshipName": "Relation directe", + "savedObjectsManagement.objectsTable.relationships.columnTitleDescription": "Titre de l'objet enregistré", + "savedObjectsManagement.objectsTable.relationships.columnTitleName": "Titre", + "savedObjectsManagement.objectsTable.relationships.columnTypeDescription": "Type de l'objet enregistré", + "savedObjectsManagement.objectsTable.relationships.columnTypeName": "Type", + "savedObjectsManagement.objectsTable.relationships.invalidRelationShip": "Cet objet enregistré présente des relations non valides.", + "savedObjectsManagement.objectsTable.relationships.relationshipsTitle": "Voici les objets enregistrés associés à {title}. La suppression de ce {type} a un impact sur ses objets parents, mais pas sur ses enfants.", + "savedObjectsManagement.objectsTable.relationships.renderErrorMessage": "Erreur", + "savedObjectsManagement.objectsTable.relationships.search.filters.relationship.childAsValue.view": "Enfant", + "savedObjectsManagement.objectsTable.relationships.search.filters.relationship.name": "Relation directe", + "savedObjectsManagement.objectsTable.relationships.search.filters.relationship.parentAsValue.view": "Parent", + "savedObjectsManagement.objectsTable.relationships.search.filters.type.name": "Type", + "savedObjectsManagement.objectsTable.searchBar.unableToParseQueryErrorMessage": "Impossible d'analyser la requête", + "savedObjectsManagement.objectsTable.table.columnActions.inspectActionDescription": "Inspecter cet objet enregistré", + "savedObjectsManagement.objectsTable.table.columnActions.inspectActionName": "Inspecter", + "savedObjectsManagement.objectsTable.table.columnActions.viewRelationshipsActionDescription": "Afficher les relations entre cet objet enregistré et d'autres objets enregistrés", + "savedObjectsManagement.objectsTable.table.columnActions.viewRelationshipsActionName": "Relations", + "savedObjectsManagement.objectsTable.table.columnActionsName": "Actions", + "savedObjectsManagement.objectsTable.table.columnTitleDescription": "Titre de l'objet enregistré", + "savedObjectsManagement.objectsTable.table.columnTitleName": "Titre", + "savedObjectsManagement.objectsTable.table.columnTypeDescription": "Type de l'objet enregistré", + "savedObjectsManagement.objectsTable.table.columnTypeName": "Type", + "savedObjectsManagement.objectsTable.table.deleteButtonLabel": "Supprimer", + "savedObjectsManagement.objectsTable.table.deleteButtonTitle": "Impossible de supprimer les objets enregistrés", + "savedObjectsManagement.objectsTable.table.exportButtonLabel": "Exporter", + "savedObjectsManagement.objectsTable.table.exportPopoverButtonLabel": "Exporter", + "savedObjectsManagement.objectsTable.table.typeFilterName": "Type", + "savedObjectsManagement.objectsTable.unableFindSavedObjectNotificationMessage": "Objet enregistré introuvable", + "savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage": "Objets enregistrés introuvables", + "savedObjectsManagement.objectView.unableFindSavedObjectNotificationMessage": "Objet enregistré introuvable", + "savedObjectsManagement.view.fieldDoesNotExistErrorMessage": "Un champ associé à cet objet n'existe plus dans le modèle d'indexation.", + "savedObjectsManagement.view.indexPatternDoesNotExistErrorMessage": "Le modèle d'indexation associé à cet objet n'existe plus.", + "savedObjectsManagement.view.savedObjectProblemErrorMessage": "Un problème est survenu avec cet objet enregistré.", + "savedObjectsManagement.view.savedSearchDoesNotExistErrorMessage": "La recherche enregistrée associée à cet objet n'existe plus.", + "savedObjectsManagement.view.viewItemButtonLabel": "Afficher {title}", + "share.advancedSettings.csv.quoteValuesText": "Les valeurs doivent-elles être mises entre guillemets dans les exportations CSV ?", + "share.advancedSettings.csv.quoteValuesTitle": "Mettre les valeurs CSV entre guillemets", + "share.advancedSettings.csv.separatorText": "Séparer les valeurs exportées avec cette chaîne", + "share.advancedSettings.csv.separatorTitle": "Séparateur CSV", + "share.contextMenu.embedCodeLabel": "Incorporer le code", + "share.contextMenu.embedCodePanelTitle": "Incorporer le code", + "share.contextMenu.permalinkPanelTitle": "Permalien", + "share.contextMenu.permalinksLabel": "Permaliens", + "share.contextMenuTitle": "Partager ce {objectType}", + "share.urlPanel.canNotShareAsSavedObjectHelpText": "Impossible de partager comme objet enregistré tant que {objectType} n'a pas été enregistré.", + "share.urlPanel.copyIframeCodeButtonLabel": "Copier le code iFrame", + "share.urlPanel.copyLinkButtonLabel": "Copier le lien", + "share.urlPanel.generateLinkAsLabel": "Générer le lien en tant que", + "share.urlPanel.publicUrlHelpText": "Utilisez l'URL publique pour partager avec tout le monde. Elle permet un accès anonyme en une étape, en supprimant l'invite de connexion.", + "share.urlPanel.publicUrlLabel": "URL publique", + "share.urlPanel.savedObjectDescription": "Vous pouvez partager cette URL avec des personnes pour leur permettre de charger la version enregistrée la plus récente de ce {objectType}.", + "share.urlPanel.savedObjectLabel": "Objet enregistré", + "share.urlPanel.shortUrlHelpText": "Nous vous recommandons de partager des URL de snapshot raccourcies pour une compatibilité maximale. Internet Explorer présente des restrictions de longueur d'URL et certains analyseurs de wiki et de balisage ne fonctionnent pas bien avec les URL de snapshot longues, mais les URL courtes devraient bien fonctionner.", + "share.urlPanel.shortUrlLabel": "URL courte", + "share.urlPanel.snapshotDescription": "Les URL de snapshot encodent l'état actuel de {objectType} dans l'URL elle-même. Les modifications apportées au {objectType} enregistré ne seront pas visibles via cette URL.", + "share.urlPanel.snapshotLabel": "Snapshot", + "share.urlPanel.unableCreateShortUrlErrorMessage": "Impossible de créer une URL courte. Erreur : {errorMessage}.", + "share.urlPanel.urlGroupTitle": "URL", + "share.urlService.redirect.components.Error.title": "Erreur de redirection", + "share.urlService.redirect.components.Spinner.label": "Redirection…", + "share.urlService.redirect.RedirectManager.invalidParamParams": "Impossible d'analyser les paramètres du localisateur. Les paramètres du localisateur doivent être sérialisés en tant que JSON et définis au paramètre de recherche d'URL \"p\".", + "share.urlService.redirect.RedirectManager.locatorNotFound": "Le localisateur [ID = {id}] n'existe pas.", + "share.urlService.redirect.RedirectManager.missingParamLocator": "ID du localisateur non spécifié. Spécifiez le paramètre de recherche \"l\" dans l'URL ; ce devrait être un ID de localisateur existant.", + "share.urlService.redirect.RedirectManager.missingParamParams": "Paramètres du localisateur non spécifiés. Spécifiez le paramètre de recherche \"p\" dans l'URL ; ce devrait être un objet sérialisé JSON des paramètres du localisateur.", + "share.urlService.redirect.RedirectManager.missingParamVersion": "Version des paramètres du localisateur non spécifiée. Spécifiez le paramètre de recherche \"v\" dans l'URL ; ce devrait être la version de Kibana au moment de la génération des paramètres du localisateur.", + "telemetry.callout.appliesSettingTitle": "Les modifications apportées à ce paramètre s'appliquent dans {allOfKibanaText} et sont enregistrées automatiquement.", + "telemetry.callout.appliesSettingTitle.allOfKibanaText": "tout Kibana", + "telemetry.callout.clusterStatisticsDescription": "Voici un exemple des statistiques de cluster de base que nous collecterons. Cela comprend le nombre d'index, de partitions et de nœuds. Cela comprend également des statistiques d'utilisation de niveau élevé, comme l'état d'activation du monitoring.", + "telemetry.callout.clusterStatisticsTitle": "Statistiques du cluster", + "telemetry.callout.errorLoadingClusterStatisticsDescription": "Une erreur inattendue s'est produite lors de la récupération des statistiques du cluster. Cela peut être dû à un échec d'Elasticsearch ou de Kibana, ou provenir d’une erreur réseau. Vérifiez Kibana, puis rechargez la page et réessayez.", + "telemetry.callout.errorLoadingClusterStatisticsTitle": "Erreur lors du chargement des statistiques du cluster", + "telemetry.callout.errorUnprivilegedUserDescription": "Vous ne disposez pas de l'accès requis pour voir les statistiques non chiffrées du cluster.", + "telemetry.callout.errorUnprivilegedUserTitle": "Erreur lors de l'affichage des statistiques du cluster", + "telemetry.clusterData": "données du cluster", + "telemetry.optInErrorToastText": "Une erreur s'est produite lors de la définition des préférences relatives aux statistiques d'utilisation.", + "telemetry.optInErrorToastTitle": "Erreur", + "telemetry.optInNoticeSeenErrorTitle": "Erreur", + "telemetry.optInNoticeSeenErrorToastText": "Une erreur s'est produite lors du rejet de l'avis.", + "telemetry.optInSuccessOff": "Collecte des données d'utilisation désactivée.", + "telemetry.optInSuccessOn": "Collecte des données d'utilisation activée.", + "telemetry.readOurUsageDataPrivacyStatementLinkText": "Déclaration de confidentialité", + "telemetry.securityData": "données de sécurité des points de terminaison", + "telemetry.telemetryBannerDescription": "Vous souhaitez nous aider à améliorer la Suite Elastic ? La collecte de données d'utilisation est actuellement désactivée. En activant la collecte de données d'utilisation, vous nous aidez à gérer et à améliorer nos produits et nos services. Consultez notre {privacyStatementLink} pour plus d'informations.", + "telemetry.telemetryConfigAndLinkDescription": "En activant la collecte de données d'utilisation, vous nous aidez à gérer et à améliorer nos produits et nos services. Consultez notre {privacyStatementLink} pour plus d'informations.", + "telemetry.telemetryOptedInDisableUsage": "désactivez les données d'utilisation ici", + "telemetry.telemetryOptedInDismissMessage": "Rejeter", + "telemetry.telemetryOptedInNoticeDescription": "Pour en savoir plus sur la manière dont les données d'utilisation nous aident à gérer et à améliorer nos produits et nos services, consultez notre {privacyStatementLink}. Pour mettre fin à la collecte, {disableLink}.", + "telemetry.telemetryOptedInNoticeTitle": "Aidez-nous à améliorer la Suite Elastic.", + "telemetry.telemetryOptedInPrivacyStatement": "Déclaration de confidentialité", + "telemetry.usageDataTitle": "Données d'utilisation", + "telemetry.welcomeBanner.disableButtonLabel": "Désactiver", + "telemetry.welcomeBanner.enableButtonLabel": "Activer", + "telemetry.welcomeBanner.telemetryConfigDetailsDescription.telemetryPrivacyStatementLinkText": "Déclaration de confidentialité", + "telemetry.welcomeBanner.title": "Aidez-nous à améliorer la Suite Elastic.", + "timelion.emptyExpressionErrorMessage": "Erreur Timelion : aucune expression fournie", + "timelion.expressionSuggestions.argument.description.acceptsText": "Accepte", + "timelion.expressionSuggestions.func.description.chainableHelpText": "Enchaînable", + "timelion.expressionSuggestions.func.description.dataSourceHelpText": "Source de données", + "timelion.fitFunctions.carry.downSampleErrorMessage": "N'utilisez pas la méthode fit \"carry\" pour sous-échantillonner, utilisez \"scale\" ou \"average\".", + "timelion.function.help": "Visualisation Timelion", + "timelion.help.functions.absHelpText": "Renvoyer la valeur absolue de chaque valeur dans la liste des séries", + "timelion.help.functions.aggregate.args.functionHelpText": "L'une des options suivantes : {functions}.", + "timelion.help.functions.aggregateHelpText": "Crée une ligne statique sur la base du résultat du traitement de tous les points de la série. Fonctions disponibles : {functions}", + "timelion.help.functions.bars.args.stackHelpText": "Vrai par défaut si les barres sont empilées", + "timelion.help.functions.bars.args.widthHelpText": "Largeur des barres en pixels", + "timelion.help.functions.barsHelpText": "Afficher la liste des séries sous la forme de barres", + "timelion.help.functions.color.args.colorHelpText": "Couleur des séries en valeurs hexadécimales, par ex. #c6c6c6 est un très joli gris clair. Si vous spécifiez plusieurs couleurs et que vous avez plusieurs séries, vous obtiendrez un dégradé, par ex. \"#00B1CC:#00FF94:#FF3A39:#CC1A6F\".", + "timelion.help.functions.colorHelpText": "Changer la couleur des séries", + "timelion.help.functions.common.args.fitHelpText": "Algorithme à utiliser pour adapter les séries à l'intervalle et à la période cible. Disponible : {fitFunctions}", + "timelion.help.functions.common.args.offsetHelpText": "Décalez la récupération des séries avec une expression de date, par ex. -1M pour afficher les événements d'il y a un mois comme s'ils se produisaient maintenant. Décalez les séries par rapport à la plage temporelle globale des graphiques en utilisant la valeur \"timerange\", par ex. \"timerange:-2\" pour obtenir un décalage correspondant à deux fois la plage temporelle globale du graphique dans le passé.", + "timelion.help.functions.condition.args.elseHelpText": "La valeur à laquelle le point sera défini si la comparaison est fausse. Si vous spécifiez une liste de séries, la première série sera utilisée.", + "timelion.help.functions.condition.args.ifHelpText": "La valeur à laquelle le point sera comparé. Si vous spécifiez une liste de séries, la première série sera utilisée.", + "timelion.help.functions.condition.args.operator.suggestions.eqHelpText": "égal", + "timelion.help.functions.condition.args.operator.suggestions.gteHelpText": "supérieur ou égal", + "timelion.help.functions.condition.args.operator.suggestions.gtHelpText": "supérieur à", + "timelion.help.functions.condition.args.operator.suggestions.lteHelpText": "inférieur ou égal", + "timelion.help.functions.condition.args.operator.suggestions.ltHelpText": "inférieur à", + "timelion.help.functions.condition.args.operator.suggestions.neHelpText": "différent", + "timelion.help.functions.condition.args.operatorHelpText": "Opérateur de comparaison à utiliser pour la comparaison ; les opérateurs valides sont eq (égal), ne (différent), lt (inférieur à), lte (inférieur ou égal), gt (supérieur à), gte (supérieur ou égal).", + "timelion.help.functions.condition.args.thenHelpText": "La valeur à laquelle le point sera défini si la comparaison est vraie. Si vous spécifiez une liste de séries, la première série sera utilisée.", + "timelion.help.functions.conditionHelpText": "Compare chaque point à un nombre ou au même point dans une autre série à l'aide d'un opérateur, puis définit sa valeur sur le résultat si la condition est vraie, avec un sinon facultatif.", + "timelion.help.functions.cusum.args.baseHelpText": "Numéro auquel commencer. Cela ajoute simplement ce numéro au début de la série", + "timelion.help.functions.cusumHelpText": "Renvoyez la somme cumulée d'une série, à partir d’une base.", + "timelion.help.functions.derivativeHelpText": "Tracez l'évolution des valeurs au fil du temps.", + "timelion.help.functions.divide.args.divisorHelpText": "Nombre de séries par lequel diviser. Une liste de plusieurs séries sera appliquée pour l'étiquette.", + "timelion.help.functions.divideHelpText": "Divise les valeurs d'une ou de plusieurs séries d'une liste de séries à chaque position, dans chaque série, de la liste de séries d'entrée.", + "timelion.help.functions.es.args.indexHelpText": "Index à interroger, caractères génériques acceptés. Fournissez le nom du modèle d'indexation pour les champs scriptés et le type de nom de champ devant les suggestions pour les arguments metrics, split et timefield.", + "timelion.help.functions.es.args.intervalHelpText": "**NE PAS UTILISER**. C'est amusant pour déboguer les fonctions fit, mais vous devriez vraiment utiliser le sélecteur d'intervalle.", + "timelion.help.functions.es.args.kibanaHelpText": "Respectez les filtres des tableaux de bord Kibana. Cela n'a d'effet qu’en cas d'utilisation dans des tableaux de bord Kibana", + "timelion.help.functions.es.args.metricHelpText": "Une agrégation d'indicateurs Elasticsearch Moyenne, Somme, Min, Max, Centiles ou Cardinalité, puis un champ. Par ex. \"sum:bytes\", \"percentiles:bytes:95,99,99.9\" ou simplement \"count\".", + "timelion.help.functions.es.args.qHelpText": "Requête dans la syntaxe de chaîne de requête Lucene", + "timelion.help.functions.es.args.splitHelpText": "Un champ Elasticsearch avec lequel diviser la série et une limite. Par ex. \"{hostnameSplitArg}\" pour obtenir les 10 premiers noms d'hôte.", + "timelion.help.functions.es.args.timefieldHelpText": "Champ de type \"date\" à utiliser pour l'axe X", + "timelion.help.functions.esHelpText": "Extraire des données d'une instance Elasticsearch", + "timelion.help.functions.firstHelpText": "Il s'agit d'une fonction interne qui renvoie simplement la liste de séries d'entrée. Ne l'utilisez pas.", + "timelion.help.functions.fit.args.modeHelpText": "L'algorithme à utiliser pour adapter les séries à la cible. L'une des options suivantes : {fitFunctions}.", + "timelion.help.functions.fitHelpText": "Remplit les valeurs nulles à l'aide d'une fonction fit définie.", + "timelion.help.functions.graphite.args.metricHelpText": "Indicateur Graphite à extraire, par ex. {metricExample}", + "timelion.help.functions.graphiteHelpText": "[expérimental] Extrayez des données de Graphite. Configurez votre serveur Graphite dans les paramètres avancés de Kibana.", + "timelion.help.functions.hide.args.hideHelpText": "Masquer ou afficher les séries", + "timelion.help.functions.hideHelpText": "Masquer les séries par défaut", + "timelion.help.functions.holt.args.alphaHelpText": "\n Pondération de lissage de 0 à 1.\n Augmentez l’alpha pour que la nouvelle série suive de plus près l'originale.\n Diminuez-le pour rendre la série plus lisse.", + "timelion.help.functions.holt.args.betaHelpText": "\n Pondération de tendance de 0 à 1.\n Augmentez le bêta pour que les lignes montantes/descendantes continuent à monter/descendre plus longtemps.\n Diminuez-le pour que la fonction apprenne plus rapidement la nouvelle tendance.", + "timelion.help.functions.holt.args.gammaHelpText": "\n Pondération saisonnière de 0 à 1. Vos données ressemblent-elles à une vague ?\n Augmentez cette valeur pour donner plus d'importance aux saisons récentes et ainsi modifier plus rapidement la forme de la vague.\n Diminuez-la pour réduire l'importance des nouvelles saisons et ainsi rendre l'historique plus important.\n ", + "timelion.help.functions.holt.args.sampleHelpText": "\n Le nombre de saisons à échantillonner avant de commencer à \"prédire\" dans une série saisonnière.\n (Utile uniquement avec gamma, par défaut : all)", + "timelion.help.functions.holt.args.seasonHelpText": "La longueur de la saison, par ex. 1w, si votre modèle se répète chaque semaine. (Utile uniquement avec gamma)", + "timelion.help.functions.holtHelpText": "\n Échantillonner le début d'une série et l'utiliser pour prévoir ce qui devrait se produire\n via plusieurs paramètres facultatifs. En règle générale, cela ne prédit pas\n l'avenir, mais ce qui devrait se produire maintenant en fonction des données passées,\n ce qui peut être utile pour la détection des anomalies. Notez que les valeurs null seront remplacées par des valeurs prévues.", + "timelion.help.functions.label.args.labelHelpText": "Valeur de légende pour les séries. Vous pouvez utiliser $1, $2, etc. dans la chaîne pour correspondre aux groupes de captures d'expressions régulières.", + "timelion.help.functions.label.args.regexHelpText": "Une expression régulière compatible avec les groupes de captures", + "timelion.help.functions.labelHelpText": "Modifiez l'étiquette des séries. Utiliser %s pour référencer l'étiquette existante", + "timelion.help.functions.legend.args.columnsHelpText": "Nombre de colonnes à utiliser lors de la division de la légende", + "timelion.help.functions.legend.args.position.suggestions.falseHelpText": "désactiver la légende", + "timelion.help.functions.legend.args.position.suggestions.neHelpText": "placer la légende dans le coin nord-est", + "timelion.help.functions.legend.args.position.suggestions.nwHelpText": "placer la légende dans le coin nord-ouest", + "timelion.help.functions.legend.args.position.suggestions.seHelpText": "placer la légende dans le coin sud-est", + "timelion.help.functions.legend.args.position.suggestions.swHelpText": "placer la légende dans le coin sud-ouest", + "timelion.help.functions.legend.args.positionHelpText": "Coin dans lequel placer la légende : nw, ne, se ou sw. Il est également possible d'indiquer \"false\" pour désactiver la légende.", + "timelion.help.functions.legend.args.showTimeHelpText": "Afficher la valeur temporelle en légende lors du passage du curseur sur le graphique. Par défaut : true.", + "timelion.help.functions.legend.args.timeFormatHelpText": "Modèle de format moment.js. Par défaut : {defaultTimeFormat}", + "timelion.help.functions.legendHelpText": "Définir la position et le style de la légende sur le tracé", + "timelion.help.functions.lines.args.fillHelpText": "Nombre compris entre 0 et 10. À utiliser pour créer des graphiques en aires.", + "timelion.help.functions.lines.args.showHelpText": "Afficher ou masquer les lignes", + "timelion.help.functions.lines.args.stackHelpText": "Empiler les lignes, souvent équivoque. Utilisez au moins des remplissages si vous utilisez cette option.", + "timelion.help.functions.lines.args.stepsHelpText": "Afficher la ligne comme une étape ; autrement dit, ne pas interpoler entre les points", + "timelion.help.functions.lines.args.widthHelpText": "Épaisseur de ligne", + "timelion.help.functions.linesHelpText": "Afficher la liste de séries sous la forme de lignes", + "timelion.help.functions.log.args.baseHelpText": "Définir la base logarithmique ; 10 par défaut", + "timelion.help.functions.logHelpText": "Renvoyer la valeur logarithmique de chaque valeur de la liste des séries (base par défaut : 10)", + "timelion.help.functions.max.args.valueHelpText": "Définit le point sur la valeur existante ou la valeur transmise, selon la plus élevée des deux. Si une liste de séries est transmise, elle doit contenir exactement 1 série.", + "timelion.help.functions.maxHelpText": "Valeurs maximales d'une ou de plusieurs séries d'une liste de séries à chaque position, dans chaque série, de la liste de séries d'entrée.", + "timelion.help.functions.min.args.valueHelpText": "Définit le point sur la valeur existante ou la valeur transmise, selon la plus basse des deux. Si une liste de séries est transmise, elle doit contenir exactement 1 série.", + "timelion.help.functions.minHelpText": "Valeurs minimales d'une ou de plusieurs séries d'une liste de séries à chaque position, dans chaque série, de la liste de séries d'entrée.", + "timelion.help.functions.movingaverage.args.positionHelpText": "Position des points moyens par rapport à l'heure du résultat. L'une des options suivantes : {validPositions}.", + "timelion.help.functions.movingaverage.args.windowHelpText": "Nombre de points ou une expression mathématique de date (par ex. 1d, 1M) à utiliser pour calculer la moyenne. Si une expression mathématique de date est spécifiée, la fonction sera la plus proche possible compte tenu de l'intervalle sélectionné. Si l'expression mathématique de date n'est pas divisible uniformément par l'intervalle, les résultats peuvent sembler être anormaux.", + "timelion.help.functions.movingaverageHelpText": "Calculez la moyenne mobile pour une fenêtre donnée. Idéal pour lisser les séries avec beaucoup de bruit.", + "timelion.help.functions.movingstd.args.positionHelpText": "Position de la section de la fenêtre par rapport à l'heure du résultat. Les options sont {positions}. Par défaut : {defaultPosition}.", + "timelion.help.functions.movingstd.args.windowHelpText": "Nombre de points à utiliser pour calculer l'écart-type.", + "timelion.help.functions.movingstdHelpText": "Calculez l'écart-type mobile pour une fenêtre donnée. Utilise l'algorithme naïf en deux passes. Les erreurs d'arrondi peuvent devenir plus évidentes avec les séries très longues ou celles comportant de très grands nombres.", + "timelion.help.functions.multiply.args.multiplierHelpText": "Nombre de séries par lequel multiplier. Une liste de plusieurs séries sera appliquée pour l'étiquette.", + "timelion.help.functions.multiplyHelpText": "Multiplie les valeurs d'une ou de plusieurs séries d'une liste de séries à chaque position, dans chaque série, de la liste de séries d'entrée.", + "timelion.help.functions.notAllowedGraphiteUrl": "Cette URL Graphite n'est pas configurée dans le fichier kibana.yml.\n Veuillez configurer votre liste de serveurs Graphite dans le fichier kibana.yml, sous \"timelion.graphiteUrls\", puis\n en sélectionner un dans les paramètres avancés de Kibana.", + "timelion.help.functions.points.args.fillColorHelpText": "Couleur à utiliser pour remplir le point", + "timelion.help.functions.points.args.fillHelpText": "Nombre compris entre 0 et 10 représentant l'opacité du remplissage", + "timelion.help.functions.points.args.radiusHelpText": "Taille des points", + "timelion.help.functions.points.args.showHelpText": "Afficher ou non les points", + "timelion.help.functions.points.args.symbolHelpText": "symbole de point. L'une des options suivantes : {validSymbols}", + "timelion.help.functions.points.args.weightHelpText": "Épaisseur de la ligne autour du point", + "timelion.help.functions.pointsHelpText": "Afficher les séries sous la forme de points", + "timelion.help.functions.precision.args.precisionHelpText": "Le nombre de chiffres à garder lors de la troncature de chaque valeur", + "timelion.help.functions.precisionHelpText": "Le nombre de chiffres à garder lors de la troncature de la partie décimale de la valeur", + "timelion.help.functions.props.args.globalHelpText": "Définir des propositions sur la liste de séries plutôt que sur chaque série", + "timelion.help.functions.propsHelpText": "À utiliser à vos risques et périls ; définit des propriétés arbitraires sur la série. Par exemple : {example}", + "timelion.help.functions.quandl.args.codeHelpText": "Le code Quandl à tracer. Disponible sur quandl.com.", + "timelion.help.functions.quandl.args.positionHelpText": "Certaines sources Quandl renvoient plusieurs séries. Laquelle utiliser ? Index basé sur 1.", + "timelion.help.functions.quandlHelpText": "\n [expérimental]\n Extrayez des données de quandl.com à l'aide du code Quandl. Définissez {quandlKeyField} sur votre clé d'API gratuite dans\n les paramètres avancés de Kibana. La limite de taux de l'API est très basse sans clé.", + "timelion.help.functions.range.args.maxHelpText": "Nouvelle valeur maximale", + "timelion.help.functions.range.args.minHelpText": "Nouvelle valeur minimale", + "timelion.help.functions.rangeHelpText": "Modifie le maximum et le minimum d'une série sans changer la forme.", + "timelion.help.functions.scaleInterval.args.intervalHelpText": "Le nouvel intervalle en notation mathématique de date, par ex. 1s pour 1 seconde. 1m, 5m, 1M, 1w, 1y, etc.", + "timelion.help.functions.scaleIntervalHelpText": "Scale une valeur (généralement une somme ou un décompte) à un nouvel intervalle. Par exemple, un taux par seconde.", + "timelion.help.functions.static.args.labelHelpText": "Une manière rapide de définir l'étiquette pour la série. Vous pouvez également utiliser la fonction .label().", + "timelion.help.functions.static.args.valueHelpText": "La valeur unique à afficher. Vous pouvez également passer plusieurs valeurs, elles seront interpolées uniformément sur la plage temporelle.", + "timelion.help.functions.staticHelpText": "Dessine une valeur unique sur le graphique", + "timelion.help.functions.subtract.args.termHelpText": "Nombre de séries à soustraire de l'entrée. Une liste de plusieurs séries sera appliquée pour l'étiquette.", + "timelion.help.functions.subtractHelpText": "Soustrait les valeurs d'une ou de plusieurs séries d'une liste de séries à chaque position, dans chaque série, de la liste de séries d'entrée.", + "timelion.help.functions.sum.args.termHelpText": "Nombre de séries à ajouter à l'entrée. Une liste de plusieurs séries sera appliquée pour l'étiquette.", + "timelion.help.functions.sumHelpText": "Ajoute les valeurs d'une ou de plusieurs séries d'une liste de séries à chaque position, dans chaque série, de la liste de séries d'entrée.", + "timelion.help.functions.title.args.titleHelpText": "Titre pour le tracé.", + "timelion.help.functions.titleHelpText": "Ajoute un titre en haut du tracé. En cas d’appel sur plusieurs listes de séries, le dernier appel est utilisé.", + "timelion.help.functions.trend.args.endHelpText": "Quand arrêter de calculer par rapport au début ou à la fin. Par exemple, -10 indique qu'il faut arrêter de calculer 10 points avant la fin, et +15 indique que le calcul doit s'arrêter 15 points après le début. Par défaut : 0", + "timelion.help.functions.trend.args.modeHelpText": "L'algorithme à utiliser pour générer la courbe de tendance. L'une des options suivantes : {validRegressions}.", + "timelion.help.functions.trend.args.startHelpText": "Quand commencer à calculer par rapport au début ou à la fin. Par exemple, -10 indique qu'il faut commencer à calculer 10 points avant la fin, et +15 indique que le calcul doit commencer 15 points après le début. Par défaut : 0", + "timelion.help.functions.trendHelpText": "Dessine une courbe de tendance à l'aide d'un algorithme de régression spécifié.", + "timelion.help.functions.trim.args.endHelpText": "Compartiments à retirer de la fin de la série. Par défaut : 1", + "timelion.help.functions.trim.args.startHelpText": "Compartiments à retirer du début de la série. Par défaut : 1", + "timelion.help.functions.trimHelpText": "Définir N compartiments au début ou à la fin de la série sur null pour ajuster le \"problème de compartiment partiel\"", + "timelion.help.functions.worldbank.args.codeHelpText": "Chemin de l'API Worldbank (Banque mondiale). Il s'agit généralement de tout ce qui suit le domaine, avant la chaîne de requête. Par exemple : {apiPathExample}.", + "timelion.help.functions.worldbankHelpText": "\n [expérimental]\n Extrayez des données de {worldbankUrl} à l'aide du chemin d’accès aux séries.\n La Banque mondiale fournit surtout des données annuelles et n'a souvent aucune donnée pour l'année en cours.\n Essayez {offsetQuery} si vous n’obtenez pas de données pour les plages temporelles récentes.", + "timelion.help.functions.worldbankIndicators.args.countryHelpText": "Identifiant de pays de la Banque mondiale. Généralement le code à 2 caractères du pays.", + "timelion.help.functions.worldbankIndicators.args.indicatorHelpText": "Le code d'indicateur à utiliser. Vous devrez le rechercher sur {worldbankUrl}. Souvent très complexe. Par exemple, {indicatorExample} correspond à la population.", + "timelion.help.functions.worldbankIndicatorsHelpText": "\n [expérimental]\n Extrayez des données de {worldbankUrl} à l'aide du nom et de l'indicateur du pays. La Banque mondiale fournit\n surtout des données annuelles et n'a souvent aucune donnée pour l'année en cours. Essayez {offsetQuery} si vous n’obtenez pas de données pour\n les plages temporelles récentes.", + "timelion.help.functions.yaxis.args.colorHelpText": "Couleur de l'étiquette de l'axe", + "timelion.help.functions.yaxis.args.labelHelpText": "Étiquette de l'axe", + "timelion.help.functions.yaxis.args.maxHelpText": "Valeur max.", + "timelion.help.functions.yaxis.args.minHelpText": "Valeur min.", + "timelion.help.functions.yaxis.args.positionHelpText": "gauche ou droite", + "timelion.help.functions.yaxis.args.tickDecimalsHelpText": "Le nombre de décimales pour les étiquettes de graduation de l'axe Y.", + "timelion.help.functions.yaxis.args.unitsHelpText": "La fonction à utiliser pour mettre en forme les étiquettes de l'axe Y. L'une des options suivantes : {formatters}.", + "timelion.help.functions.yaxis.args.yaxisHelpText": "L'axe Y numéroté sur lequel tracer cette série, par exemple .yaxis(2) pour un deuxième axe Y.", + "timelion.help.functions.yaxisHelpText": "Configure une variété d'options pour l'axe Y, la plus importante étant sans doute celle permettant d'ajouter un énième (par ex. deuxième) axe Y.", + "timelion.noFunctionErrorMessage": "Fonction inconnue : {name}", + "timelion.panels.timechart.unknownIntervalErrorMessage": "Intervalle inconnu", + "timelion.requestHandlerErrorTitle": "Erreur de requête Timelion", + "timelion.serverSideErrors.argumentsOverflowErrorMessage": "Trop d'arguments transmis à : {functionName}", + "timelion.serverSideErrors.bucketsOverflowErrorMessage": "Nombre max. de compartiments dépassé : {bucketCount} sur {maxBuckets} autorisés. Sélectionnez un intervalle plus grand ou une période plus courte.", + "timelion.serverSideErrors.colorFunction.colorNotProvidedErrorMessage": "couleur non spécifiée", + "timelion.serverSideErrors.conditionFunction.unknownOperatorErrorMessage": "Opérateur inconnu", + "timelion.serverSideErrors.conditionFunction.wrongArgTypeErrorMessage": "doit être un nombre ou une liste de séries", + "timelion.serverSideErrors.esFunction.indexNotFoundErrorMessage": "Index Elasticsearch introuvable : {index}", + "timelion.serverSideErrors.holtFunction.missingParamsErrorMessage": "Vous devez spécifier une longueur de saison et une taille d'échantillon >= 2.", + "timelion.serverSideErrors.holtFunction.notEnoughPointsErrorMessage": "Au moins 2 points sont nécessaires pour utiliser le lissage exponentiel double.", + "timelion.serverSideErrors.movingaverageFunction.notValidPositionErrorMessage": "Les positions valides sont : {validPositions}.", + "timelion.serverSideErrors.movingstdFunction.notValidPositionErrorMessage": "Les positions valides sont : {validPositions}.", + "timelion.serverSideErrors.pointsFunction.notValidSymbolErrorMessage": "Les symboles valides sont : {validSymbols}.", + "timelion.serverSideErrors.quandlFunction.unsupportedIntervalErrorMessage": "Intervalle non pris en charge par quandl() : {interval}. Les intervalles pris en charge par quandl() sont les suivants : {intervals}.", + "timelion.serverSideErrors.sheetParseErrorMessage": "Attendu : {expectedDescription} au caractère {column}", + "timelion.serverSideErrors.unknownArgumentErrorMessage": "Argument inconnu pour {functionName} : {argumentName}", + "timelion.serverSideErrors.unknownArgumentTypeErrorMessage": "Type d'argument non pris en charge : {argument}", + "timelion.serverSideErrors.worldbankFunction.noDataErrorMessage": "La requête à la Banque mondiale a réussi, mais il n'y a pas de données pour {code}.", + "timelion.serverSideErrors.wrongFunctionArgumentTypeErrorMessage": "{functionName}({argumentName}) doit être l'une des options suivantes : {requiredTypes}. Obtenu : {actualType}", + "timelion.serverSideErrors.yaxisFunction.notSupportedUnitTypeErrorMessage": "{units} n'est pas un type d'unité pris en charge.", + "timelion.serverSideErrors.yaxisFunction.notValidCurrencyFormatErrorMessage": "La devise doit être un code à trois caractères.", + "timelion.timelionDescription": "Affichez des données temporelles sur un graphe.", + "timelion.uiSettings.defaultIndexDescription": "Index Elasticsearch par défaut dans lequel rechercher avec {esParam}", + "timelion.uiSettings.defaultIndexLabel": "Index par défaut", + "timelion.uiSettings.experimentalLabel": "expérimental", + "timelion.uiSettings.graphiteURLDescription": "{experimentalLabel} L'URL de l'hôte Graphite", + "timelion.uiSettings.graphiteURLLabel": "URL Graphite", + "timelion.uiSettings.legacyChartsLibraryDeprication": "Ce paramètre est déclassé et ne sera plus pris en charge à partir de la version 8.0.", + "timelion.uiSettings.legacyChartsLibraryDescription": "Active la bibliothèque de graphiques héritée pour les graphiques Timelion dans Visualize.", + "timelion.uiSettings.legacyChartsLibraryLabel": "Bibliothèque de graphiques Timelion héritée", + "timelion.uiSettings.maximumBucketsDescription": "Le nombre maximal de compartiments qu'une source de données unique peut renvoyer", + "timelion.uiSettings.maximumBucketsLabel": "Nombre maximal de compartiments", + "timelion.uiSettings.minimumIntervalDescription": "Le plus petit intervalle qui sera calculé lors de l'utilisation de l'option \"auto\"", + "timelion.uiSettings.minimumIntervalLabel": "Intervalle minimum", + "timelion.uiSettings.quandlKeyDescription": "{experimentalLabel} Votre clé d'API de www.quandl.com", + "timelion.uiSettings.quandlKeyLabel": "Clé Quandl", + "timelion.uiSettings.targetBucketsDescription": "Le nombre de compartiments visé lors de l'utilisation d'intervalles automatiques", + "timelion.uiSettings.targetBucketsLabel": "Compartiments cibles", + "timelion.uiSettings.timeFieldDescription": "Champ par défaut contenant un horodatage lors de l'utilisation de {esParam}", + "timelion.uiSettings.timeFieldLabel": "Champ temporel", + "timelion.vis.expressionLabel": "Expression Timelion", + "timelion.vis.interval.auto": "Auto", + "timelion.vis.interval.day": "1 jour", + "timelion.vis.interval.hour": "1 heure", + "timelion.vis.interval.minute": "1 minute", + "timelion.vis.interval.month": "1 mois", + "timelion.vis.interval.second": "1 seconde", + "timelion.vis.interval.week": "1 semaine", + "timelion.vis.interval.year": "1 an", + "timelion.vis.intervalLabel": "Intervalle", + "timelion.vis.invalidIntervalErrorMessage": "Format d'intervalle non valide.", + "timelion.vis.selectIntervalHelpText": "Choisissez une option ou créez une valeur personnalisée. Exemples : 30s, 20m, 24h, 2d, 1w, 1M", + "timelion.vis.selectIntervalPlaceholder": "Choisir un intervalle", + "uiActions.actionPanel.more": "Plus", + "uiActions.actionPanel.title": "Options", + "uiActions.errors.incompatibleAction": "Action non compatible", + "uiActions.triggers.rowClickkDescription": "Un clic sur une ligne de tableau", + "uiActions.triggers.rowClickTitle": "Clic sur ligne de tableau", + "visDefaultEditor.advancedToggle.advancedLinkLabel": "Avancé", + "visDefaultEditor.agg.disableAggButtonTooltip": "Désactiver l'agrégation {aggTitle} de {schemaTitle}", + "visDefaultEditor.agg.enableAggButtonTooltip": "Activer l'agrégation {aggTitle} de {schemaTitle}", + "visDefaultEditor.agg.errorsAriaLabel": "L'agrégation {aggTitle} de {schemaTitle} présente des erreurs.", + "visDefaultEditor.agg.modifyPriorityButtonTooltip": "Modifier la priorité de l'agrégation {aggTitle} de {schemaTitle} en la faisant glisser", + "visDefaultEditor.agg.removeDimensionButtonTooltip": "Supprimer l'agrégation {aggTitle} de {schemaTitle}", + "visDefaultEditor.agg.toggleEditorButtonAriaLabel": "Activer/Désactiver l'éditeur {schema}", + "visDefaultEditor.aggAdd.addButtonLabel": "Ajouter", + "visDefaultEditor.aggAdd.addGroupButtonLabel": "Ajouter {groupNameLabel}", + "visDefaultEditor.aggAdd.addSubGroupButtonLabel": "Ajouter sous-{groupNameLabel}", + "visDefaultEditor.aggAdd.bucketLabel": "compartiment", + "visDefaultEditor.aggAdd.maxBuckets": "Nombre maximal de {groupNameLabel} atteint", + "visDefaultEditor.aggAdd.metricLabel": "indicateur", + "visDefaultEditor.aggParams.errors.aggWrongRunOrderErrorMessage": "Les agrégations \"{schema}\" doivent s'exécuter avant tous les autres compartiments.", + "visDefaultEditor.aggSelect.aggregationLabel": "Agrégation", + "visDefaultEditor.aggSelect.helpLinkLabel": "Aide {aggTitle}", + "visDefaultEditor.aggSelect.noCompatibleAggsDescription": "Le modèle d'indexation {indexPatternTitle} ne possède pas de champs regroupables.", + "visDefaultEditor.aggSelect.selectAggPlaceholder": "Choisir une agrégation", + "visDefaultEditor.aggSelect.subAggregationLabel": "Sous-agrégation", + "visDefaultEditor.buckets.mustHaveBucketErrorMessage": "Ajoutez un compartiment avec une agrégation Histogramme de date ou Histogramme.", + "visDefaultEditor.controls.aggNotValidLabel": "- agrégation non valide -", + "visDefaultEditor.controls.aggregateWith.noAggsErrorTooltip": "Le champ choisi n'a pas d'agrégations compatibles.", + "visDefaultEditor.controls.aggregateWithLabel": "Agréger avec", + "visDefaultEditor.controls.aggregateWithTooltip": "Choisissez une stratégie pour combiner plusieurs occurrences ou un champ à valeurs multiples en un seul indicateur.", + "visDefaultEditor.controls.changePrecisionLabel": "Modifier la précision lors d'un zoom sur la carte", + "visDefaultEditor.controls.columnsLabel": "Colonnes", + "visDefaultEditor.controls.customMetricLabel": "Indicateur personnalisé", + "visDefaultEditor.controls.dateRanges.acceptedDateFormatsLinkText": "Formats de date acceptables", + "visDefaultEditor.controls.dateRanges.addRangeButtonLabel": "Ajouter une plage", + "visDefaultEditor.controls.dateRanges.errorMessage": "Chaque plage doit avoir au moins une date valide.", + "visDefaultEditor.controls.dateRanges.fromColumnLabel": "De", + "visDefaultEditor.controls.dateRanges.removeRangeButtonAriaLabel": "Supprimer la plage allant de {from} à {to}", + "visDefaultEditor.controls.dateRanges.toColumnLabel": "Au", + "visDefaultEditor.controls.definiteMetricLabel": "Indicateur : {metric}", + "visDefaultEditor.controls.dotSizeRatioHelpText": "Remplacez le rapport du rayon du plus petit point par le plus grand point.", + "visDefaultEditor.controls.dotSizeRatioLabel": "Rapport de taille de point", + "visDefaultEditor.controls.dropPartialBucketsLabel": "Abandonner les compartiments partiels", + "visDefaultEditor.controls.dropPartialBucketsTooltip": "Retirez les compartiments qui s'étendent au-delà de la plage temporelle afin que l'histogramme ne commence pas et ne se termine pas par des compartiments incomplets.", + "visDefaultEditor.controls.extendedBounds.errorMessage": "Le minimum doit être inférieur ou égal au maximum.", + "visDefaultEditor.controls.extendedBounds.maxLabel": "Max.", + "visDefaultEditor.controls.extendedBounds.minLabel": "Min.", + "visDefaultEditor.controls.extendedBoundsLabel": "Étendre les limites", + "visDefaultEditor.controls.extendedBoundsTooltip": "Le minimum et le maximum ne filtrent pas de résultats, mais étendent plutôt les limites de l'ensemble de résultats.", + "visDefaultEditor.controls.field.fieldIsNotExists": "Le champ \"{fieldParameter}\" associé à cet objet n'existe plus dans le modèle d'indexation. Veuillez utiliser un autre champ.", + "visDefaultEditor.controls.field.fieldLabel": "Champ", + "visDefaultEditor.controls.field.invalidFieldForAggregation": "Le champ enregistré \"{fieldParameter}\" du modèle d'indexation \"{indexPatternTitle}\" n'est pas valide pour une utilisation avec cette agrégation. Veuillez sélectionner un nouveau champ.", + "visDefaultEditor.controls.field.noCompatibleFieldsDescription": "Le modèle d'indexation {indexPatternTitle} ne contient aucun des types de champs compatibles suivants : {fieldTypes}.", + "visDefaultEditor.controls.field.selectFieldPlaceholder": "Sélectionner un champ", + "visDefaultEditor.controls.filters.addFilterButtonLabel": "Ajouter un filtre", + "visDefaultEditor.controls.filters.definiteFilterLabel": "Étiquette du filtre {index}", + "visDefaultEditor.controls.filters.filterLabel": "Filtre {index}", + "visDefaultEditor.controls.filters.labelPlaceholder": "Étiquette", + "visDefaultEditor.controls.filters.removeFilterButtonAriaLabel": "Supprimer ce filtre", + "visDefaultEditor.controls.filters.toggleFilterButtonAriaLabel": "Activer/Désactiver l'étiquette du filtre", + "visDefaultEditor.controls.includeExclude.addUnitButtonLabel": "Ajouter une valeur", + "visDefaultEditor.controls.ipRanges.addRangeButtonLabel": "Ajouter une plage", + "visDefaultEditor.controls.ipRanges.cidrMaskAriaLabel": "Masque CIDR : {mask}", + "visDefaultEditor.controls.ipRanges.cidrMasksButtonLabel": "Masques CIDR", + "visDefaultEditor.controls.ipRanges.fromToButtonLabel": "De/à", + "visDefaultEditor.controls.ipRanges.ipRangeFromAriaLabel": "Début de la plage d’IP : {value}", + "visDefaultEditor.controls.ipRanges.ipRangeToAriaLabel": "Fin de la plage d’IP : {value}", + "visDefaultEditor.controls.ipRanges.removeCidrMaskButtonAriaLabel": "Supprimer la valeur du masque CIDR de {mask}", + "visDefaultEditor.controls.ipRanges.removeEmptyCidrMaskButtonAriaLabel": "Supprimer la valeur par défaut du masque CIDR", + "visDefaultEditor.controls.ipRanges.removeRangeAriaLabel": "Supprimer la plage allant de {from} à {to}", + "visDefaultEditor.controls.ipRangesAriaLabel": "Plages d’IP", + "visDefaultEditor.controls.jsonInputLabel": "Entrée JSON", + "visDefaultEditor.controls.jsonInputTooltip": "Toutes les propriétés au format JSON ajoutées ici seront fusionnées avec la définition d'agrégation Elasticsearch pour cette section. Par exemple, \"shard_size\" pour une agrégation de termes.", + "visDefaultEditor.controls.maxBars.autoPlaceholder": "Auto", + "visDefaultEditor.controls.maxBars.maxBarsHelpText": "Les intervalles seront sélectionnés automatiquement en fonction des données disponibles. Le nombre maximal de barres ne peut jamais être supérieur à la valeur {histogramMaxBars} des paramètres avancés.", + "visDefaultEditor.controls.maxBars.maxBarsLabel": "Barres max.", + "visDefaultEditor.controls.metricLabel": "Indicateur", + "visDefaultEditor.controls.metrics.bucketTitle": "Compartiment", + "visDefaultEditor.controls.metrics.metricTitle": "Indicateur", + "visDefaultEditor.controls.numberInterval.autoInteralIsUsed": "L'intervalle automatique est utilisé.", + "visDefaultEditor.controls.numberInterval.minimumIntervalLabel": "Intervalle minimum", + "visDefaultEditor.controls.numberInterval.minimumIntervalTooltip": "L'intervalle sera scalé automatiquement si la valeur fournie crée plus de compartiments que ce qui est spécifié par la valeur {histogramMaxBars} dans les paramètres avancés.", + "visDefaultEditor.controls.numberInterval.selectIntervalPlaceholder": "Saisir un intervalle", + "visDefaultEditor.controls.numberList.addUnitButtonLabel": "Ajouter {unitName}", + "visDefaultEditor.controls.numberList.duplicateValueErrorMessage": "Dupliquez la valeur.", + "visDefaultEditor.controls.numberList.enterValuePlaceholder": "Saisir une valeur", + "visDefaultEditor.controls.numberList.invalidAscOrderErrorMessage": "La valeur n'est pas dans l'ordre croissant.", + "visDefaultEditor.controls.numberList.invalidRangeErrorMessage": "La valeur doit être comprise dans la plage allant de {min} à {max}.", + "visDefaultEditor.controls.numberList.removeUnitButtonAriaLabel": "Supprimer la valeur de rang de {value}", + "visDefaultEditor.controls.onlyRequestDataAroundMapExtentLabel": "Demander uniquement des données sur l'étendue de la carte", + "visDefaultEditor.controls.onlyRequestDataAroundMapExtentTooltip": "Appliquer l'agrégation de filtres geo_bounding_box pour réduire la zone d’intérêt à la zone d'affichage de la carte avec collier", + "visDefaultEditor.controls.orderAgg.alphabeticalLabel": "Alphabétique", + "visDefaultEditor.controls.orderAgg.orderByLabel": "Classer par", + "visDefaultEditor.controls.orderLabel": "Ordre", + "visDefaultEditor.controls.otherBucket.groupValuesLabel": "Regrouper les autres valeurs dans un compartiment séparé", + "visDefaultEditor.controls.otherBucket.groupValuesTooltip": "Les valeurs qui ne sont pas dans le top N sont regroupées dans ce compartiment. Pour inclure les documents avec des valeurs manquantes, activez l'option \"Afficher les valeurs manquantes\".", + "visDefaultEditor.controls.otherBucket.showMissingValuesLabel": "Afficher les valeurs manquantes", + "visDefaultEditor.controls.otherBucket.showMissingValuesTooltip": "Ne fonctionne que pour les champs de type \"chaîne\". Lorsque cette option est activée, les documents avec des valeurs manquantes sont inclus dans la recherche. Si ce compartiment est dans le top N, il apparaît dans le graphique. S'il n'est pas dans le top N et que l’option \"Regrouper les autres valeurs dans un compartiment séparé\" est activée, Elasticsearch ajoute les valeurs manquantes à \"l'autre\" compartiment.", + "visDefaultEditor.controls.percentileRanks.percentUnitNameText": "pour cent", + "visDefaultEditor.controls.percentileRanks.valuesLabel": "Valeurs", + "visDefaultEditor.controls.percentileRanks.valueUnitNameText": "valeur", + "visDefaultEditor.controls.percentiles.percentsLabel": "Pour cent", + "visDefaultEditor.controls.placeMarkersOffGridLabel": "Placer les marqueurs hors grille (utiliser un centroïde géométrique)", + "visDefaultEditor.controls.precisionLabel": "Précision", + "visDefaultEditor.controls.ranges.addRangeButtonLabel": "Ajouter une plage", + "visDefaultEditor.controls.ranges.fromLabel": "De", + "visDefaultEditor.controls.ranges.greaterThanOrEqualPrepend": "≥", + "visDefaultEditor.controls.ranges.greaterThanOrEqualTooltip": "Supérieur ou égal à", + "visDefaultEditor.controls.ranges.lessThanPrepend": "<", + "visDefaultEditor.controls.ranges.lessThanTooltip": "Inférieur à", + "visDefaultEditor.controls.ranges.removeRangeButtonAriaLabel": "Supprimer la plage allant de {from} à {to}", + "visDefaultEditor.controls.ranges.toLabel": "Au", + "visDefaultEditor.controls.rowsLabel": "Lignes", + "visDefaultEditor.controls.scaleMetricsLabel": "Scaler les valeurs des indicateurs (déclassé)", + "visDefaultEditor.controls.scaleMetricsTooltip": "Si vous sélectionnez un intervalle minimal manuel et qu'un intervalle plus grand est utilisé, l'activation de cette option entraînera le scaling des indicateurs de décompte et de somme à l'intervalle manuel sélectionné.", + "visDefaultEditor.controls.showEmptyBucketsLabel": "Afficher les compartiments vides", + "visDefaultEditor.controls.showEmptyBucketsTooltip": "Afficher tous les compartiments, pas seulement ceux avec des résultats", + "visDefaultEditor.controls.sizeLabel": "Taille", + "visDefaultEditor.controls.sizeTooltip": "Demander les K premiers résultats. Plusieurs résultats seront combinés par le biais de \"agréger avec\".", + "visDefaultEditor.controls.sortOnLabel": "Trier en fonction de", + "visDefaultEditor.controls.splitByLegend": "Diviser le graphique par lignes ou colonnes.", + "visDefaultEditor.controls.timeInterval.createsTooLargeBucketsTooltip": "Cet intervalle crée des compartiments trop grands pour permettre l’affichage dans la plage temporelle sélectionnée, il a donc été réduit.", + "visDefaultEditor.controls.timeInterval.createsTooManyBucketsTooltip": "Cet intervalle crée trop de compartiments pour permettre l’affichage dans la plage temporelle sélectionnée, il a donc été augmenté.", + "visDefaultEditor.controls.timeInterval.invalidFormatErrorMessage": "Format d'intervalle non valide.", + "visDefaultEditor.controls.timeInterval.minimumIntervalLabel": "Intervalle minimum", + "visDefaultEditor.controls.timeInterval.scaledHelpText": "Actuellement scalé à {bucketDescription}", + "visDefaultEditor.controls.timeInterval.selectIntervalPlaceholder": "Choisir un intervalle", + "visDefaultEditor.controls.timeInterval.selectOptionHelpText": "Choisissez une option ou créez une valeur personnalisée. Exemples : 30s, 20m, 24h, 2d, 1w, 1M", + "visDefaultEditor.controls.useAutoInterval": "Utiliser l'intervalle automatique", + "visDefaultEditor.editorConfig.dateHistogram.customInterval.helpText": "Doit être un multiple de l'intervalle de configuration : {interval}.", + "visDefaultEditor.editorConfig.histogram.interval.helpText": "Doit être un multiple de l'intervalle de configuration : {interval}.", + "visDefaultEditor.metrics.wrongLastBucketTypeErrorMessage": "La dernière agrégation de compartiments doit être \"Histogramme de date\" ou \"Histogramme\" lorsque vous utilisez l'agrégation d'indicateurs \"{type}\".", + "visDefaultEditor.options.colorRanges.errorText": "Chaque plage doit être supérieure à la précédente.", + "visDefaultEditor.options.colorSchema.colorSchemaLabel": "Schéma de couleurs", + "visDefaultEditor.options.colorSchema.howToChangeColorsDescription": "Les couleurs individuelles peuvent être modifiées dans la légende.", + "visDefaultEditor.options.colorSchema.resetColorsButtonLabel": "Réinitialiser les couleurs", + "visDefaultEditor.options.colorSchema.reverseColorSchemaLabel": "Inverser le schéma", + "visDefaultEditor.options.percentageMode.documentationLabel": "Documentation Numeral.js", + "visDefaultEditor.options.percentageMode.numeralLabel": "Modèle de format", + "visDefaultEditor.options.percentageMode.percentageModeLabel": "Mode de pourcentage", + "visDefaultEditor.options.rangeErrorMessage": "Les valeurs doivent être comprises entre {min} et {max}, inclus.", + "visDefaultEditor.options.vislibBasicOptions.legendPositionLabel": "Position de la légende", + "visDefaultEditor.options.vislibBasicOptions.showTooltipLabel": "Afficher l'infobulle", + "visDefaultEditor.palettePicker.label": "Palette de couleurs", + "visDefaultEditor.sidebar.autoApplyChangesLabelOff": "Application automatique désactivée", + "visDefaultEditor.sidebar.autoApplyChangesLabelOn": "Application automatique activée", + "visDefaultEditor.sidebar.autoApplyChangesOff": "Off", + "visDefaultEditor.sidebar.autoApplyChangesOffLabel": "Application automatique désactivée", + "visDefaultEditor.sidebar.autoApplyChangesOn": "On", + "visDefaultEditor.sidebar.autoApplyChangesOnLabel": "Application automatique activée", + "visDefaultEditor.sidebar.autoApplyChangesTooltip": "Met automatiquement à jour la visualisation à chaque modification.", + "visDefaultEditor.sidebar.collapseButtonAriaLabel": "Activer/Désactiver la barre latérale", + "visDefaultEditor.sidebar.discardChangesButtonLabel": "Abandonner", + "visDefaultEditor.sidebar.errorButtonTooltip": "Les erreurs dans les champs mis en évidence doivent être corrigées.", + "visDefaultEditor.sidebar.indexPatternAriaLabel": "Modèle d'indexation : {title}", + "visDefaultEditor.sidebar.savedSearch.goToDiscoverButtonText": "Afficher cette recherche dans Discover", + "visDefaultEditor.sidebar.savedSearch.linkButtonAriaLabel": "Lier à la recherche enregistrée. Cliquez pour en savoir plus ou rompre le lien.", + "visDefaultEditor.sidebar.savedSearch.popoverHelpText": "Les modifications apportées ultérieurement à cette recherche enregistrée sont reflétées dans la visualisation. Pour désactiver les mises à jour automatiques, supprimez le lien.", + "visDefaultEditor.sidebar.savedSearch.popoverTitle": "Lié à la recherche enregistrée", + "visDefaultEditor.sidebar.savedSearch.titleAriaLabel": "Recherche enregistrée : {title}", + "visDefaultEditor.sidebar.savedSearch.unlinkSavedSearchButtonText": "Supprimer le lien avec la recherche enregistrée", + "visDefaultEditor.sidebar.tabs.dataLabel": "Données", + "visDefaultEditor.sidebar.tabs.optionsLabel": "Options", + "visDefaultEditor.sidebar.updateChartButtonLabel": "Mettre à jour", + "visDefaultEditor.sidebar.updateInfoTooltip": "CTRL + Entrée est le raccourci clavier pour Mettre à jour.", + "visTypeMarkdown.function.font.help": "Paramètres de police.", + "visTypeMarkdown.function.help": "Visualisation Markdown", + "visTypeMarkdown.function.markdown.help": "Markdown à rendre", + "visTypeMarkdown.function.openLinksInNewTab.help": "Ouvre les liens dans un nouvel onglet", + "visTypeMarkdown.markdownDescription": "Ajoutez du texte et des images à votre tableau de bord.", + "visTypeMarkdown.markdownTitleInWizard": "Texte", + "visTypeMarkdown.params.fontSizeLabel": "Taille de police de base en points", + "visTypeMarkdown.params.helpLinkLabel": "Aide", + "visTypeMarkdown.params.openLinksLabel": "Ouvrir les liens dans un nouvel onglet", + "visTypeMarkdown.tabs.dataText": "Données", + "visTypeMarkdown.tabs.optionsText": "Options", + "visTypeMetric.colorModes.backgroundOptionLabel": "Arrière-plan", + "visTypeMetric.colorModes.labelsOptionLabel": "Étiquettes", + "visTypeMetric.colorModes.noneOptionLabel": "Aucun", + "visTypeMetric.metricDescription": "Affiche un calcul sous la forme d'un nombre unique.", + "visTypeMetric.metricTitle": "Indicateur", + "visTypeMetric.params.color.useForLabel": "Utiliser la couleur pour", + "visTypeMetric.params.rangesTitle": "Plages", + "visTypeMetric.params.settingsTitle": "Paramètres", + "visTypeMetric.params.showTitleLabel": "Afficher le titre", + "visTypeMetric.params.style.fontSizeLabel": "Taille de police de l'indicateur en points", + "visTypeMetric.params.style.styleTitle": "Style", + "visTypeMetric.schemas.metricTitle": "Indicateur", + "visTypeMetric.schemas.splitGroupTitle": "Diviser le groupe", + "visTypePie.advancedSettings.visualization.legacyPieChartsLibrary.deprecation": "La bibliothèque de graphiques héritée pour les camemberts dans Visualize est déclassée et ne sera plus prise en charge à partir de la version 8.0.", + "visTypePie.advancedSettings.visualization.legacyPieChartsLibrary.description": "Active la bibliothèque de graphiques héritée pour les camemberts dans Visualize.", + "visTypePie.advancedSettings.visualization.legacyPieChartsLibrary.name": "Bibliothèque de graphiques héritée pour les camemberts", + "visTypePie.controls.truncateLabel": "Tronquer", + "visTypePie.editors.pie.decimalSliderLabel": "Nombre maximal de décimales pour les pourcentages", + "visTypePie.editors.pie.distinctColorsLabel": "Utiliser des couleurs distinctes pour chaque section", + "visTypePie.editors.pie.donutLabel": "Graphique en anneau", + "visTypePie.editors.pie.labelPositionLabel": "Position de l'étiquette", + "visTypePie.editors.pie.labelsSettingsTitle": "Paramètres des étiquettes", + "visTypePie.editors.pie.nestedLegendLabel": "Imbriquer la légende", + "visTypePie.editors.pie.pieSettingsTitle": "Paramètres du camembert", + "visTypePie.editors.pie.showLabelsLabel": "Afficher les étiquettes", + "visTypePie.editors.pie.showTopLevelOnlyLabel": "Afficher uniquement le niveau supérieur", + "visTypePie.editors.pie.showValuesLabel": "Afficher les valeurs", + "visTypePie.editors.pie.valueFormatsLabel": "Valeurs", + "visTypePie.labelPositions.insideOrOutsideText": "Intérieur ou extérieur", + "visTypePie.labelPositions.insideText": "Intérieur", + "visTypePie.legendPositions.bottomText": "Bas", + "visTypePie.legendPositions.leftText": "Gauche", + "visTypePie.legendPositions.rightText": "Droite", + "visTypePie.legendPositions.topText": "Haut", + "visTypePie.pie.metricTitle": "Taille de section", + "visTypePie.pie.pieDescription": "Comparez des données proportionnellement à un ensemble.", + "visTypePie.pie.pieTitle": "Camembert", + "visTypePie.pie.segmentTitle": "Diviser les sections", + "visTypePie.pie.splitTitle": "Diviser le graphique", + "visTypePie.valuesFormats.percent": "Afficher le pourcentage", + "visTypePie.valuesFormats.value": "Afficher la valeur", + "visTypeTable.defaultAriaLabel": "Visualisation du tableau de données", + "visTypeTable.function.adimension.buckets": "Compartiments", + "visTypeTable.function.args.bucketsHelpText": "Configuration des dimensions de compartiment", + "visTypeTable.function.args.metricsHelpText": "Configuration des dimensions d’indicateur", + "visTypeTable.function.args.percentageColHelpText": "Nom de la colonne pour laquelle afficher le pourcentage", + "visTypeTable.function.args.perPageHelpText": "Le nombre de lignes dans une page de tableau est utilisé pour la pagination.", + "visTypeTable.function.args.rowHelpText": "La valeur de ligne est utilisée pour le mode de division de tableau. Définir sur \"vrai\" pour diviser par ligne", + "visTypeTable.function.args.showToolbarHelpText": "Définir sur \"vrai\" pour afficher la barre d'outils de la grille avec le bouton \"Exporter\"", + "visTypeTable.function.args.showTotalHelpText": "Définir sur \"vrai\" pour afficher le nombre total de lignes", + "visTypeTable.function.args.splitColumnHelpText": "Diviser par la configuration des dimensions de colonne", + "visTypeTable.function.args.splitRowHelpText": "Diviser par la configuration des dimensions de ligne", + "visTypeTable.function.args.titleHelpText": "Titre de la visualisation. Le titre est utilisé comme nom de fichier par défaut pour l'exportation CSV.", + "visTypeTable.function.args.totalFuncHelpText": "Spécifie la fonction de calcul du nombre total de lignes. Les options possibles sont : ", + "visTypeTable.function.dimension.metrics": "Indicateurs", + "visTypeTable.function.dimension.splitColumn": "Diviser par colonne", + "visTypeTable.function.dimension.splitRow": "Diviser par ligne", + "visTypeTable.function.help": "Visualisation du tableau", + "visTypeTable.params.defaultPercentageCol": "Ne pas afficher", + "visTypeTable.params.PercentageColLabel": "Colonne de pourcentage", + "visTypeTable.params.percentageTableColumnName": "Pourcentages de {title}", + "visTypeTable.params.perPageLabel": "Nombre max. de lignes par page", + "visTypeTable.params.showMetricsLabel": "Afficher les indicateurs pour chaque compartiment/niveau", + "visTypeTable.params.showPartialRowsLabel": "Afficher les lignes partielles", + "visTypeTable.params.showPartialRowsTip": "Affichez les lignes contenant des données partielles. Les indicateurs de chaque compartiment/niveau seront toujours calculés, même s'ils ne sont pas affichés.", + "visTypeTable.params.showToolbarLabel": "Afficher la barre d'outils", + "visTypeTable.params.showTotalLabel": "Afficher le total", + "visTypeTable.params.totalFunctionLabel": "Fonction de total", + "visTypeTable.sort.ascLabel": "Tri croissant", + "visTypeTable.sort.descLabel": "Tri décroissant", + "visTypeTable.tableCellFilter.filterForValueAriaLabel": "Filtrer sur la valeur : {cellContent}", + "visTypeTable.tableCellFilter.filterForValueText": "Filtrer sur la valeur", + "visTypeTable.tableCellFilter.filterOutValueAriaLabel": "Exclure la valeur : {cellContent}", + "visTypeTable.tableCellFilter.filterOutValueText": "Exclure la valeur", + "visTypeTable.tableVisDescription": "Affichez des données en lignes et en colonnes.", + "visTypeTable.tableVisEditorConfig.schemas.bucketTitle": "Diviser les lignes", + "visTypeTable.tableVisEditorConfig.schemas.metricTitle": "Indicateur", + "visTypeTable.tableVisEditorConfig.schemas.splitTitle": "Diviser le tableau", + "visTypeTable.tableVisTitle": "Tableau de données", + "visTypeTable.totalAggregations.averageText": "Moyenne", + "visTypeTable.totalAggregations.countText": "Décompte", + "visTypeTable.totalAggregations.maxText": "Max.", + "visTypeTable.totalAggregations.minText": "Min.", + "visTypeTable.totalAggregations.sumText": "Somme", + "visTypeTable.vis.controls.exportButtonAriaLabel": "Exporter {dataGridAriaLabel} au format CSV", + "visTypeTable.vis.controls.exportButtonFormulasWarning": "Votre fichier CSV contient des caractères que les applications de feuilles de calcul pourraient considérer comme des formules.", + "visTypeTable.vis.controls.exportButtonLabel": "Exporter", + "visTypeTable.vis.controls.formattedCSVButtonLabel": "Formaté", + "visTypeTable.vis.controls.rawCSVButtonLabel": "Brut", + "visTypeTagCloud.orientations.multipleText": "Multiple", + "visTypeTagCloud.orientations.rightAngledText": "Angle droit", + "visTypeTagCloud.orientations.singleText": "Unique", + "visTypeTagCloud.scales.linearText": "Linéaire", + "visTypeTagCloud.scales.logText": "Log", + "visTypeTagCloud.scales.squareRootText": "Racine carrée", + "visTypeTagCloud.vis.schemas.metricTitle": "Taille de balise", + "visTypeTagCloud.vis.schemas.segmentTitle": "Balises", + "visTypeTagCloud.vis.tagCloudDescription": "Affichez la fréquence des mots avec la taille de police.", + "visTypeTagCloud.vis.tagCloudTitle": "Nuage de balises", + "visTypeTagCloud.visParams.fontSizeLabel": "Plage de taille de police en pixels", + "visTypeTagCloud.visParams.orientationsLabel": "Orientations", + "visTypeTagCloud.visParams.showLabelToggleLabel": "Afficher l'étiquette", + "visTypeTagCloud.visParams.textScaleLabel": "Échelle de texte", + "visTypeTimeseries.addDeleteButtons.addButtonDefaultTooltip": "Ajouter", + "visTypeTimeseries.addDeleteButtons.cloneButtonDefaultTooltip": "Cloner", + "visTypeTimeseries.addDeleteButtons.deleteButtonDefaultTooltip": "Supprimer", + "visTypeTimeseries.addDeleteButtons.reEnableTooltip": "Réactiver", + "visTypeTimeseries.addDeleteButtons.temporarilyDisableTooltip": "Désactiver temporairement", + "visTypeTimeseries.advancedSettings.maxBucketsText": "A un impact sur la densité de l'histogramme TSVB. Doit être défini sur une valeur supérieure à \"histogram:maxBars\".", + "visTypeTimeseries.advancedSettings.maxBucketsTitle": "Limite de compartiments TSVB", + "visTypeTimeseries.aggRow.addMetricButtonTooltip": "Ajouter un indicateur", + "visTypeTimeseries.aggRow.deleteMetricButtonTooltip": "Supprimer un indicateur", + "visTypeTimeseries.aggSelect.aggGroups.metricAggLabel": "Agrégations d'indicateurs", + "visTypeTimeseries.aggSelect.aggGroups.parentPipelineAggLabel": "Agrégations de pipelines parents", + "visTypeTimeseries.aggSelect.aggGroups.siblingPipelineAggLabel": "Agrégations de pipelines enfants", + "visTypeTimeseries.aggSelect.aggGroups.specialAggLabel": "Agrégations spéciales", + "visTypeTimeseries.aggSelect.selectAggPlaceholder": "Sélectionner une agrégation", + "visTypeTimeseries.annotationsEditor.addDataSourceButtonLabel": "Ajouter une source de données", + "visTypeTimeseries.annotationsEditor.dataSourcesLabel": "Sources de données", + "visTypeTimeseries.annotationsEditor.fieldsLabel": "Champs (requis – chemins séparés par des virgules)", + "visTypeTimeseries.annotationsEditor.howToCreateAnnotationDataSourceDescription": "Cliquez sur le bouton ci-dessous pour créer une source de données d'annotation.", + "visTypeTimeseries.annotationsEditor.iconLabel": "Icône (requis)", + "visTypeTimeseries.annotationsEditor.ignoreGlobalFiltersLabel": "Ignorer les filtres globaux ?", + "visTypeTimeseries.annotationsEditor.ignorePanelFiltersLabel": "Ignorer les filtres de panneau ?", + "visTypeTimeseries.annotationsEditor.queryStringLabel": "Chaîne de requête", + "visTypeTimeseries.annotationsEditor.rowTemplateHelpText": "eg.{rowTemplateExample}", + "visTypeTimeseries.annotationsEditor.rowTemplateLabel": "Modèle de ligne (requis)", + "visTypeTimeseries.annotationsEditor.timeFieldLabel": "Champ temporel (requis)", + "visTypeTimeseries.axisLabelOptions.axisLabel": "par {unitValue} {unitString}", + "visTypeTimeseries.calculateLabel.bucketScriptsLabel": "Script de compartiment", + "visTypeTimeseries.calculateLabel.countLabel": "Décompte", + "visTypeTimeseries.calculateLabel.filterRatioLabel": "Rapport de filtre", + "visTypeTimeseries.calculateLabel.mathLabel": "Mathématique", + "visTypeTimeseries.calculateLabel.positiveRateLabel": "Taux de compteur de {field}", + "visTypeTimeseries.calculateLabel.seriesAggLabel": "Agrégation de séries ({metricFunction})", + "visTypeTimeseries.calculateLabel.staticValueLabel": "Valeur statique de {metricValue}", + "visTypeTimeseries.calculateLabel.unknownLabel": "Inconnu", + "visTypeTimeseries.calculation.aggregationLabel": "Agrégation", + "visTypeTimeseries.calculation.painlessScriptDescription": "Les variables sont des clés sur l'objet {params}, c.-à-d. {paramsName}. Pour accéder à l'intervalle de compartiment (en millisecondes), utilisez {paramsInterval}.", + "visTypeTimeseries.calculation.painlessScriptLabel": "Script Painless", + "visTypeTimeseries.calculation.variablesLabel": "Variables", + "visTypeTimeseries.colorPicker.clearIconLabel": "Effacer", + "visTypeTimeseries.colorPicker.notAccessibleAriaLabel": "Sélecteur de couleur, non accessible", + "visTypeTimeseries.colorPicker.notAccessibleWithValueAriaLabel": "Sélecteur de couleur ({value}), non accessible", + "visTypeTimeseries.colorRules.adjustChartSizeAriaLabel": "Utilisez les flèches haut/bas pour ajuster la taille du graphique.", + "visTypeTimeseries.colorRules.defaultPrimaryNameLabel": "arrière-plan", + "visTypeTimeseries.colorRules.defaultSecondaryNameLabel": "texte", + "visTypeTimeseries.colorRules.emptyLabel": "vide", + "visTypeTimeseries.colorRules.greaterThanLabel": "> supérieur à", + "visTypeTimeseries.colorRules.greaterThanOrEqualLabel": ">= supérieur ou égal à", + "visTypeTimeseries.colorRules.ifMetricIsLabel": "si l'indicateur est", + "visTypeTimeseries.colorRules.lessThanLabel": "< inférieur à", + "visTypeTimeseries.colorRules.lessThanOrEqualLabel": "<= inférieur ou égal à", + "visTypeTimeseries.colorRules.setPrimaryColorLabel": "Définissez {primaryName} sur", + "visTypeTimeseries.colorRules.setSecondaryColorLabel": "et {secondaryName} sur", + "visTypeTimeseries.colorRules.valueAriaLabel": "Valeur", + "visTypeTimeseries.cumulativeSum.aggregationLabel": "Agrégation", + "visTypeTimeseries.cumulativeSum.metricLabel": "Indicateur", + "visTypeTimeseries.dataFormatPicker.bytesLabel": "Octets", + "visTypeTimeseries.dataFormatPicker.customLabel": "Personnalisé", + "visTypeTimeseries.dataFormatPicker.decimalPlacesLabel": "Décimales", + "visTypeTimeseries.dataFormatPicker.durationLabel": "Durée", + "visTypeTimeseries.dataFormatPicker.fromLabel": "De", + "visTypeTimeseries.dataFormatPicker.numberLabel": "Nombre", + "visTypeTimeseries.dataFormatPicker.percentLabel": "Pour cent", + "visTypeTimeseries.dataFormatPicker.toLabel": "À", + "visTypeTimeseries.defaultDataFormatterLabel": "Formateur de données", + "visTypeTimeseries.derivative.aggregationLabel": "Agrégation", + "visTypeTimeseries.derivative.metricLabel": "Indicateur", + "visTypeTimeseries.derivative.unitsLabel": "Unités (1s, 1m, etc.)", + "visTypeTimeseries.durationOptions.daysLabel": "Jours", + "visTypeTimeseries.durationOptions.hoursLabel": "Heures", + "visTypeTimeseries.durationOptions.humanize": "Lisible par l'utilisateur", + "visTypeTimeseries.durationOptions.microsecondsLabel": "Microsecondes", + "visTypeTimeseries.durationOptions.millisecondsLabel": "Millisecondes", + "visTypeTimeseries.durationOptions.minutesLabel": "Minutes", + "visTypeTimeseries.durationOptions.monthsLabel": "Mois", + "visTypeTimeseries.durationOptions.nanosecondsLabel": "Nanosecondes", + "visTypeTimeseries.durationOptions.picosecondsLabel": "Picosecondes", + "visTypeTimeseries.durationOptions.secondsLabel": "Secondes", + "visTypeTimeseries.durationOptions.weeksLabel": "Semaines", + "visTypeTimeseries.durationOptions.yearsLabel": "Années", + "visTypeTimeseries.emptyTextValue": "(vide)", + "visTypeTimeseries.error.requestForPanelFailedErrorMessage": "La requête pour ce panneau a échoué.", + "visTypeTimeseries.fetchFields.loadIndexPatternFieldsErrorMessage": "Impossible de charger les champs index_pattern", + "visTypeTimeseries.fieldSelect.fieldIsNotValid": "Le champ \"{fieldParameter}\" n'est pas valide pour une utilisation avec l'index actuel. Veuillez sélectionner un nouveau champ.", + "visTypeTimeseries.fieldSelect.selectFieldPlaceholder": "Sélectionner un champ…", + "visTypeTimeseries.filterRatio.aggregationLabel": "Agrégation", + "visTypeTimeseries.filterRatio.denominatorLabel": "Dénominateur", + "visTypeTimeseries.filterRatio.fieldLabel": "Champ", + "visTypeTimeseries.filterRatio.metricAggregationLabel": "Agrégation d'indicateurs", + "visTypeTimeseries.filterRatio.numeratorLabel": "Numérateur", + "visTypeTimeseries.function.help": "Visualisation TSVB", + "visTypeTimeseries.gauge.dataTab.dataButtonLabel": "Données", + "visTypeTimeseries.gauge.dataTab.metricsButtonLabel": "Indicateurs", + "visTypeTimeseries.gauge.editor.addSeriesTooltip": "Ajouter une série", + "visTypeTimeseries.gauge.editor.cloneSeriesTooltip": "Cloner la série", + "visTypeTimeseries.gauge.editor.deleteSeriesTooltip": "Supprimer la série", + "visTypeTimeseries.gauge.editor.labelPlaceholder": "Étiquette", + "visTypeTimeseries.gauge.editor.toggleEditorAriaLabel": "Activer/Désactiver l'éditeur de séries", + "visTypeTimeseries.gauge.optionsTab.backgroundColorLabel": "Couleur d'arrière-plan :", + "visTypeTimeseries.gauge.optionsTab.colorRulesLabel": "Règles de couleur", + "visTypeTimeseries.gauge.optionsTab.dataLabel": "Données", + "visTypeTimeseries.gauge.optionsTab.gaugeLineWidthLabel": "Largeur de la ligne de jauge", + "visTypeTimeseries.gauge.optionsTab.gaugeMaxLabel": "Jauge max. (vide pour auto)", + "visTypeTimeseries.gauge.optionsTab.gaugeStyleLabel": "Style de jauge", + "visTypeTimeseries.gauge.optionsTab.ignoreGlobalFilterLabel": "Ignorer le filtre global ?", + "visTypeTimeseries.gauge.optionsTab.innerColorLabel": "Couleur intérieure :", + "visTypeTimeseries.gauge.optionsTab.innerLineWidthLabel": "Largeur de la ligne intérieure", + "visTypeTimeseries.gauge.optionsTab.optionsButtonLabel": "Options", + "visTypeTimeseries.gauge.optionsTab.panelFilterLabel": "Filtre de panneau", + "visTypeTimeseries.gauge.optionsTab.panelOptionsButtonLabel": "Options du panneau", + "visTypeTimeseries.gauge.optionsTab.styleLabel": "Style", + "visTypeTimeseries.gauge.styleOptions.circleLabel": "Cercle", + "visTypeTimeseries.gauge.styleOptions.halfCircleLabel": "Demi-cercle", + "visTypeTimeseries.getInterval.daysLabel": "jours", + "visTypeTimeseries.getInterval.hoursLabel": "heures", + "visTypeTimeseries.getInterval.minutesLabel": "minutes", + "visTypeTimeseries.getInterval.monthsLabel": "mois", + "visTypeTimeseries.getInterval.secondsLabel": "secondes", + "visTypeTimeseries.getInterval.weeksLabel": "semaines", + "visTypeTimeseries.getInterval.yearsLabel": "années", + "visTypeTimeseries.handleErrorResponse.unexpectedError": "Erreur inattendue", + "visTypeTimeseries.iconSelect.asteriskLabel": "Astérisque", + "visTypeTimeseries.iconSelect.bellLabel": "Cloche", + "visTypeTimeseries.iconSelect.boltLabel": "Éclair", + "visTypeTimeseries.iconSelect.bombLabel": "Bombe", + "visTypeTimeseries.iconSelect.bugLabel": "Bug", + "visTypeTimeseries.iconSelect.commentLabel": "Commentaire", + "visTypeTimeseries.iconSelect.exclamationCircleLabel": "Cercle exclamation", + "visTypeTimeseries.iconSelect.exclamationTriangleLabel": "Triangle exclamation", + "visTypeTimeseries.iconSelect.fireLabel": "Feu", + "visTypeTimeseries.iconSelect.flagLabel": "Drapeau", + "visTypeTimeseries.iconSelect.heartLabel": "Cœur", + "visTypeTimeseries.iconSelect.mapMarkerLabel": "Repère", + "visTypeTimeseries.iconSelect.mapPinLabel": "Punaise", + "visTypeTimeseries.iconSelect.starLabel": "Étoile", + "visTypeTimeseries.iconSelect.tagLabel": "Balise", + "visTypeTimeseries.indexPattern.detailLevel": "Niveau de détail", + "visTypeTimeseries.indexPattern.detailLevelAriaLabel": "Niveau de détail", + "visTypeTimeseries.indexPattern.detailLevelHelpText": "Contrôle les intervalles auto et gte en fonction de la plage temporelle. Les paramètres avancés {histogramTargetBars} et {histogramMaxBars} ont un impact sur l'intervalle par défaut.", + "visTypeTimeseries.indexPattern.dropLastBucketLabel": "Abandonner le dernier compartiment ?", + "visTypeTimeseries.indexPattern.finest": "Plus fin", + "visTypeTimeseries.indexPattern.intervalHelpText": "Exemples : auto, 1m, 1d, 7d, 1y, >=1m", + "visTypeTimeseries.indexPattern.intervalLabel": "Intervalle", + "visTypeTimeseries.indexPattern.timeFieldLabel": "Champ temporel", + "visTypeTimeseries.indexPattern.timeRange.entireTimeRange": "Toute la plage temporelle", + "visTypeTimeseries.indexPattern.timeRange.error": "Vous ne pouvez pas utiliser \"{mode}\" avec le type d'index actuel.", + "visTypeTimeseries.indexPattern.timeRange.hint": "Ce paramètre contrôle la période utilisée pour la mise en correspondance des documents. L'option \"Toute la plage temporelle\" mettra en correspondance tous les documents sélectionnés dans le sélecteur d'heure. L'option \"Dernière valeur\" ne mettra en correspondance que les documents pour l'intervalle spécifié à partir de la fin de la plage temporelle.", + "visTypeTimeseries.indexPattern.timeRange.label": "Mode de plage temporelle des données", + "visTypeTimeseries.indexPattern.timeRange.lastValue": "Dernière valeur", + "visTypeTimeseries.indexPattern.timeRange.selectTimeRange": "Sélectionner", + "visTypeTimeseries.indexPattern.сoarse": "Grossier", + "visTypeTimeseries.indexPatternSelect.label": "Modèle d'indexation", + "visTypeTimeseries.indexPatternSelect.switchModePopover.areaLabel": "Configurer le mode de sélection du modèle d'indexation", + "visTypeTimeseries.indexPatternSelect.switchModePopover.title": "Mode de sélection du modèle d'indexation", + "visTypeTimeseries.indexPatternSelect.switchModePopover.useKibanaIndices": "Utiliser uniquement les modèles d'indexation Kibana", + "visTypeTimeseries.kbnVisTypes.metricsDescription": "Réalisez des analyses avancées de vos données temporelles.", + "visTypeTimeseries.kbnVisTypes.metricsTitle": "TSVB", + "visTypeTimeseries.lastValueModeIndicator.lastBucketDate": "Compartiment : {lastBucketDate}", + "visTypeTimeseries.lastValueModeIndicator.lastValue": "Dernière valeur", + "visTypeTimeseries.lastValueModeIndicator.lastValueModeBadgeAriaLabel": "Afficher les détails de la dernière valeur", + "visTypeTimeseries.lastValueModeIndicator.panelInterval": "Intervalle : {formattedPanelInterval}", + "visTypeTimeseries.lastValueModePopover.gearButton": "Modifier l'option d'affichage de l'indicateur Dernière valeur", + "visTypeTimeseries.lastValueModePopover.switch": "Afficher l'étiquette lors de l'utilisation du mode Dernière valeur", + "visTypeTimeseries.lastValueModePopover.title": "Options de Dernière valeur", + "visTypeTimeseries.markdown.alignOptions.bottomLabel": "Bas", + "visTypeTimeseries.markdown.alignOptions.middleLabel": "Milieu", + "visTypeTimeseries.markdown.alignOptions.topLabel": "Haut", + "visTypeTimeseries.markdown.dataTab.dataButtonLabel": "Données", + "visTypeTimeseries.markdown.dataTab.metricsButtonLabel": "Indicateurs", + "visTypeTimeseries.markdown.editor.addSeriesTooltip": "Ajouter une série", + "visTypeTimeseries.markdown.editor.cloneSeriesTooltip": "Cloner la série", + "visTypeTimeseries.markdown.editor.deleteSeriesTooltip": "Supprimer la série", + "visTypeTimeseries.markdown.editor.labelPlaceholder": "Étiquette", + "visTypeTimeseries.markdown.editor.toggleEditorAriaLabel": "Activer/Désactiver l'éditeur de séries", + "visTypeTimeseries.markdown.editor.variableNamePlaceholder": "Nom de la variable", + "visTypeTimeseries.markdown.optionsTab.backgroundColorLabel": "Couleur d'arrière-plan :", + "visTypeTimeseries.markdown.optionsTab.customCSSLabel": "CSS personnalisé (prend en charge Less)", + "visTypeTimeseries.markdown.optionsTab.dataLabel": "Données", + "visTypeTimeseries.markdown.optionsTab.ignoreGlobalFilterLabel": "Ignorer le filtre global ?", + "visTypeTimeseries.markdown.optionsTab.openLinksInNewTab": "Ouvrir les liens dans un nouvel onglet ?", + "visTypeTimeseries.markdown.optionsTab.optionsButtonLabel": "Options", + "visTypeTimeseries.markdown.optionsTab.panelFilterLabel": "Filtre de panneau", + "visTypeTimeseries.markdown.optionsTab.panelOptionsButtonLabel": "Options du panneau", + "visTypeTimeseries.markdown.optionsTab.showScrollbarsLabel": "Afficher les barres de défilement ?", + "visTypeTimeseries.markdown.optionsTab.styleLabel": "Style", + "visTypeTimeseries.markdown.optionsTab.verticalAlignmentLabel": "Alignement vertical :", + "visTypeTimeseries.markdownEditor.howToAccessEntireTreeDescription": "Il existe également une variable spéciale nommée {all} que vous pouvez utiliser pour accéder à l'ensemble de l'arborescence. C'est utile pour créer des listes avec des données à l'aide d'une action Regrouper par :", + "visTypeTimeseries.markdownEditor.howToUseVariablesInMarkdownDescription": "Les variables suivantes peuvent être utilisées dans Markdown à l'aide de la syntaxe Handlebar (moustache). {handlebarLink} sur les expressions disponibles.", + "visTypeTimeseries.markdownEditor.howUseVariablesInMarkdownDescription.documentationLinkText": "Cliquer ici pour la documentation", + "visTypeTimeseries.markdownEditor.nameLabel": "Nom", + "visTypeTimeseries.markdownEditor.noVariablesAvailableDescription": "Aucune variable disponible pour les indicateurs de données sélectionnés.", + "visTypeTimeseries.markdownEditor.valueLabel": "Valeur", + "visTypeTimeseries.math.aggregationLabel": "Agrégation", + "visTypeTimeseries.math.expressionDescription": "Ce champ utilise des expressions mathématiques de base (voir {link}). Les variables sont des clés sur l'objet {params}, c.-à-d. {paramsName}. Pour accéder à toutes les données, utilisez {paramsValues} pour un tableau de valeurs et {paramsTimestamps} pour un tableau d’horodatages. {paramsTimestamp} est disponible pour l'horodatage du compartiment actuel, {paramsIndex} est disponible pour l'index du compartiment actuel et {paramsInterval} est disponible pour l'intervalle en millisecondes.", + "visTypeTimeseries.math.expressionDescription.tinyMathLinkText": "TinyMath", + "visTypeTimeseries.math.expressionLabel": "Expression", + "visTypeTimeseries.math.variablesLabel": "Variables", + "visTypeTimeseries.metric.dataTab.dataButtonLabel": "Données", + "visTypeTimeseries.metric.dataTab.metricsButtonLabel": "Indicateurs", + "visTypeTimeseries.metric.editor.addSeriesTooltip": "Ajouter une série", + "visTypeTimeseries.metric.editor.cloneSeriesTooltip": "Cloner la série", + "visTypeTimeseries.metric.editor.deleteSeriesTooltip": "Supprimer la série", + "visTypeTimeseries.metric.editor.labelPlaceholder": "Étiquette", + "visTypeTimeseries.metric.editor.toggleEditorAriaLabel": "Activer/Désactiver l'éditeur de séries", + "visTypeTimeseries.metric.optionsTab.colorRulesLabel": "Règles de couleur", + "visTypeTimeseries.metric.optionsTab.dataLabel": "Données", + "visTypeTimeseries.metric.optionsTab.ignoreGlobalFilterLabel": "Ignorer le filtre global ?", + "visTypeTimeseries.metric.optionsTab.optionsButtonLabel": "Options", + "visTypeTimeseries.metric.optionsTab.panelFilterLabel": "Filtre de panneau", + "visTypeTimeseries.metric.optionsTab.panelOptionsButtonLabel": "Options du panneau", + "visTypeTimeseries.metricMissingErrorMessage": "Indicateur manquant {field}", + "visTypeTimeseries.metricSelect.selectMetricPlaceholder": "Sélectionner l'indicateur…", + "visTypeTimeseries.missingPanelConfigDescription": "Configuration de panneau manquante pour \"{modelType}\"", + "visTypeTimeseries.movingAverage.aggregationLabel": "Agrégation", + "visTypeTimeseries.movingAverage.alpha": "Alpha", + "visTypeTimeseries.movingAverage.beta": "Bêta", + "visTypeTimeseries.movingAverage.gamma": "Gamma", + "visTypeTimeseries.movingAverage.metricLabel": "Indicateur", + "visTypeTimeseries.movingAverage.model.selectPlaceholder": "Sélectionner", + "visTypeTimeseries.movingAverage.modelLabel": "Modèle", + "visTypeTimeseries.movingAverage.modelOptions.exponentiallyWeightedLabel": "Pondéré exponentiellement", + "visTypeTimeseries.movingAverage.modelOptions.holtLinearLabel": "Holt–Linéaire", + "visTypeTimeseries.movingAverage.modelOptions.holtWintersLabel": "Holt-Winters", + "visTypeTimeseries.movingAverage.modelOptions.linearLabel": "Linéaire", + "visTypeTimeseries.movingAverage.modelOptions.simpleLabel": "Simple", + "visTypeTimeseries.movingAverage.multiplicative": "Multiplicative", + "visTypeTimeseries.movingAverage.multiplicative.selectPlaceholder": "Sélectionner", + "visTypeTimeseries.movingAverage.multiplicativeOptions.false": "Faux", + "visTypeTimeseries.movingAverage.multiplicativeOptions.true": "Vrai", + "visTypeTimeseries.movingAverage.period": "Période", + "visTypeTimeseries.movingAverage.windowSizeHint": "La fenêtre doit toujours être au moins deux fois plus grande que la période.", + "visTypeTimeseries.movingAverage.windowSizeLabel": "Taille de la fenêtre", + "visTypeTimeseries.noButtonLabel": "Non", + "visTypeTimeseries.percentile.aggregationLabel": "Agrégation", + "visTypeTimeseries.percentile.fieldLabel": "Champ", + "visTypeTimeseries.percentile.fillToLabel": "Remplir à :", + "visTypeTimeseries.percentile.modeLabel": "Mode :", + "visTypeTimeseries.percentile.modeOptions.bandLabel": "Bande", + "visTypeTimeseries.percentile.modeOptions.lineLabel": "Ligne", + "visTypeTimeseries.percentile.percentile": "Centile", + "visTypeTimeseries.percentile.percentileAriaLabel": "Centile", + "visTypeTimeseries.percentile.percents": "Pour cent", + "visTypeTimeseries.percentile.shadeLabel": "Ombre (0 à 1) :", + "visTypeTimeseries.percentileHdr.numberOfSignificantValueDigits": "Nombre de chiffres à valeur significative (histogramme HDR)", + "visTypeTimeseries.percentileHdr.numberOfSignificantValueDigits.hint": "L'histogramme HDR (High Dynamic Range, grande plage dynamique) est une autre implémentation qui peut être utile lors du calcul des rangs centiles pour les mesures de la latence, car elle peut être plus rapide que l'implémentation t-digest, bien qu'elle présente une empreinte mémoire plus élevée. Le paramètre \"Nombre de chiffres à valeur significative\" spécifie le nombre de chiffres significatifs pour la résolution des valeurs de l'histogramme.", + "visTypeTimeseries.percentileRank.aggregationLabel": "Agrégation", + "visTypeTimeseries.percentileRank.fieldLabel": "Champ", + "visTypeTimeseries.percentileRank.values": "Valeurs", + "visTypeTimeseries.positiveOnly.aggregationLabel": "Agrégation", + "visTypeTimeseries.positiveOnly.metricLabel": "Indicateur", + "visTypeTimeseries.positiveRate.aggregationLabel": "Agrégation", + "visTypeTimeseries.positiveRate.helpText": "Cette agrégation ne doit être appliquée qu'à {link} ; il s'agit d'un raccourci pour appliquer Max., Dérivée et Positif uniquement à un champ.", + "visTypeTimeseries.positiveRate.helpTextLink": "nombres augmentant de manière monolithique", + "visTypeTimeseries.positiveRate.unitSelectPlaceholder": "Sélectionner le scaling…", + "visTypeTimeseries.positiveRate.unitsLabel": "Scaling", + "visTypeTimeseries.postiveRate.fieldLabel": "Champ", + "visTypeTimeseries.replaceVars.errors.markdownErrorDescription": "Veuillez vérifier que vous utilisez uniquement Markdown, des variables connues et des expressions Handlebar intégrées.", + "visTypeTimeseries.replaceVars.errors.markdownErrorTitle": "Erreur lors du traitement de votre Markdown", + "visTypeTimeseries.replaceVars.errors.unknownVarDescription": "{badVar} est une variable inconnue.", + "visTypeTimeseries.replaceVars.errors.unknownVarTitle": "Erreur lors du traitement de votre Markdown", + "visTypeTimeseries.searchStrategyUndefinedErrorMessage": "La stratégie de recherche n'était pas définie.", + "visTypeTimeseries.serialDiff.aggregationLabel": "Agrégation", + "visTypeTimeseries.serialDiff.lagLabel": "Décalage", + "visTypeTimeseries.serialDiff.metricLabel": "Indicateur", + "visTypeTimeseries.series.missingAggregationKeyErrorMessage": "La clé des agrégations est manquante dans la réponse. Vérifiez vos autorisations pour cette requête.", + "visTypeTimeseries.series.shouldOneSeriesPerRequestErrorMessage": "Il ne devrait y avoir qu'une seule série par requête.", + "visTypeTimeseries.seriesAgg.aggregationLabel": "Agrégation", + "visTypeTimeseries.seriesAgg.functionLabel": "Fonction", + "visTypeTimeseries.seriesAgg.functionOptions.avgLabel": "Moy.", + "visTypeTimeseries.seriesAgg.functionOptions.countLabel": "Nombre de séries", + "visTypeTimeseries.seriesAgg.functionOptions.cumulativeSumLabel": "Somme cumulée", + "visTypeTimeseries.seriesAgg.functionOptions.maxLabel": "Max.", + "visTypeTimeseries.seriesAgg.functionOptions.minLabel": "Min.", + "visTypeTimeseries.seriesAgg.functionOptions.overallAvgLabel": "Moy. générale", + "visTypeTimeseries.seriesAgg.functionOptions.overallMaxLabel": "Max. général", + "visTypeTimeseries.seriesAgg.functionOptions.overallMinLabel": "Min. général", + "visTypeTimeseries.seriesAgg.functionOptions.overallSumLabel": "Somme générale", + "visTypeTimeseries.seriesAgg.functionOptions.sumLabel": "Somme", + "visTypeTimeseries.seriesAgg.seriesAggIsNotCompatibleLabel": "L'agrégation de séries n'est pas compatible avec la visualisation de tableau.", + "visTypeTimeseries.seriesConfig.filterLabel": "Filtre", + "visTypeTimeseries.seriesConfig.ignoreGlobalFilterDisabledTooltip": "Cette option est désactivée, car les filtres globaux sont ignorés dans les options du panneau.", + "visTypeTimeseries.seriesConfig.ignoreGlobalFilterLabel": "Ignorer le filtre global ?", + "visTypeTimeseries.seriesConfig.missingSeriesComponentDescription": "Composant de série manquant pour le type de panneau : {panelType}", + "visTypeTimeseries.seriesConfig.offsetSeriesTimeLabel": "Décaler l'heure de la série de (1m, 1h, 1w, 1d)", + "visTypeTimeseries.seriesConfig.templateHelpText": "par ex. {templateExample}", + "visTypeTimeseries.seriesConfig.templateLabel": "Modèle", + "visTypeTimeseries.sort.dragToSortAriaLabel": "Faire glisser pour trier", + "visTypeTimeseries.sort.dragToSortTooltip": "Faire glisser pour trier", + "visTypeTimeseries.splits.everything.groupByLabel": "Regrouper par", + "visTypeTimeseries.splits.filter.groupByLabel": "Regrouper par", + "visTypeTimeseries.splits.filter.queryStringLabel": "Chaîne de requête", + "visTypeTimeseries.splits.filterItems.labelAriaLabel": "Étiquette", + "visTypeTimeseries.splits.filterItems.labelPlaceholder": "Étiquette", + "visTypeTimeseries.splits.filters.groupByLabel": "Regrouper par", + "visTypeTimeseries.splits.groupBySelect.modeOptions.everythingLabel": "Tout", + "visTypeTimeseries.splits.groupBySelect.modeOptions.filterLabel": "Filtre", + "visTypeTimeseries.splits.groupBySelect.modeOptions.filtersLabel": "Filtres", + "visTypeTimeseries.splits.groupBySelect.modeOptions.termsLabel": "Termes", + "visTypeTimeseries.splits.terms.byLabel": "Par", + "visTypeTimeseries.splits.terms.defaultCountLabel": "Nombre de docs (par défaut)", + "visTypeTimeseries.splits.terms.directionLabel": "Sens", + "visTypeTimeseries.splits.terms.dirOptions.ascendingLabel": "Croissant", + "visTypeTimeseries.splits.terms.dirOptions.descendingLabel": "Décroissant", + "visTypeTimeseries.splits.terms.excludeLabel": "Exclure", + "visTypeTimeseries.splits.terms.groupByLabel": "Regrouper par", + "visTypeTimeseries.splits.terms.includeLabel": "Inclure", + "visTypeTimeseries.splits.terms.orderByLabel": "Classer par", + "visTypeTimeseries.splits.terms.sizePlaceholder": "Taille", + "visTypeTimeseries.splits.terms.termsLabel": "Termes", + "visTypeTimeseries.splits.terms.topLabel": "Haut", + "visTypeTimeseries.static.aggregationLabel": "Agrégation", + "visTypeTimeseries.static.staticValuesLabel": "Valeur statique", + "visTypeTimeseries.stdAgg.aggregationLabel": "Agrégation", + "visTypeTimeseries.stdAgg.fieldLabel": "Champ", + "visTypeTimeseries.stdDeviation.aggregationLabel": "Agrégation", + "visTypeTimeseries.stdDeviation.fieldLabel": "Champ", + "visTypeTimeseries.stdDeviation.modeLabel": "Mode", + "visTypeTimeseries.stdDeviation.modeOptions.boundsBandLabel": "Bande de limites", + "visTypeTimeseries.stdDeviation.modeOptions.lowerBoundLabel": "Limite inférieure", + "visTypeTimeseries.stdDeviation.modeOptions.rawLabel": "Brut", + "visTypeTimeseries.stdDeviation.modeOptions.upperBoundLabel": "Limite supérieure", + "visTypeTimeseries.stdDeviation.sigmaLabel": "Sigma", + "visTypeTimeseries.stdSibling.aggregationLabel": "Agrégation", + "visTypeTimeseries.stdSibling.metricLabel": "Indicateur", + "visTypeTimeseries.stdSibling.modeLabel": "Mode", + "visTypeTimeseries.stdSibling.modeOptions.boundsBandLabel": "Bande de limites", + "visTypeTimeseries.stdSibling.modeOptions.lowerBoundLabel": "Limite inférieure", + "visTypeTimeseries.stdSibling.modeOptions.rawLabel": "Brut", + "visTypeTimeseries.stdSibling.modeOptions.upperBoundLabel": "Limite supérieure", + "visTypeTimeseries.stdSibling.sigmaLabel": "Sigma", + "visTypeTimeseries.table.addSeriesTooltip": "Ajouter une série", + "visTypeTimeseries.table.aggregateFunctionLabel": "Fonction agrégée", + "visTypeTimeseries.table.avgLabel": "Moy.", + "visTypeTimeseries.table.cloneSeriesTooltip": "Cloner la série", + "visTypeTimeseries.table.colorRulesLabel": "Règles de couleurs", + "visTypeTimeseries.table.columnNotSortableTooltip": "Cette colonne ne peut pas être triée", + "visTypeTimeseries.table.cumulativeSumLabel": "Somme cumulée", + "visTypeTimeseries.table.dataTab.columnLabel": "Étiquette de colonne", + "visTypeTimeseries.table.dataTab.columnsButtonLabel": "Colonnes", + "visTypeTimeseries.table.dataTab.defineFieldDescription": "Pour la visualisation du tableau, vous devez définir un champ sur lequel effectuer le regroupement, en utilisant une agrégation de termes.", + "visTypeTimeseries.table.dataTab.groupByFieldLabel": "Champ Regrouper par", + "visTypeTimeseries.table.dataTab.rowsLabel": "Lignes", + "visTypeTimeseries.table.deleteSeriesTooltip": "Supprimer la série", + "visTypeTimeseries.table.fieldLabel": "Champ", + "visTypeTimeseries.table.filterLabel": "Filtre", + "visTypeTimeseries.table.labelAriaLabel": "Étiquette", + "visTypeTimeseries.table.labelPlaceholder": "Étiquette", + "visTypeTimeseries.table.maxLabel": "Max", + "visTypeTimeseries.table.minLabel": "Min", + "visTypeTimeseries.table.noResultsAvailableWithDescriptionMessage": "Aucun résultat disponible. Vous devez choisir un champ Regrouper par pour cette visualisation.", + "visTypeTimeseries.table.optionsTab.dataLabel": "Données", + "visTypeTimeseries.table.optionsTab.ignoreGlobalFilterLabel": "Ignorer le filtre global ?", + "visTypeTimeseries.table.optionsTab.itemUrlHelpText": "Prend en charge les modèles de moustaches. {key} est défini sur le terme.", + "visTypeTimeseries.table.optionsTab.itemUrlLabel": "URL de l'élément", + "visTypeTimeseries.table.optionsTab.panelFilterLabel": "Filtre de panneau", + "visTypeTimeseries.table.optionsTab.panelOptionsButtonLabel": "Options du panneau", + "visTypeTimeseries.table.overallAvgLabel": "Moy. générale", + "visTypeTimeseries.table.overallMaxLabel": "Max général", + "visTypeTimeseries.table.overallMinLabel": "Min général", + "visTypeTimeseries.table.overallSumLabel": "Somme générale", + "visTypeTimeseries.table.showTrendArrowsLabel": "Afficher les flèches de tendance ?", + "visTypeTimeseries.table.sumLabel": "Somme", + "visTypeTimeseries.table.tab.metricsLabel": "Indicateurs", + "visTypeTimeseries.table.tab.optionsLabel": "Options", + "visTypeTimeseries.table.templateHelpText": "par ex. {templateExample}", + "visTypeTimeseries.table.templateLabel": "Modèle", + "visTypeTimeseries.table.toggleSeriesEditorAriaLabel": "Basculer l'éditeur de séries", + "visTypeTimeseries.timeSeries.addSeriesTooltip": "Ajouter une série", + "visTypeTimeseries.timeseries.annotationsTab.annotationsButtonLabel": "Annotations", + "visTypeTimeseries.timeSeries.axisMaxLabel": "Max. de l'axe", + "visTypeTimeseries.timeSeries.axisMinLabel": "Min. de l'axe", + "visTypeTimeseries.timeSeries.axisPositionLabel": "Position de l'axe", + "visTypeTimeseries.timeSeries.barLabel": "Barre", + "visTypeTimeseries.timeSeries.chartBar.chartTypeLabel": "Type de graphique", + "visTypeTimeseries.timeSeries.chartBar.fillLabel": "Remplissage (0 à 1)", + "visTypeTimeseries.timeSeries.chartBar.lineWidthLabel": "Largeur de la ligne", + "visTypeTimeseries.timeSeries.chartBar.stackedLabel": "Empilé", + "visTypeTimeseries.timeSeries.chartLine.chartTypeLabel": "Type de graphique", + "visTypeTimeseries.timeSeries.chartLine.fillLabel": "Remplissage (0 à 1)", + "visTypeTimeseries.timeSeries.chartLine.lineWidthLabel": "Largeur de la ligne", + "visTypeTimeseries.timeSeries.chartLine.pointSizeLabel": "Taille du point", + "visTypeTimeseries.timeSeries.chartLine.stackedLabel": "Empilé", + "visTypeTimeseries.timeSeries.chartLine.stepsLabel": "Étapes", + "visTypeTimeseries.timeSeries.cloneSeriesTooltip": "Cloner la série", + "visTypeTimeseries.timeseries.dataTab.dataButtonLabel": "Données", + "visTypeTimeseries.timeSeries.deleteSeriesTooltip": "Supprimer la série", + "visTypeTimeseries.timeSeries.gradientLabel": "Gradient", + "visTypeTimeseries.timeSeries.hideInLegendLabel": "Masquer dans la légende", + "visTypeTimeseries.timeSeries.labelPlaceholder": "Étiquette", + "visTypeTimeseries.timeSeries.leftLabel": "Gauche", + "visTypeTimeseries.timeseries.legendPositionOptions.bottomLabel": "Bas", + "visTypeTimeseries.timeseries.legendPositionOptions.leftLabel": "Gauche", + "visTypeTimeseries.timeseries.legendPositionOptions.rightLabel": "Droite", + "visTypeTimeseries.timeSeries.lineLabel": "Ligne", + "visTypeTimeseries.timeSeries.noneLabel": "Aucun", + "visTypeTimeseries.timeSeries.offsetSeriesTimeLabel": "Décaler l'heure de la série de (1m, 1h, 1w, 1d)", + "visTypeTimeseries.timeseries.optionsTab.axisMaxLabel": "Max. de l'axe", + "visTypeTimeseries.timeseries.optionsTab.axisMinLabel": "Min. de l'axe", + "visTypeTimeseries.timeseries.optionsTab.axisPositionLabel": "Position de l'axe", + "visTypeTimeseries.timeseries.optionsTab.axisScaleLabel": "Échelle de l'axe", + "visTypeTimeseries.timeseries.optionsTab.backgroundColorLabel": "Couleur de l'arrière-plan :", + "visTypeTimeseries.timeseries.optionsTab.dataLabel": "Données", + "visTypeTimeseries.timeseries.optionsTab.displayGridLabel": "Afficher la grille", + "visTypeTimeseries.timeseries.optionsTab.ignoreDaylightTimeLabel": "Ignorer l'heure d'été ?", + "visTypeTimeseries.timeseries.optionsTab.ignoreGlobalFilterLabel": "Ignorer le filtre global ?", + "visTypeTimeseries.timeseries.optionsTab.legendPositionLabel": "Position de la légende", + "visTypeTimeseries.timeseries.optionsTab.maxLinesLabel": "Nombre maxi de lignes de légende", + "visTypeTimeseries.timeseries.optionsTab.panelFilterLabel": "Filtre de panneau", + "visTypeTimeseries.timeseries.optionsTab.panelOptionsButtonLabel": "Options du panneau", + "visTypeTimeseries.timeseries.optionsTab.showLegendLabel": "Afficher la légende ?", + "visTypeTimeseries.timeseries.optionsTab.styleLabel": "Style", + "visTypeTimeseries.timeseries.optionsTab.tooltipMode": "Infobulle", + "visTypeTimeseries.timeseries.optionsTab.truncateLegendLabel": "Tronquer la légende ?", + "visTypeTimeseries.timeSeries.percentLabel": "Pour cent", + "visTypeTimeseries.timeseries.positionOptions.leftLabel": "Gauche", + "visTypeTimeseries.timeseries.positionOptions.rightLabel": "Droite", + "visTypeTimeseries.timeSeries.rainbowLabel": "Arc-en-ciel", + "visTypeTimeseries.timeSeries.rightLabel": "Droite", + "visTypeTimeseries.timeseries.scaleOptions.logLabel": "Logarithmique", + "visTypeTimeseries.timeseries.scaleOptions.normalLabel": "Normal", + "visTypeTimeseries.timeSeries.separateAxisLabel": "Axe séparé ?", + "visTypeTimeseries.timeSeries.splitColorThemeLabel": "Thème de couleurs de division", + "visTypeTimeseries.timeSeries.stackedLabel": "Empilé", + "visTypeTimeseries.timeSeries.stackedWithinSeriesLabel": "Empilé dans la série", + "visTypeTimeseries.timeSeries.tab.metricsLabel": "Indicateurs", + "visTypeTimeseries.timeSeries.tab.optionsLabel": "Options", + "visTypeTimeseries.timeSeries.templateHelpText": "par ex. {templateExample}", + "visTypeTimeseries.timeSeries.templateLabel": "Modèle", + "visTypeTimeseries.timeSeries.toggleSeriesEditorAriaLabel": "Basculer l'éditeur de séries", + "visTypeTimeseries.timeseries.tooltipOptions.showAll": "Afficher toutes les valeurs", + "visTypeTimeseries.timeseries.tooltipOptions.showFocused": "Afficher les valeurs ciblées", + "visTypeTimeseries.topHit.aggregateWith.selectPlaceholder": "Sélectionner…", + "visTypeTimeseries.topHit.aggregateWithLabel": "Agréger avec", + "visTypeTimeseries.topHit.aggregationLabel": "Agrégation", + "visTypeTimeseries.topHit.aggWithOptions.averageLabel": "Moy.", + "visTypeTimeseries.topHit.aggWithOptions.concatenate": "Concaténer", + "visTypeTimeseries.topHit.aggWithOptions.maxLabel": "Max", + "visTypeTimeseries.topHit.aggWithOptions.minLabel": "Min", + "visTypeTimeseries.topHit.aggWithOptions.sumLabel": "Somme", + "visTypeTimeseries.topHit.fieldLabel": "Champ", + "visTypeTimeseries.topHit.order.selectPlaceholder": "Sélectionner…", + "visTypeTimeseries.topHit.orderByLabel": "Classer par", + "visTypeTimeseries.topHit.orderLabel": "Ordre", + "visTypeTimeseries.topHit.orderOptions.ascLabel": "Croiss.", + "visTypeTimeseries.topHit.orderOptions.descLabel": "Décroiss.", + "visTypeTimeseries.topHit.sizeLabel": "Taille", + "visTypeTimeseries.topN.addSeriesTooltip": "Ajouter une série", + "visTypeTimeseries.topN.cloneSeriesTooltip": "Cloner la série", + "visTypeTimeseries.topN.dataTab.dataButtonLabel": "Données", + "visTypeTimeseries.topN.deleteSeriesTooltip": "Supprimer la série", + "visTypeTimeseries.topN.labelPlaceholder": "Étiquette", + "visTypeTimeseries.topN.optionsTab.backgroundColorLabel": "Couleur de l'arrière-plan :", + "visTypeTimeseries.topN.optionsTab.colorRulesLabel": "Règles de couleurs", + "visTypeTimeseries.topN.optionsTab.dataLabel": "Données", + "visTypeTimeseries.topN.optionsTab.ignoreGlobalFilterLabel": "Ignorer le filtre global ?", + "visTypeTimeseries.topN.optionsTab.itemUrlDescription": "Prend en charge les modèles de moustaches. {key} est défini sur le terme.", + "visTypeTimeseries.topN.optionsTab.itemUrlLabel": "URL de l'élément", + "visTypeTimeseries.topN.optionsTab.panelFilterLabel": "Filtre de panneau", + "visTypeTimeseries.topN.optionsTab.panelOptionsButtonLabel": "Options du panneau", + "visTypeTimeseries.topN.optionsTab.styleLabel": "Style", + "visTypeTimeseries.topN.tab.metricsLabel": "Indicateurs", + "visTypeTimeseries.topN.tab.optionsLabel": "Options", + "visTypeTimeseries.topN.toggleSeriesEditorAriaLabel": "Basculer l'éditeur de séries", + "visTypeTimeseries.units.auto": "auto", + "visTypeTimeseries.units.perDay": "par jour", + "visTypeTimeseries.units.perHour": "par heure", + "visTypeTimeseries.units.perMillisecond": "par milliseconde", + "visTypeTimeseries.units.perMinute": "par minute", + "visTypeTimeseries.units.perSecond": "par seconde", + "visTypeTimeseries.unsupportedSplit.splitIsUnsupportedDescription": "Diviser par {modelType} n'est pas pris en charge.", + "visTypeTimeseries.vars.variableNameAriaLabel": "Nom de la variable", + "visTypeTimeseries.vars.variableNamePlaceholder": "Nom de la variable", + "visTypeTimeseries.visEditorVisualization.applyChangesLabel": "Appliquer les modifications", + "visTypeTimeseries.visEditorVisualization.autoApplyLabel": "Appliquer automatiquement", + "visTypeTimeseries.visEditorVisualization.changesHaveNotBeenAppliedMessage": "Les modifications apportées à cette visualisation n'ont pas été appliquées.", + "visTypeTimeseries.visEditorVisualization.changesSuccessfullyAppliedMessage": "Les dernières modifications ont été appliquées.", + "visTypeTimeseries.visEditorVisualization.changesWillBeAutomaticallyAppliedMessage": "Les modifications seront appliquées automatiquement.", + "visTypeTimeseries.visPicker.gaugeLabel": "Jauge", + "visTypeTimeseries.visPicker.metricLabel": "Indicateur", + "visTypeTimeseries.visPicker.tableLabel": "Tableau", + "visTypeTimeseries.visPicker.timeSeriesLabel": "Séries temporelles", + "visTypeTimeseries.visPicker.topNLabel": "N premiers", + "visTypeTimeseries.yesButtonLabel": "Oui", + "visTypeVega.editor.formatError": "Erreur lors du formatage des spécifications", + "visTypeVega.editor.reformatAsHJSONButtonLabel": "Reformater en HJSON", + "visTypeVega.editor.reformatAsJSONButtonLabel": "Reformater en JSON, supprimer les commentaires", + "visTypeVega.editor.vegaDocumentationLinkText": "Documentation Vega", + "visTypeVega.editor.vegaEditorOptionsButtonAriaLabel": "Options de l'éditeur Vega", + "visTypeVega.editor.vegaHelpButtonAriaLabel": "Aide Vega", + "visTypeVega.editor.vegaHelpLinkText": "Aide Kibana Vega", + "visTypeVega.editor.vegaLiteDocumentationLinkText": "Documentation Vega-Lite", + "visTypeVega.emsFileParser.emsFileNameDoesNotExistErrorMessage": "{emsfile} {emsfileName} n'existe pas", + "visTypeVega.emsFileParser.missingNameOfFileErrorMessage": "{dataUrlParam} avec {dataUrlParamValue} requiert le paramètre {nameParam} (nom du fichier)", + "visTypeVega.esQueryParser.autointervalValueTypeErrorMessage": "{autointerval} doit être {trueValue} ou un nombre", + "visTypeVega.esQueryParser.dataUrlMustNotHaveLegacyAndBodyQueryValuesAtTheSameTimeErrorMessage": "{dataUrlParam} ne doit pas avoir de {legacyContext} existant et de valeurs {bodyQueryConfigName} en même temps", + "visTypeVega.esQueryParser.dataUrlMustNotHaveLegacyContextTogetherWithContextOrTimefieldErrorMessage": "{dataUrlParam} ne doit pas avoir de {legacyContext} avec {context} ou {timefield}", + "visTypeVega.esQueryParser.legacyContextCanBeTrueErrorMessage": "{legacyContext} existant peut être {trueValue} (ignore le sélecteur de plage temporelle), ou il peut s'agir du nom du champ temporel, par ex. {timestampParam}", + "visTypeVega.esQueryParser.legacyUrlShouldChangeToWarningMessage": "{urlParam} existant : {legacyUrl} doit être modifié en {result}", + "visTypeVega.esQueryParser.shiftMustValueTypeErrorMessage": "{shiftParam} doit être une valeur numérique", + "visTypeVega.esQueryParser.timefilterValueErrorMessage": "La propriété {timefilter} doit être définie sur {trueValue}, {minValue} ou {maxValue}", + "visTypeVega.esQueryParser.unknownUnitValueErrorMessage": "Valeur {unitParamName} inconnue. Doit être l'une des valeurs suivantes : [{unitParamValues}]", + "visTypeVega.esQueryParser.unnamedRequest": "Requête sans nom #{index}", + "visTypeVega.esQueryParser.urlBodyValueTypeErrorMessage": "{configName} doit être un objet", + "visTypeVega.esQueryParser.urlContextAndUrlTimefieldMustNotBeUsedErrorMessage": "{urlContext} et {timefield} ne doivent pas être utilisés lorsque {queryParam} est défini", + "visTypeVega.function.help": "Visualisation Vega", + "visTypeVega.inspector.dataSetsLabel": "Ensembles de données", + "visTypeVega.inspector.dataViewer.dataSetAriaLabel": "Ensemble de données", + "visTypeVega.inspector.dataViewer.gridAriaLabel": "Grille de données {name}", + "visTypeVega.inspector.signalValuesLabel": "Valeurs de signal", + "visTypeVega.inspector.signalViewer.gridAriaLabel": "Grille de données des valeurs de signal", + "visTypeVega.inspector.specLabel": "Spéc.", + "visTypeVega.inspector.specViewer.copyToClipboardLabel": "Copier dans le presse-papiers", + "visTypeVega.inspector.vegaAdapter.signal": "Signal", + "visTypeVega.inspector.vegaAdapter.value": "Valeur", + "visTypeVega.inspector.vegaDebugLabel": "Débogage Vega", + "visTypeVega.mapView.experimentalMapLayerInfo": "Les calques de cartes sont expérimentaux et ne sont pas soumis aux accords de niveau de service d'assistance des fonctionnalités officielles en disponibilité générale. Pour apporter des commentaires, veuillez créer une entrée dans {githubLink}.", + "visTypeVega.mapView.mapStyleNotFoundWarningMessage": "{mapStyleParam} est introuvable", + "visTypeVega.mapView.minZoomAndMaxZoomHaveBeenSwappedWarningMessage": "{minZoomPropertyName} et {maxZoomPropertyName} ont été permutés", + "visTypeVega.mapView.resettingPropertyToMaxValueWarningMessage": "Réinitialisation de {name} sur {max}", + "visTypeVega.mapView.resettingPropertyToMinValueWarningMessage": "Réinitialisation de {name} sur {min}", + "visTypeVega.type.vegaDescription": "Utilisez Vega pour créer de nouveaux types de visualisations.", + "visTypeVega.type.vegaNote": "Requiert une connaissance de la syntaxe Vega.", + "visTypeVega.type.vegaTitleInWizard": "Visualisation personnalisée", + "visTypeVega.urlParser.dataUrlRequiresUrlParameterInFormErrorMessage": "{dataUrlParam} requiert un paramètre {urlParam} sous la forme \"{formLink}\"", + "visTypeVega.urlParser.urlShouldHaveQuerySubObjectWarningMessage": "L'utilisation d'un {urlObject} requiert un sous-objet {subObjectName}", + "visTypeVega.vegaParser.autoSizeDoesNotAllowFalse": "{autoSizeParam} est activé ; il peut uniquement être désactivé en définissant {autoSizeParam} sur {noneParam}", + "visTypeVega.vegaParser.baseView.externalUrlsAreNotEnabledErrorMessage": "Les URL externes ne sont pas activées. Ajouter {enableExternalUrls} à {kibanaConfigFileName}", + "visTypeVega.vegaParser.baseView.functionIsNotDefinedForGraphErrorMessage": "{funcName} n'est pas défini pour ce graphe", + "visTypeVega.vegaParser.baseView.indexNotFoundErrorMessage": "Impossible de trouver l'index {index}", + "visTypeVega.vegaParser.baseView.timeValuesTypeErrorMessage": "Erreur lors de la définition du filtre de temps : les deux valeurs temporelles doivent être des dates relatives ou absolues. {start}, {end}", + "visTypeVega.vegaParser.baseView.unableToFindDefaultIndexErrorMessage": "Impossible de trouver l'index par défaut", + "visTypeVega.vegaParser.centerOnMarkConfigValueTypeErrorMessage": "Les valeurs attendues pour {configName} sont {trueValue}, {falseValue} ou un nombre", + "visTypeVega.vegaParser.dataExceedsSomeParamsUseTimesLimitErrorMessage": "Les données ne doivent pas avoir plus d'un paramètre {urlParam}, {valuesParam} et {sourceParam}", + "visTypeVega.vegaParser.hostConfigIsDeprecatedWarningMessage": "{deprecatedConfigName} a été déclassé. Utilisez {newConfigName} à la place.", + "visTypeVega.vegaParser.hostConfigValueTypeErrorMessage": "S'il est présent, le paramètre {configName} doit être un objet", + "visTypeVega.vegaParser.inputSpecDoesNotSpecifySchemaErrorMessage": "Vos spécifications requièrent un champ {schemaParam} avec une URL valide pour\nVega (voir {vegaSchemaUrl}) ou\nVega-Lite (voir {vegaLiteSchemaUrl}).\nL'URL est uniquement un identificateur. Kibana et votre navigateur n'accéderont jamais à cette URL.", + "visTypeVega.vegaParser.invalidVegaSpecErrorMessage": "Spécification Vega non valide", + "visTypeVega.vegaParser.kibanaConfigValueTypeErrorMessage": "S'il est présent, le paramètre {configName} doit être un objet", + "visTypeVega.vegaParser.maxBoundsValueTypeWarningMessage": "{maxBoundsConfigName} doit être un tableau avec quatre nombres", + "visTypeVega.vegaParser.notSupportedUrlTypeErrorMessage": "{urlObject} n'est pas pris en charge", + "visTypeVega.vegaParser.notValidLibraryVersionForInputSpecWarningMessage": "Les spécifications d'entrée utilisent {schemaLibrary} {schemaVersion}, mais la version actuelle de {schemaLibrary} est {libraryVersion}.", + "visTypeVega.vegaParser.paddingConfigValueTypeErrorMessage": "La valeur attendue pour {configName} est un nombre", + "visTypeVega.vegaParser.someKibanaConfigurationIsNoValidWarningMessage": "{configName} n'est pas valide", + "visTypeVega.vegaParser.someKibanaParamValueTypeWarningMessage": "{configName} doit être une valeur booléenne", + "visTypeVega.vegaParser.textTruncateConfigValueTypeErrorMessage": "La valeur attendue pour {configName} est une valeur booléenne", + "visTypeVega.vegaParser.unexpectedValueForPositionConfigurationErrorMessage": "Valeur inattendue pour la configuration {configurationName}", + "visTypeVega.vegaParser.unrecognizedControlsLocationValueErrorMessage": "Valeur {controlsLocationParam} non reconnue. Valeur attendue parmi [{locToDirMap}]", + "visTypeVega.vegaParser.unrecognizedDirValueErrorMessage": "Valeur {dirParam} non reconnue. Valeur attendue parmi [{expectedValues}]", + "visTypeVega.vegaParser.VLCompilerShouldHaveGeneratedSingleProtectionObjectErrorMessage": "Erreur interne : le compilateur Vega-Lite aurait dû générer un objet de projection unique", + "visTypeVega.vegaParser.widthAndHeightParamsAreIgnored": "Les paramètres {widthParam} et {heightParam} sont ignorés, car {autoSizeParam} est activé. Pour le désactiver, définissez {autoSizeParam} sur {noneParam}", + "visTypeVega.vegaParser.widthAndHeightParamsAreRequired": "Aucun rendu n'est généré lorsque {autoSizeParam} est défini sur {noneParam} quand les spécifications {vegaLiteParam} à facette ou répétées sont utilisées. Pour y remédier, retirez {autoSizeParam} ou utilisez {vegaParam}.", + "visTypeVega.visualization.renderErrorTitle": "Erreur Vega", + "visTypeVega.visualization.unableToRenderWithoutDataWarningMessage": "Impossible de générer un rendu sans données", + "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsText": "Nombre maximal de groupes pouvant être renvoyés par une source de données unique. Un nombre plus élevé pourra impacter négativement les performances de rendu du navigateur", + "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsTitle": "Nombre maximal de groupes pour la carte thermique", + "visTypeVislib.aggResponse.allDocsTitle": "Tous les docs", + "visTypeVislib.functions.pie.help": "Visualisation camembert", + "visTypeVislib.functions.vislib.help": "Visualisation Vislib", + "visTypeVislib.vislib.errors.noResultsFoundTitle": "Aucun résultat trouvé", + "visTypeVislib.vislib.heatmap.maxBucketsText": "Trop de séries sont définies ({nr}). La valeur de configuration maximale est {max}.", + "visTypeVislib.vislib.legend.filterForValueButtonAriaLabel": "Filtrer pour la valeur {legendDataLabel}", + "visTypeVislib.vislib.legend.filterOptionsLegend": "{legendDataLabel}, options de filtre", + "visTypeVislib.vislib.legend.filterOutValueButtonAriaLabel": "Filtrer la valeur {legendDataLabel}", + "visTypeVislib.vislib.legend.loadingLabel": "chargement…", + "visTypeVislib.vislib.legend.toggleLegendButtonAriaLabel": "Basculer la légende", + "visTypeVislib.vislib.legend.toggleLegendButtonTitle": "Basculer la légende", + "visTypeVislib.vislib.legend.toggleOptionsButtonAriaLabel": "{legendDataLabel}, options de basculement", + "visTypeVislib.vislib.tooltip.fieldLabel": "champ", + "visTypeVislib.vislib.tooltip.valueLabel": "valeur", + "visTypeXy.aggResponse.allDocsTitle": "Tous les docs", + "visTypeXy.area.areaDescription": "Mettez en avant les données entre un axe et une ligne.", + "visTypeXy.area.areaTitle": "Aire", + "visTypeXy.area.groupTitle": "Diviser la série", + "visTypeXy.area.metricsTitle": "Axe Y", + "visTypeXy.area.radiusTitle": "Taille du point", + "visTypeXy.area.segmentTitle": "Axe X", + "visTypeXy.area.splitTitle": "Diviser le graphique", + "visTypeXy.area.tabs.metricsAxesTitle": "Indicateurs et axes", + "visTypeXy.area.tabs.panelSettingsTitle": "Paramètres du panneau", + "visTypeXy.axisModes.normalText": "Normal", + "visTypeXy.axisModes.percentageText": "Pourcentage", + "visTypeXy.axisModes.silhouetteText": "Silhouette", + "visTypeXy.axisModes.wiggleText": "Ondulé", + "visTypeXy.categoryAxis.rotate.angledText": "En angle", + "visTypeXy.categoryAxis.rotate.horizontalText": "Horizontal", + "visTypeXy.categoryAxis.rotate.verticalText": "Vertical", + "visTypeXy.chartModes.normalText": "Normal", + "visTypeXy.chartModes.stackedText": "Empilé", + "visTypeXy.chartTypes.areaText": "Aire", + "visTypeXy.chartTypes.barText": "Barre", + "visTypeXy.chartTypes.lineText": "Ligne", + "visTypeXy.controls.pointSeries.categoryAxis.alignLabel": "Aligner", + "visTypeXy.controls.pointSeries.categoryAxis.filterLabelsLabel": "Étiquettes de filtre", + "visTypeXy.controls.pointSeries.categoryAxis.labelsTitle": "Étiquettes", + "visTypeXy.controls.pointSeries.categoryAxis.positionLabel": "Position", + "visTypeXy.controls.pointSeries.categoryAxis.showLabel": "Afficher les lignes et étiquettes de l'axe", + "visTypeXy.controls.pointSeries.categoryAxis.showLabelsLabel": "Afficher les étiquettes", + "visTypeXy.controls.pointSeries.categoryAxis.xAxisTitle": "Axe X", + "visTypeXy.controls.pointSeries.gridAxis.dontShowLabel": "Ne pas afficher", + "visTypeXy.controls.pointSeries.gridAxis.gridText": "Grille", + "visTypeXy.controls.pointSeries.gridAxis.xAxisLinesLabel": "Afficher les lignes de l'axe X", + "visTypeXy.controls.pointSeries.gridAxis.yAxisLinesLabel": "Lignes de l'axe Y", + "visTypeXy.controls.pointSeries.series.chartTypeLabel": "Type de graphique", + "visTypeXy.controls.pointSeries.series.circlesRadius": "Taille des points", + "visTypeXy.controls.pointSeries.series.lineModeLabel": "Mode ligne", + "visTypeXy.controls.pointSeries.series.lineWidthLabel": "Largeur de la ligne", + "visTypeXy.controls.pointSeries.series.metricsTitle": "Indicateurs", + "visTypeXy.controls.pointSeries.series.modeLabel": "Mode", + "visTypeXy.controls.pointSeries.series.newAxisLabel": "Nouvel axe…", + "visTypeXy.controls.pointSeries.series.showDotsLabel": "Afficher les points", + "visTypeXy.controls.pointSeries.series.showLineLabel": "Afficher la ligne", + "visTypeXy.controls.pointSeries.series.valueAxisLabel": "Axe des valeurs", + "visTypeXy.controls.pointSeries.seriesAccordionAriaLabel": "Basculer les options {agg}", + "visTypeXy.controls.pointSeries.valueAxes.addButtonTooltip": "Ajouter l'axe Y", + "visTypeXy.controls.pointSeries.valueAxes.customExtentsLabel": "Extensions personnalisées", + "visTypeXy.controls.pointSeries.valueAxes.maxLabel": "Max", + "visTypeXy.controls.pointSeries.valueAxes.minErrorMessage": "Min doit être inférieur à Max.", + "visTypeXy.controls.pointSeries.valueAxes.minLabel": "Min", + "visTypeXy.controls.pointSeries.valueAxes.minNeededScaleText": "Min doit être supérieur à 0 lorsqu'une échelle logarithmique est sélectionnée.", + "visTypeXy.controls.pointSeries.valueAxes.modeLabel": "Mode", + "visTypeXy.controls.pointSeries.valueAxes.positionLabel": "Position", + "visTypeXy.controls.pointSeries.valueAxes.removeButtonTooltip": "Retirer l'axe Y", + "visTypeXy.controls.pointSeries.valueAxes.scaleToDataBounds.boundsMargin": "Marge des limites", + "visTypeXy.controls.pointSeries.valueAxes.scaleToDataBounds.minNeededBoundsMargin": "La marge des limites doit être supérieure ou égale à 0.", + "visTypeXy.controls.pointSeries.valueAxes.scaleToDataBoundsLabel": "Scaler sur les limites de données", + "visTypeXy.controls.pointSeries.valueAxes.scaleTypeLabel": "Type d'échelle", + "visTypeXy.controls.pointSeries.valueAxes.setAxisExtentsLabel": "Définir la portée de l'axe", + "visTypeXy.controls.pointSeries.valueAxes.showLabel": "Afficher les lignes et étiquettes de l'axe", + "visTypeXy.controls.pointSeries.valueAxes.titleLabel": "Titre", + "visTypeXy.controls.pointSeries.valueAxes.toggleCustomExtendsAriaLabel": "Basculer la portée personnalisée", + "visTypeXy.controls.pointSeries.valueAxes.toggleOptionsAriaLabel": "Basculer les options {axisName}", + "visTypeXy.controls.pointSeries.valueAxes.yAxisTitle": "Axes Y", + "visTypeXy.controls.truncateLabel": "Tronquer", + "visTypeXy.editors.elasticChartsOptions.detailedTooltip.label": "Afficher l'infobulle détaillée", + "visTypeXy.editors.elasticChartsOptions.detailedTooltip.tooltip": "Active l'ancienne infobulle détaillée pour l'affichage d'une valeur unique. Lorsque cette option est désactivée, une nouvelle infobulle résumée affichera plusieurs valeurs.", + "visTypeXy.editors.elasticChartsOptions.fillOpacity": "Opacité de remplissage", + "visTypeXy.editors.elasticChartsOptions.missingValuesLabel": "Remplir les valeurs manquantes", + "visTypeXy.editors.pointSeries.currentTimeMarkerLabel": "Repère de temps actuel", + "visTypeXy.editors.pointSeries.orderBucketsBySumLabel": "Classer les groupes par somme", + "visTypeXy.editors.pointSeries.settingsTitle": "Paramètres", + "visTypeXy.editors.pointSeries.showLabels": "Afficher les valeurs sur le graphique", + "visTypeXy.editors.pointSeries.thresholdLine.colorLabel": "Couleur de la ligne", + "visTypeXy.editors.pointSeries.thresholdLine.showLabel": "Afficher la ligne de seuil", + "visTypeXy.editors.pointSeries.thresholdLine.styleLabel": "Style de la ligne", + "visTypeXy.editors.pointSeries.thresholdLine.valueLabel": "Valeur seuil", + "visTypeXy.editors.pointSeries.thresholdLine.widthLabel": "Largeur de la ligne", + "visTypeXy.editors.pointSeries.thresholdLineSettingsTitle": "Ligne de seuil", + "visTypeXy.fittingFunctionsTitle.carry": "Dernière (remplit les blancs avec la dernière valeur)", + "visTypeXy.fittingFunctionsTitle.linear": "Linéaire (remplit les blancs avec une ligne)", + "visTypeXy.fittingFunctionsTitle.lookahead": "Suivante (remplit les blancs avec la valeur suivante)", + "visTypeXy.fittingFunctionsTitle.none": "Masquer (ne remplit pas les blancs)", + "visTypeXy.fittingFunctionsTitle.zero": "Zéro (remplit les blancs avec des zéros)", + "visTypeXy.function.adimension.bucket": "Groupe", + "visTypeXy.function.adimension.dotSize": "Taille du point", + "visTypeXy.function.args.addLegend.help": "Afficher la légende du graphique", + "visTypeXy.function.args.addTimeMarker.help": "Afficher le repère de temps", + "visTypeXy.function.args.addTooltip.help": "Afficher l'infobulle au survol", + "visTypeXy.function.args.args.chartType.help": "Type de graphique. Peut être linéaire, en aires ou histogramme", + "visTypeXy.function.args.categoryAxes.help": "Configuration de l'axe de catégorie", + "visTypeXy.function.args.detailedTooltip.help": "Afficher l'infobulle détaillée", + "visTypeXy.function.args.fillOpacity.help": "Définit l'opacité du remplissage du graphique en aires", + "visTypeXy.function.args.fittingFunction.help": "Nom de la fonction d'adaptation", + "visTypeXy.function.args.gridCategoryLines.help": "Afficher les lignes de catégories de la grille dans le graphique", + "visTypeXy.function.args.gridValueAxis.help": "Nom de l'axe des valeurs pour lequel la grille est affichée", + "visTypeXy.function.args.isVislibVis.help": "Indicateur des anciennes visualisations vislib. Utilisé pour la rétro-compatibilité, notamment pour les couleurs", + "visTypeXy.function.args.labels.help": "Configuration des étiquettes du graphique", + "visTypeXy.function.args.legendPosition.help": "Positionner la légende en haut, en bas, à gauche ou à droite du graphique", + "visTypeXy.function.args.orderBucketsBySum.help": "Classer les groupes par somme", + "visTypeXy.function.args.palette.help": "Définit le nom de la palette du graphique", + "visTypeXy.function.args.radiusRatio.help": "Rapport de taille des points", + "visTypeXy.function.args.seriesDimension.help": "Configuration de la dimension de la série", + "visTypeXy.function.args.seriesParams.help": "Configuration des paramètres de la série", + "visTypeXy.function.args.splitColumnDimension.help": "Configuration de la dimension Diviser par colonne", + "visTypeXy.function.args.splitRowDimension.help": "Configuration de la dimension Diviser par ligne", + "visTypeXy.function.args.thresholdLine.help": "Configuration de la ligne de seuil", + "visTypeXy.function.args.times.help": "Configuration du repère de temps", + "visTypeXy.function.args.valueAxes.help": "Configuration de l'axe des valeurs", + "visTypeXy.function.args.widthDimension.help": "Configuration de la dimension en largeur", + "visTypeXy.function.args.xDimension.help": "Configuration de la dimension de l'axe X", + "visTypeXy.function.args.yDimension.help": "Configuration de la dimension de l'axe Y", + "visTypeXy.function.args.zDimension.help": "Configuration de la dimension de l'axe Z", + "visTypeXy.function.categoryAxis.help": "Génère l'objet axe de catégorie", + "visTypeXy.function.categoryAxis.id.help": "ID de l'axe de catégorie", + "visTypeXy.function.categoryAxis.labels.help": "Configuration de l'étiquette de l'axe", + "visTypeXy.function.categoryAxis.position.help": "Position de l'axe de catégorie", + "visTypeXy.function.categoryAxis.scale.help": "Configuration de l'échelle", + "visTypeXy.function.categoryAxis.show.help": "Afficher l'axe de catégorie", + "visTypeXy.function.categoryAxis.title.help": "Titre de l'axe de catégorie", + "visTypeXy.function.categoryAxis.type.help": "Type de l'axe de catégorie. Peut être une catégorie ou une valeur", + "visTypeXy.function.dimension.metric": "Indicateur", + "visTypeXy.function.dimension.splitcolumn": "Division de colonne", + "visTypeXy.function.dimension.splitrow": "Division de ligne", + "visTypeXy.function.label.color.help": "Couleur de l'étiquette", + "visTypeXy.function.label.filter.help": "Masque les étiquettes qui se chevauchent et les éléments en double sur l'axe", + "visTypeXy.function.label.help": "Génère l'objet étiquette", + "visTypeXy.function.label.overwriteColor.help": "Écraser la couleur", + "visTypeXy.function.label.rotate.help": "Faire pivoter l'angle", + "visTypeXy.function.label.show.help": "Afficher l'étiquette", + "visTypeXy.function.label.truncate.help": "Nombre de symboles avant troncature", + "visTypeXy.function.scale.boundsMargin.help": "Marge des limites", + "visTypeXy.function.scale.defaultYExtents.help": "Indicateur qui permet de scaler sur les limites de données", + "visTypeXy.function.scale.help": "Génère l'objet échelle", + "visTypeXy.function.scale.max.help": "Valeur max", + "visTypeXy.function.scale.min.help": "Valeur min", + "visTypeXy.function.scale.mode.help": "Mode échelle. Peut être normal, pourcentage, ondulé ou silhouette", + "visTypeXy.function.scale.setYExtents.help": "Indicateur qui permet de définir votre propre portée", + "visTypeXy.function.scale.type.help": "Type d'échelle. Peut être linéaire, logarithmique ou racine carrée", + "visTypeXy.function.seriesParam.circlesRadius.help": "Définit la taille des cercles (rayon)", + "visTypeXy.function.seriesParam.drawLinesBetweenPoints.help": "Trace des lignes entre des points", + "visTypeXy.function.seriesparam.help": "Génère un objet paramètres de la série", + "visTypeXy.function.seriesParam.id.help": "ID des paramètres de la série", + "visTypeXy.function.seriesParam.interpolate.help": "Mode d'interpolation. Peut être linéaire, cardinal ou palier suivant", + "visTypeXy.function.seriesParam.label.help": "Nom des paramètres de la série", + "visTypeXy.function.seriesParam.lineWidth.help": "Largeur de ligne", + "visTypeXy.function.seriesParam.mode.help": "Mode graphique. Peut être empilé ou pourcentage", + "visTypeXy.function.seriesParam.show.help": "Afficher les paramètres", + "visTypeXy.function.seriesParam.showCircles.help": "Afficher les cercles", + "visTypeXy.function.seriesParam.type.help": "Type de graphique. Peut être linéaire, en aires ou histogramme", + "visTypeXy.function.seriesParam.valueAxis.help": "Nom de l'axe des valeurs", + "visTypeXy.function.thresholdLine.color.help": "Couleur de la ligne de seuil", + "visTypeXy.function.thresholdLine.help": "Génère un objet ligne de seuil", + "visTypeXy.function.thresholdLine.show.help": "Afficher la ligne de seuil", + "visTypeXy.function.thresholdLine.style.help": "Style de la ligne de seuil. Peut être pleine, en tirets ou en point-tiret", + "visTypeXy.function.thresholdLine.value.help": "Valeur seuil", + "visTypeXy.function.thresholdLine.width.help": "Largeur de la ligne de seuil", + "visTypeXy.function.timeMarker.class.help": "Nom de classe Css", + "visTypeXy.function.timeMarker.color.help": "Couleur du repère de temps", + "visTypeXy.function.timemarker.help": "Génère un objet repère de temps", + "visTypeXy.function.timeMarker.opacity.help": "Opacité du repère de temps", + "visTypeXy.function.timeMarker.time.help": "Heure exacte", + "visTypeXy.function.timeMarker.width.help": "Largeur du repère de temps", + "visTypeXy.function.valueAxis.axisParams.help": "Paramètres de l'axe des valeurs", + "visTypeXy.function.valueaxis.help": "Génère l'objet axe des valeurs", + "visTypeXy.function.valueAxis.name.help": "Nom de l'axe des valeurs", + "visTypeXy.functions.help": "Visualisation XY", + "visTypeXy.histogram.groupTitle": "Diviser la série", + "visTypeXy.histogram.histogramDescription": "Présente les données en barres verticales sur un axe.", + "visTypeXy.histogram.histogramTitle": "Barre verticale", + "visTypeXy.histogram.metricTitle": "Axe Y", + "visTypeXy.histogram.radiusTitle": "Taille du point", + "visTypeXy.histogram.segmentTitle": "Axe X", + "visTypeXy.histogram.splitTitle": "Diviser le graphique", + "visTypeXy.horizontalBar.groupTitle": "Diviser la série", + "visTypeXy.horizontalBar.horizontalBarDescription": "Présente les données en barres horizontales sur un axe.", + "visTypeXy.horizontalBar.horizontalBarTitle": "Barre horizontale", + "visTypeXy.horizontalBar.metricTitle": "Axe Y", + "visTypeXy.horizontalBar.radiusTitle": "Taille du point", + "visTypeXy.horizontalBar.segmentTitle": "Axe X", + "visTypeXy.horizontalBar.splitTitle": "Diviser le graphique", + "visTypeXy.interpolationModes.smoothedText": "Lissé", + "visTypeXy.interpolationModes.steppedText": "Par paliers", + "visTypeXy.interpolationModes.straightText": "Droit", + "visTypeXy.legend.filterForValueButtonAriaLabel": "Filtre pour la valeur", + "visTypeXy.legend.filterOptionsLegend": "{legendDataLabel}, options de filtre", + "visTypeXy.legend.filterOutValueButtonAriaLabel": "Filtrer la valeur", + "visTypeXy.legendPositions.bottomText": "Bas", + "visTypeXy.legendPositions.leftText": "Gauche", + "visTypeXy.legendPositions.rightText": "Droite", + "visTypeXy.legendPositions.topText": "Haut", + "visTypeXy.line.groupTitle": "Diviser la série", + "visTypeXy.line.lineDescription": "Affiche les données sous forme d'une série de points.", + "visTypeXy.line.lineTitle": "Ligne", + "visTypeXy.line.metricTitle": "Axe Y", + "visTypeXy.line.radiusTitle": "Taille du point", + "visTypeXy.line.segmentTitle": "Axe X", + "visTypeXy.line.splitTitle": "Diviser le graphique", + "visTypeXy.scaleTypes.linearText": "Linéaire", + "visTypeXy.scaleTypes.logText": "Logarithmique", + "visTypeXy.scaleTypes.squareRootText": "Racine carrée", + "visTypeXy.thresholdLine.style.dashedText": "Tirets", + "visTypeXy.thresholdLine.style.dotdashedText": "Point-tiret", + "visTypeXy.thresholdLine.style.fullText": "Pleine", + "visualizations.advancedSettings.visualizeEnableLabsText": "Permet aux utilisateurs de créer, d’afficher et de modifier des visualisations expérimentales. Si la fonctionnalité est désactivée,\n seules les visualisations considérées prêtes pour la production sont disponibles pour l'utilisateur.", + "visualizations.advancedSettings.visualizeEnableLabsTitle": "Activer les visualisations expérimentales", + "visualizations.disabledLabVisualizationLink": "Lire la documentation", + "visualizations.disabledLabVisualizationMessage": "Veuillez activer le mode lab dans les paramètres avancés pour consulter les visualisations lab.", + "visualizations.disabledLabVisualizationTitle": "{title} est une visualisation lab.", + "visualizations.displayName": "Visualisation", + "visualizations.embeddable.placeholderTitle": "Titre de l'espace réservé", + "visualizations.function.range.from.help": "Début de la plage", + "visualizations.function.range.help": "Génère un objet plage", + "visualizations.function.range.to.help": "Fin de la plage", + "visualizations.function.visDimension.accessor.help": "Colonne de votre ensemble de données à utiliser (index de colonne ou nom de colonne)", + "visualizations.function.visDimension.format.help": "Format", + "visualizations.function.visDimension.formatParams.help": "Paramètres de format", + "visualizations.function.visDimension.help": "Génère un objet dimension visConfig", + "visualizations.function.xyDimension.aggType.help": "Type d'agrégation", + "visualizations.function.xydimension.help": "Génère un objet dimension xy", + "visualizations.function.xyDimension.label.help": "Étiquette", + "visualizations.function.xyDimension.params.help": "Paramètres", + "visualizations.function.xyDimension.visDimension.help": "Configuration de l'objet dimension", + "visualizations.initializeWithoutIndexPatternErrorMessage": "Tentative d'initialisation des agrégations sans modèle d'indexation", + "visualizations.newVisWizard.aggBasedGroupDescription": "Utilisez notre bibliothèque Visualize classique pour créer des graphiques basés sur des agrégations.", + "visualizations.newVisWizard.aggBasedGroupTitle": "Basé sur une agrégation", + "visualizations.newVisWizard.chooseSourceTitle": "Choisir une source", + "visualizations.newVisWizard.experimentalTitle": "Expérimental", + "visualizations.newVisWizard.experimentalTooltip": "Cette visualisation est susceptible d'être modifiée ou supprimée dans une version ultérieure, et n'est pas soumise à l'accord de niveau de service d'assistance.", + "visualizations.newVisWizard.exploreOptionLinkText": "Explorer les options", + "visualizations.newVisWizard.filterVisTypeAriaLabel": "Filtrer un type de visualisation", + "visualizations.newVisWizard.goBackLink": "Sélectionner une visualisation différente", + "visualizations.newVisWizard.helpTextAriaLabel": "Commencez à créer votre visualisation en sélectionnant un type pour cette visualisation. Appuyez sur Échap pour fermer ce mode. Appuyez sur Tab pour aller plus loin.", + "visualizations.newVisWizard.learnMoreText": "Envie d'en savoir plus ?", + "visualizations.newVisWizard.newVisTypeTitle": "Nouveau {visTypeName}", + "visualizations.newVisWizard.readDocumentationLink": "Lire la documentation", + "visualizations.newVisWizard.resultsFound": "{resultCount, plural, one {type trouvé} other {types trouvés}}", + "visualizations.newVisWizard.searchSelection.notFoundLabel": "Aucun index ni aucune recherche enregistrée correspondant(e) trouvé(e).", + "visualizations.newVisWizard.searchSelection.savedObjectType.search": "Recherche enregistrée", + "visualizations.newVisWizard.title": "Nouvelle visualisation", + "visualizations.newVisWizard.toolsGroupTitle": "Outils", + "visualizations.noResultsFoundTitle": "Aucun résultat trouvé", + "visualizations.savedObjectName": "Visualisation", + "visualizations.savingVisualizationFailed.errorMsg": "L'enregistrement de la visualisation a échoué", + "visualizations.visualizationTypeInvalidMessage": "Type de visualisation non valide \"{visType}\"", + "xpack.actions.actionTypeRegistry.get.missingActionTypeErrorMessage": "Le type d'action \"{id}\" n'est pas enregistré.", + "xpack.actions.actionTypeRegistry.register.duplicateActionTypeErrorMessage": "Le type d'action \"{id}\" est déjà enregistré.", + "xpack.actions.alertHistoryEsIndexConnector.name": "Index Elasticsearch d'historique d'alertes", + "xpack.actions.appName": "Actions", + "xpack.actions.builtin.case.swimlaneTitle": "Swimlane", + "xpack.actions.builtin.cases.jiraTitle": "Jira", + "xpack.actions.builtin.cases.resilientTitle": "IBM Resilient", + "xpack.actions.builtin.configuration.apiAllowedHostsError": "erreur lors de la configuration de l'action du connecteur : {message}", + "xpack.actions.builtin.email.customViewInKibanaMessage": "Ce message a été envoyé par Kibana. [{kibanaFooterLinkText}]({link}).", + "xpack.actions.builtin.email.errorSendingErrorMessage": "erreur lors de l'envoi de l'e-mail", + "xpack.actions.builtin.email.kibanaFooterLinkText": "Accéder à Kibana", + "xpack.actions.builtin.email.sentByKibanaMessage": "Ce message a été envoyé par Kibana.", + "xpack.actions.builtin.emailTitle": "E-mail", + "xpack.actions.builtin.esIndex.errorIndexingErrorMessage": "erreur lors de l'indexation des documents", + "xpack.actions.builtin.esIndexTitle": "Index", + "xpack.actions.builtin.jira.configuration.apiAllowedHostsError": "erreur lors de la configuration de l'action du connecteur : {message}", + "xpack.actions.builtin.pagerduty.invalidTimestampErrorMessage": "erreur lors de l'analyse de l'horodatage \"{timestamp}\"", + "xpack.actions.builtin.pagerduty.missingDedupkeyErrorMessage": "DedupKey est requis lorsque eventAction est \"{eventAction}\"", + "xpack.actions.builtin.pagerduty.pagerdutyConfigurationError": "erreur lors de la configuration de l'action pagerduty : {message}", + "xpack.actions.builtin.pagerduty.postingErrorMessage": "erreur lors de la publication de l'événement pagerduty", + "xpack.actions.builtin.pagerduty.postingRetryErrorMessage": "erreur lors de la publication de l'événement pagerduty : statut http {status}, réessayer ultérieurement", + "xpack.actions.builtin.pagerduty.postingUnexpectedErrorMessage": "erreur lors de la publication de l'événement pagerduty : statut inattendu {status}", + "xpack.actions.builtin.pagerduty.timestampParsingFailedErrorMessage": "erreur lors de l'analyse de l'horodatage \"{timestamp}\" : {message}", + "xpack.actions.builtin.pagerdutyTitle": "PagerDuty", + "xpack.actions.builtin.serverLog.errorLoggingErrorMessage": "erreur lors du logging du message", + "xpack.actions.builtin.serverLogTitle": "Log de serveur", + "xpack.actions.builtin.serviceNowITSMTitle": "ServiceNow ITSM", + "xpack.actions.builtin.serviceNowSIRTitle": "ServiceNow SecOps", + "xpack.actions.builtin.serviceNowTitle": "ServiceNow", + "xpack.actions.builtin.slack.errorPostingErrorMessage": "erreur lors de la publication du message slack", + "xpack.actions.builtin.slack.errorPostingRetryDateErrorMessage": "erreur lors de la publication d'un message slack, réessayer à cette date/heure : {retryString}", + "xpack.actions.builtin.slack.errorPostingRetryLaterErrorMessage": "erreur lors de la publication d'un message slack, réessayer ultérieurement", + "xpack.actions.builtin.slack.slackConfigurationError": "erreur lors de la configuration de l'action slack : {message}", + "xpack.actions.builtin.slack.slackConfigurationErrorNoHostname": "erreur lors de la configuration de l'action slack : impossible d'analyser le nom de l'hôte depuis webhookUrl", + "xpack.actions.builtin.slack.unexpectedHttpResponseErrorMessage": "réponse http inattendue de Slack : {httpStatus} {httpStatusText}", + "xpack.actions.builtin.slack.unexpectedNullResponseErrorMessage": "réponse nulle inattendue de Slack", + "xpack.actions.builtin.slackTitle": "Slack", + "xpack.actions.builtin.swimlane.configuration.apiAllowedHostsError": "erreur lors de la configuration de l'action du connecteur : {message}", + "xpack.actions.builtin.swimlaneTitle": "Swimlane", + "xpack.actions.builtin.teams.errorPostingRetryDateErrorMessage": "erreur lors de la publication d'un message Microsoft Teams, réessayer à cette date/heure : {retryString}", + "xpack.actions.builtin.teams.errorPostingRetryLaterErrorMessage": "erreur lors de la publication d'un message Microsoft Teams, réessayer ultérieurement", + "xpack.actions.builtin.teams.invalidResponseErrorMessage": "erreur lors de la publication sur Microsoft Teams, réponse non valide", + "xpack.actions.builtin.teams.teamsConfigurationError": "erreur lors de la configuration de l'action teams : {message}", + "xpack.actions.builtin.teams.teamsConfigurationErrorNoHostname": "erreur lors de la configuration de l'action teams : impossible d'analyser le nom de l'hôte depuis webhookUrl", + "xpack.actions.builtin.teams.unreachableErrorMessage": "erreur lors de la publication sur Microsoft Teams, erreur inattendue", + "xpack.actions.builtin.teamsTitle": "Microsoft Teams", + "xpack.actions.builtin.webhook.invalidResponseErrorMessage": "erreur lors de l'appel de webhook, réponse non valide", + "xpack.actions.builtin.webhook.invalidResponseRetryDateErrorMessage": "erreur lors de l'appel de webhook, réessayer à cette date/heure : {retryString}", + "xpack.actions.builtin.webhook.invalidResponseRetryLaterErrorMessage": "erreur lors de l'appel de webhook, réessayer ultérieurement", + "xpack.actions.builtin.webhook.invalidUsernamePassword": "l'utilisateur et le mot de passe doivent être spécifiés", + "xpack.actions.builtin.webhook.requestFailedErrorMessage": "erreur lors de l'appel de webhook, requête échouée", + "xpack.actions.builtin.webhook.unreachableErrorMessage": "erreur lors de l'appel de webhook, erreur inattendue", + "xpack.actions.builtin.webhook.webhookConfigurationError": "erreur lors de la configuration de l'action webhook : {message}", + "xpack.actions.builtin.webhook.webhookConfigurationErrorNoHostname": "erreur lors de la configuration de l'action webhook : impossible d'analyser l'url : {err}", + "xpack.actions.builtin.webhookTitle": "Webhook", + "xpack.actions.disabledActionTypeError": "le type d'action \"{actionType}\" n'est pas activé dans la configuration Kibana xpack.actions.enabledActionTypes", + "xpack.actions.featureRegistry.actionsFeatureName": "Actions et connecteurs", + "xpack.actions.savedObjects.goToConnectorsButtonText": "Accéder aux connecteurs", + "xpack.actions.savedObjects.onImportText": "{connectorsWithSecretsLength} {connectorsWithSecretsLength, plural, one {Le connecteur contient} other {Les connecteurs contiennent}} des informations sensibles qui requièrent des mises à jour.", + "xpack.actions.serverSideErrors.expirerdLicenseErrorMessage": "Le type d'action {actionTypeId} est désactivé, car votre licence {licenseType} a expiré.", + "xpack.actions.serverSideErrors.invalidLicenseErrorMessage": "Le type d'action {actionTypeId} est désactivé, car votre licence {licenseType} ne le prend pas en charge. Veuillez mettre à niveau votre licence.", + "xpack.actions.serverSideErrors.predefinedActionDeleteDisabled": "L'action préconfigurée {id} n'est pas autorisée à effectuer des suppressions.", + "xpack.actions.serverSideErrors.predefinedActionUpdateDisabled": "L'action préconfigurée {id} n'est pas autorisée à effectuer des mises à jour.", + "xpack.actions.serverSideErrors.unavailableLicenseErrorMessage": "Le type d'action {actionTypeId} est désactivé, car les informations de licence ne sont pas disponibles actuellement.", + "xpack.actions.serverSideErrors.unavailableLicenseInformationErrorMessage": "Les actions sont indisponibles - les informations de licence ne sont pas disponibles actuellement.", + "xpack.actions.urlAllowedHostsConfigurationError": "Le {field} cible \"{value}\" n'est pas ajouté à la configuration Kibana xpack.actions.allowedHosts", + "xpack.alerting.alertNavigationRegistry.get.missingNavigationError": "La navigation pour le type d'alerte \"{alertType}\" dans \"{consumer}\" n'est pas enregistrée.", + "xpack.alerting.alertNavigationRegistry.register.duplicateDefaultError": "La navigation par défaut dans \"{consumer}\" est déjà enregistrée.", + "xpack.alerting.alertNavigationRegistry.register.duplicateNavigationError": "La navigation pour le type d'alerte \"{alertType}\" dans \"{consumer}\" est déjà enregistrée.", + "xpack.alerting.api.error.disabledApiKeys": "L'alerting se base sur les clés d'API qui semblent désactivées", + "xpack.alerting.appName": "Alerting", + "xpack.alerting.builtinActionGroups.recovered": "Récupéré", + "xpack.alerting.injectActionParams.email.kibanaFooterLinkText": "Afficher la règle dans Kibana", + "xpack.alerting.rulesClient.invalidDate": "Date non valide pour le {field} de paramètre : \"{dateValue}\"", + "xpack.alerting.rulesClient.validateActions.invalidGroups": "Groupes d'actions non valides : {groups}", + "xpack.alerting.rulesClient.validateActions.misconfiguredConnector": "Connecteurs non valides : {groups}", + "xpack.alerting.ruleTypeRegistry.register.customRecoveryActionGroupUsageError": "Le type de règle [id=\"{id}\"] ne peut pas être enregistré. Le groupe d'actions [{actionGroup}] ne peut pas être utilisé à la fois comme groupe de récupération et comme groupe d'actions actif.", + "xpack.alerting.ruleTypeRegistry.register.reservedActionGroupUsageError": "Le type de règle [id=\"{id}\"] ne peut pas être enregistré. Les groupes d'actions [{actionGroups}] sont réservés par le framework.", + "xpack.alerting.savedObjects.goToRulesButtonText": "Accéder aux règles", + "xpack.alerting.savedObjects.onImportText": "{rulesSavedObjectsLength} {rulesSavedObjectsLength, plural, one {La règle doit être activée} other {Les règles doivent être activées}} après l'importation.", + "xpack.alerting.serverSideErrors.unavailableLicenseInformationErrorMessage": "Les alertes sont indisponibles – les informations de licence ne sont pas disponibles actuellement.", + "xpack.apm.a.thresholdMet": "Seuil atteint", + "xpack.apm.addDataButtonLabel": "Ajouter des données", + "xpack.apm.agentConfig.allOptionLabel": "Tous", + "xpack.apm.agentConfig.apiRequestSize.description": "Taille totale compressée maximale du corps de la requête envoyé à l'API d'ingestion du serveur APM depuis un encodage fragmenté (diffusion HTTP).\nVeuillez noter qu'un léger dépassement est possible.\n\nLes unités d'octets autorisées sont \"b\", \"kb\" et \"mb\". \"1kb\" correspond à \"1024b\".", + "xpack.apm.agentConfig.apiRequestSize.label": "Taille de la requête API", + "xpack.apm.agentConfig.apiRequestTime.description": "Durée maximale de l'ouverture d'une requête HTTP sur le serveur APM.\n\nREMARQUE : cette valeur doit être inférieure à celle du paramètre \"read_timeout\" du serveur APM.", + "xpack.apm.agentConfig.apiRequestTime.label": "Heure de la requête API", + "xpack.apm.agentConfig.captureBody.description": "Pour les transactions qui sont des requêtes HTTP, l'agent peut éventuellement capturer le corps de la requête (par ex., variables POST).\nPour les transactions qui sont initiées par la réception d'un message depuis un agent de message, l'agent peut capturer le corps du message texte.", + "xpack.apm.agentConfig.captureBody.label": "Capturer le corps", + "xpack.apm.agentConfig.captureHeaders.description": "Si cette option est définie sur \"true\", l'agent capturera les en-têtes de la requête HTTP et de la réponse (y compris les cookies), ainsi que les en-têtes/les propriétés du message lors de l'utilisation de frameworks de messagerie (tels que Kafka).\n\nREMARQUE : Si \"false\" est défini, cela permet de réduire la bande passante du réseau, l'espace disque et les allocations d'objets.", + "xpack.apm.agentConfig.captureHeaders.label": "Capturer les en-têtes", + "xpack.apm.agentConfig.chooseService.editButton": "Modifier", + "xpack.apm.agentConfig.chooseService.service.environment.label": "Environnement", + "xpack.apm.agentConfig.chooseService.service.name.label": "Nom de service", + "xpack.apm.agentConfig.circuitBreakerEnabled.description": "Nombre booléen spécifiant si le disjoncteur doit être activé ou non. Lorsqu'il est activé, l'agent interroge régulièrement les monitorings de tension pour détecter l'état de tension du système/du processus/de la JVM. Si L'UN des monitorings détecte un signe de tension, l'agent s'interrompt, comme si l'option de configuration \"recording\" était définie sur \"false\", réduisant ainsi la consommation des ressources au minimum. Pendant l'interruption, l'agent continue à interroger les mêmes monitorings pour vérifier si l'état de tension a été allégé. Si TOUS les monitorings indiquent que le système, le processus et la JVM ne sont plus en état de tension, l'agent reprend son activité et redevient entièrement fonctionnel.", + "xpack.apm.agentConfig.circuitBreakerEnabled.label": "Disjoncteur activé", + "xpack.apm.agentConfig.configTable.appliedTooltipMessage": "Appliqué par au moins un agent", + "xpack.apm.agentConfig.configTable.configTable.failurePromptText": "La liste des configurations d'agent n'a pas pu être récupérée. Votre utilisateur ne dispose peut-être pas d'autorisations suffisantes.", + "xpack.apm.agentConfig.configTable.createConfigButtonLabel": "Créer une configuration", + "xpack.apm.agentConfig.configTable.emptyPromptTitle": "Aucune configuration trouvée.", + "xpack.apm.agentConfig.configTable.environmentColumnLabel": "Environnement de service", + "xpack.apm.agentConfig.configTable.lastUpdatedColumnLabel": "Dernière mise à jour", + "xpack.apm.agentConfig.configTable.notAppliedTooltipMessage": "Appliqué par aucun agent pour le moment", + "xpack.apm.agentConfig.configTable.serviceNameColumnLabel": "Nom de service", + "xpack.apm.agentConfig.configurationsPanelTitle": "Configurations", + "xpack.apm.agentConfig.configurationsPanelTitle.noPermissionTooltipLabel": "Votre rôle d'utilisateur ne dispose pas des autorisations nécessaires pour créer des configurations d'agent", + "xpack.apm.agentConfig.createConfigButtonLabel": "Créer une configuration", + "xpack.apm.agentConfig.createConfigTitle": "Créer une configuration", + "xpack.apm.agentConfig.deleteModal.cancel": "Annuler", + "xpack.apm.agentConfig.deleteModal.confirm": "Supprimer", + "xpack.apm.agentConfig.deleteModal.text": "Vous êtes sur le point de supprimer la configuration du service \"{serviceName}\" et de l'environnement \"{environment}\".", + "xpack.apm.agentConfig.deleteModal.title": "Supprimer la configuration", + "xpack.apm.agentConfig.deleteSection.deleteConfigFailedText": "Une erreur est survenue lors de la suppression d'une configuration de \"{serviceName}\". Erreur : \"{errorMessage}\"", + "xpack.apm.agentConfig.deleteSection.deleteConfigFailedTitle": "La configuration n'a pas pu être supprimée", + "xpack.apm.agentConfig.deleteSection.deleteConfigSucceededText": "Vous avez supprimé une configuration de \"{serviceName}\" avec succès. La propagation jusqu'aux agents pourra prendre un certain temps.", + "xpack.apm.agentConfig.deleteSection.deleteConfigSucceededTitle": "La configuration a été supprimée", + "xpack.apm.agentConfig.editConfigTitle": "Modifier la configuration", + "xpack.apm.agentConfig.enableLogCorrelation.description": "Nombre booléen spécifiant si l'agent doit être intégré au MDC de SLF4J pour activer la corrélation de logs de suivi. Si cette option est configurée sur \"true\", l'agent définira \"trace.id\" et \"transaction.id\" pour les intervalles et transactions actifs sur le MDC. Depuis la version 1.16.0 de l'agent Java, l'agent ajoute également le \"error.id\" de l'erreur capturée au MDC juste avant le logging du message d'erreur. REMARQUE : bien qu'il soit autorisé d'activer ce paramètre au moment de l'exécution, vous ne pouvez pas le désactiver sans redémarrage.", + "xpack.apm.agentConfig.enableLogCorrelation.label": "Activer la corrélation de logs", + "xpack.apm.agentConfig.logLevel.description": "Définit le niveau de logging pour l'agent", + "xpack.apm.agentConfig.logLevel.label": "Niveau de log", + "xpack.apm.agentConfig.newConfig.description": "Affinez votre configuration d'agent depuis l'application APM. Les modifications sont automatiquement propagées à vos agents APM, ce qui vous évite d'effectuer un redéploiement.", + "xpack.apm.agentConfig.profilingInferredSpansEnabled.description": "Définissez cette option sur \"true\" pour que l'agent crée des intervalles pour des exécutions de méthodes basées sur async-profiler, un profiler d'échantillonnage (ou profiler statistique). En raison de la nature du fonctionnement des profilers d'échantillonnage, la durée des intervalles générés n'est pas exacte, il ne s'agit que d'estimations. \"profiling_inferred_spans_sampling_interval\" vous permet d'ajuster avec exactitude le compromis entre précision et surcharge. Les intervalles générés sont créés à la fin d'une session de profilage. Cela signifie qu'il existe un délai entre les intervalles réguliers et les intervalles générés visibles dans l'interface utilisateur. REMARQUE : cette fonctionnalité n'est pas disponible sous Windows.", + "xpack.apm.agentConfig.profilingInferredSpansEnabled.label": "Intervalles générés par le profilage activés", + "xpack.apm.agentConfig.profilingInferredSpansExcludedClasses.description": "Exclut les classes pour lesquelles aucun intervalle généré par le profiler ne doit être créé. Cette option prend en charge le caractère générique \"*\" qui correspond à zéro caractère ou plus. La correspondance n'est pas sensible à la casse par défaut. L'ajout de \"(?-i)\" au début d'un élément rend la correspondance sensible à la casse.", + "xpack.apm.agentConfig.profilingInferredSpansExcludedClasses.label": "Classes exclues des intervalles générés par le profilage", + "xpack.apm.agentConfig.profilingInferredSpansIncludedClasses.description": "Si cette option est définie, l'agent ne créera des intervalles générés que pour les méthodes correspondant à cette liste. La définition d'une valeur peut diminuer légèrement la surcharge et réduire l'encombrement en ne créant des intervalles que pour les classes qui vous intéressent. Cette option prend en charge le caractère générique \"*\" qui correspond à zéro caractère ou plus. Par exemple : \"org.example.myapp.*\". La correspondance n'est pas sensible à la casse par défaut. L'ajout de \"(?-i)\" au début d'un élément rend la correspondance sensible à la casse.", + "xpack.apm.agentConfig.profilingInferredSpansIncludedClasses.label": "Classes incluses des intervalles générés par le profilage", + "xpack.apm.agentConfig.profilingInferredSpansMinDuration.description": "Durée minimale d'un intervalle généré. Veuillez noter que la durée minimale est également définie de façon implicite par l'intervalle d'échantillonnage. Toutefois, l'augmentation de l'intervalle d'échantillonnage diminue également la précision de la durée des intervalles générés.", + "xpack.apm.agentConfig.profilingInferredSpansMinDuration.label": "Durée minimale des intervalles générés par le profilage", + "xpack.apm.agentConfig.profilingInferredSpansSamplingInterval.description": "Fréquence à laquelle les traces de pile sont rassemblées au cours d'une session de profilage. Plus vous définissez un chiffre bas, plus les durées seront précises. Cela induit une surcharge plus élevée et un plus grand nombre d'intervalles, pour des opérations potentiellement non pertinentes. La durée minimale d'un intervalle généré par le profilage est identique à la valeur de ce paramètre.", + "xpack.apm.agentConfig.profilingInferredSpansSamplingInterval.label": "Intervalle d'échantillonnage des intervalles générés par le profilage", + "xpack.apm.agentConfig.range.errorText": "{rangeType, select,\n between {doit être compris entre {min} et {max}}\n gt {doit être supérieur à {min}}\n lt {doit être inférieur à {max}}\n other {doit être un entier}\n }", + "xpack.apm.agentConfig.recording.description": "Lorsque l'enregistrement est activé, l'agent instrumente les requêtes HTTP entrantes, effectue le suivi des erreurs, et collecte et envoie les indicateurs. Lorsque l'enregistrement n'est pas activé, l'agent agit comme un noop, sans collecter de données ni communiquer avec le serveur AMP, sauf pour rechercher la configuration mise à jour. Puisqu'il s'agit d'un commutateur réversible, les threads d'agents ne sont pas détruits lorsque le mode sans enregistrement est défini. Ils restent principalement inactifs, de sorte que la surcharge est négligeable. Vous pouvez utiliser ce paramètre pour contrôler dynamiquement si Elastic APM doit être activé ou désactivé.", + "xpack.apm.agentConfig.recording.label": "Enregistrement", + "xpack.apm.agentConfig.sanitizeFiledNames.description": "Il est parfois nécessaire d'effectuer un nettoyage, c'est-à-dire de supprimer les données sensibles envoyées à Elastic APM. Cette configuration accepte une liste de modèles de caractères génériques de champs de noms qui doivent être nettoyés. Ils s'appliquent aux en-têtes HTTP (y compris les cookies) et aux données \"application/x-www-form-urlencoded\" (champs de formulaire POST). La chaîne de la requête et le corps de la requête capturé (comme des données \"application/json\") ne seront pas nettoyés.", + "xpack.apm.agentConfig.sanitizeFiledNames.label": "Nettoyer les noms des champs", + "xpack.apm.agentConfig.saveConfig.failed.text": "Une erreur est survenue pendant l'enregistrement de la configuration de \"{serviceName}\". Erreur : \"{errorMessage}\"", + "xpack.apm.agentConfig.saveConfig.failed.title": "La configuration n'a pas pu être enregistrée", + "xpack.apm.agentConfig.saveConfig.succeeded.text": "La configuration de \"{serviceName}\" a été enregistrée. La propagation jusqu'aux agents pourra prendre un certain temps.", + "xpack.apm.agentConfig.saveConfig.succeeded.title": "Configuration enregistrée", + "xpack.apm.agentConfig.saveConfigurationButtonLabel": "Étape suivante", + "xpack.apm.agentConfig.serverTimeout.description": "Si une requête au serveur APM prend plus de temps que le délai d'expiration configuré,\nla requête est annulée et l'événement (exception ou transaction) est abandonné.\nDéfinissez sur 0 pour désactiver les délais d'expiration.\n\nAVERTISSEMENT : si les délais d'expiration sont désactivés ou définis sur une valeur élevée, il est possible que votre application rencontre des problèmes de mémoire en cas d'expiration du serveur APM.", + "xpack.apm.agentConfig.serverTimeout.label": "Délai d'expiration du serveur", + "xpack.apm.agentConfig.servicePage.alreadyConfiguredOption": "déjà configuré", + "xpack.apm.agentConfig.servicePage.cancelButton": "Annuler", + "xpack.apm.agentConfig.servicePage.environment.description": "Seul un environnement unique par configuration est pris en charge.", + "xpack.apm.agentConfig.servicePage.environment.fieldLabel": "Environnement de service", + "xpack.apm.agentConfig.servicePage.environment.title": "Environnement", + "xpack.apm.agentConfig.servicePage.service.description": "Choisissez le service que vous souhaitez configurer.", + "xpack.apm.agentConfig.servicePage.service.fieldLabel": "Nom de service", + "xpack.apm.agentConfig.servicePage.service.title": "Service", + "xpack.apm.agentConfig.settingsPage.discardChangesButton": "Abandonner les modifications", + "xpack.apm.agentConfig.settingsPage.notFound.message": "La configuration demandée n'existe pas", + "xpack.apm.agentConfig.settingsPage.notFound.title": "Désolé, une erreur est survenue", + "xpack.apm.agentConfig.settingsPage.saveButton": "Enregistrer la configuration", + "xpack.apm.agentConfig.spanFramesMinDuration.description": "Dans ses paramètres par défaut, l'agent APM collectera une trace de la pile avec chaque intervalle enregistré.\nBien qu'il soit très pratique de trouver l'endroit exact dans votre code qui provoque l'intervalle, la collecte de cette trace de la pile provoque une certaine surcharge. \nLorsque cette option est définie sur une valeur négative, telle que \"-1ms\", les traces de pile sont collectées pour tous les intervalles. En choisissant une valeur positive, par ex. \"5ms\", la collecte des traces de pile se limitera aux intervalles dont la durée est égale ou supérieure à la valeur donnée, par ex. 5 millisecondes.\n\nPour désactiver complètement la collecte des traces de pile des intervalles, réglez la valeur sur \"0ms\".", + "xpack.apm.agentConfig.spanFramesMinDuration.label": "Durée minimale des cadres des intervalles", + "xpack.apm.agentConfig.stackTraceLimit.description": "En définissant cette option sur 0, la collecte des traces de pile sera désactivée. Toute valeur entière positive sera utilisée comme nombre maximal de cadres à collecter. La valeur -1 signifie que tous les cadres seront collectés.", + "xpack.apm.agentConfig.stackTraceLimit.label": "Limite de trace de pile", + "xpack.apm.agentConfig.stressMonitorCpuDurationThreshold.description": "Durée minimale requise pour déterminer si le système est actuellement sous tension ou si la tension précédemment détectée a été allégée. Toutes les mesures réalisées pendant ce laps de temps doivent être cohérentes par rapport au seuil concerné pour pouvoir détecter un changement d'état de tension. La valeur doit être d'au moins \"1m\".", + "xpack.apm.agentConfig.stressMonitorCpuDurationThreshold.label": "Seuil de durée de tension CPU du monitoring", + "xpack.apm.agentConfig.stressMonitorGcReliefThreshold.description": "Seuil utilisé par le monitoring RM pour identifier le moment auquel le tas n'est pas sous tension. Si le \"stress_monitor_gc_stress_threshold\" a été franchi, l'agent le considérera comme un état de tension du tas. Pour déterminer l'état de tension comme terminé, le pourcentage de mémoire occupée dans TOUS les pools de tas doit être inférieur à ce seuil. Le monitoring RM ne se base que sur la consommation de mémoire mesurée après une RM récente.", + "xpack.apm.agentConfig.stressMonitorGcReliefThreshold.label": "Seuil d'allègement de la tension du monitoring RM", + "xpack.apm.agentConfig.stressMonitorGcStressThreshold.description": "Seuil utilisé par le monitoring RM pour identifier la tension du tas. Ce même seuil sera utilisé pour tous les pools de mémoire, de sorte que si L'UN d'entre eux a un pourcentage d'utilisation qui dépasse ce seuil, l'agent l'interprétera comme une tension de segment de mémoire. Le monitoring RM ne se base que sur la consommation de mémoire mesurée après une RM récente.", + "xpack.apm.agentConfig.stressMonitorGcStressThreshold.label": "Seuil de tension du monitoring RM", + "xpack.apm.agentConfig.stressMonitorSystemCpuReliefThreshold.description": "Seuil utilisé par le monitoring du CPU système pour déterminer que le système n'est pas sous tension au niveau du processeur. Si le monitor détecte une tension de CPU, le CPU système mesuré doit être inférieur à ce seuil pour une durée d'au moins \"stress_monitor_cpu_duration_threshold\", pour que le monitoring établisse l'allègement de la tension de CPU.", + "xpack.apm.agentConfig.stressMonitorSystemCpuReliefThreshold.label": "Seuil d'allègement de la tension du monitoring du CPU système", + "xpack.apm.agentConfig.stressMonitorSystemCpuStressThreshold.description": "Seuil utilisé par le monitoring du CPU du système pour détecter la tension du processeur du système. Si le CPU système dépasse ce seuil pour une durée d'au moins \"stress_monitor_cpu_duration_threshold\", le monitoring considère qu'il est en état de tension.", + "xpack.apm.agentConfig.stressMonitorSystemCpuStressThreshold.label": "Seuil de tension du monitoring du CPU système", + "xpack.apm.agentConfig.transactionIgnoreUrl.description": "Utilisé pour limiter l'instrumentation des requêtes vers certaines URL. Cette configuration accepte une liste séparée par des virgules de modèles de caractères génériques de chemins d'URL qui doivent être ignorés. Lorsqu'une requête HTTP entrante sera détectée, son chemin de requête sera confronté à chaque élément figurant dans cette liste. Par exemple, l'ajout de \"/home/index\" à cette liste permettrait de faire correspondre et de supprimer l'instrumentation de \"http://localhost/home/index\" ainsi que de \"http://whatever.com/home/index?value1=123\"", + "xpack.apm.agentConfig.transactionIgnoreUrl.label": "Ignorer les transactions basées sur les URL", + "xpack.apm.agentConfig.transactionMaxSpans.description": "Limite la quantité d'intervalles enregistrés par transaction.", + "xpack.apm.agentConfig.transactionMaxSpans.label": "Nb maxi d'intervalles de transaction", + "xpack.apm.agentConfig.transactionSampleRate.description": "Par défaut, l'agent échantillonnera chaque transaction (par ex. requête à votre service). Pour réduire la surcharge et les exigences de stockage, vous pouvez définir le taux d'échantillonnage sur une valeur comprise entre 0,0 et 1,0. La durée globale et le résultat des transactions non échantillonnées seront toujours enregistrés, mais pas les informations de contexte, les étiquettes ni les intervalles.", + "xpack.apm.agentConfig.transactionSampleRate.label": "Taux d'échantillonnage des transactions", + "xpack.apm.agentConfig.unsavedSetting.tooltip": "Non enregistré", + "xpack.apm.agentMetrics.java.gcRate": "Taux RM", + "xpack.apm.agentMetrics.java.gcRateChartTitle": "Récupération de mémoire par minute", + "xpack.apm.agentMetrics.java.gcTime": "Durée RM", + "xpack.apm.agentMetrics.java.gcTimeChartTitle": "Durée de récupération de mémoire par minute", + "xpack.apm.agentMetrics.java.heapMemoryChartTitle": "Segment de mémoire", + "xpack.apm.agentMetrics.java.heapMemorySeriesCommitted": "Moy. allouée", + "xpack.apm.agentMetrics.java.heapMemorySeriesMax": "Limite moy.", + "xpack.apm.agentMetrics.java.heapMemorySeriesUsed": "Moy. utilisée", + "xpack.apm.agentMetrics.java.nonHeapMemoryChartTitle": "Segment de mémoire sans tas", + "xpack.apm.agentMetrics.java.nonHeapMemorySeriesCommitted": "Moy. allouée", + "xpack.apm.agentMetrics.java.nonHeapMemorySeriesUsed": "Moy. utilisée", + "xpack.apm.agentMetrics.java.threadCount": "Nombre moy.", + "xpack.apm.agentMetrics.java.threadCountChartTitle": "Nombre de threads", + "xpack.apm.agentMetrics.java.threadCountMax": "Nombre max", + "xpack.apm.aggregatedTransactions.fallback.badge": "Basé sur les transactions échantillonnées", + "xpack.apm.aggregatedTransactions.fallback.tooltip": "Cette page utilise les données d'événements de transactions lorsqu'aucun événement d'indicateur n'a été trouvé dans la plage temporelle actuelle, ou lorsqu'un filtre a été appliqué en fonction des champs indisponibles dans les documents des événements d'indicateurs.", + "xpack.apm.alertAnnotationButtonAriaLabel": "Afficher les détails de l'alerte", + "xpack.apm.alertAnnotationCriticalTitle": "Alerte critique", + "xpack.apm.alertAnnotationNoSeverityTitle": "Alerte", + "xpack.apm.alertAnnotationWarningTitle": "Alerte d'avertissement", + "xpack.apm.alerting.fields.environment": "Environnement", + "xpack.apm.alerting.fields.service": "Service", + "xpack.apm.alerting.fields.type": "Type", + "xpack.apm.alerts.action_variables.environment": "Type de transaction pour lequel l'alerte est créée", + "xpack.apm.alerts.action_variables.intervalSize": "La longueur et l'unité de la période à laquelle les conditions de l'alerte ont été remplies", + "xpack.apm.alerts.action_variables.serviceName": "Service pour lequel l'alerte est créée", + "xpack.apm.alerts.action_variables.threshold": "Toute valeur de déclenchement dépassant cette valeur lancera l'alerte", + "xpack.apm.alerts.action_variables.transactionType": "Type de transaction pour lequel l'alerte est créée", + "xpack.apm.alerts.action_variables.triggerValue": "Valeur ayant dépassé le seuil et déclenché l'alerte", + "xpack.apm.alerts.anomalySeverity.criticalLabel": "critique", + "xpack.apm.alerts.anomalySeverity.majorLabel": "majeur", + "xpack.apm.alerts.anomalySeverity.minor": "mineur", + "xpack.apm.alerts.anomalySeverity.scoreDetailsDescription": "score {value} {value, select, critical {} other {et plus}}", + "xpack.apm.alerts.anomalySeverity.warningLabel": "avertissement", + "xpack.apm.alertTypes.errorCount.defaultActionMessage": "L'alerte \\{\\{alertName\\}\\} se déclenche en raison des conditions suivantes :\n\n- Nom de service : \\{\\{context.serviceName\\}\\}\n- Environnement : \\{\\{context.environment\\}\\}\n- Seuil : \\{\\{context.threshold\\}\\} erreurs\n- Valeur de déclenchement : \\{\\{context.triggerValue\\}\\} erreurs sur la dernière période de \\{\\{context.interval\\}\\}", + "xpack.apm.alertTypes.errorCount.description": "Alerte lorsque le nombre d'erreurs d'un service dépasse un seuil défini.", + "xpack.apm.alertTypes.transactionDuration.defaultActionMessage": "L'alerte \\{\\{alertName\\}\\} se déclenche en raison des conditions suivantes :\n\n- Nom de service : \\{\\{context.serviceName\\}\\}\n- Type : \\{\\{context.transactionType\\}\\}\n- Environnement : \\{\\{context.environment\\}\\}\n- Seuil de latence : \\{\\{context.threshold\\}\\} ms\n- Latence observée : \\{\\{context.triggerValue\\}\\} sur la dernière période de \\{\\{context.interval\\}\\}", + "xpack.apm.alertTypes.transactionDuration.description": "Alerte lorsque la latence d'un type de transaction spécifique dans un service dépasse le seuil défini.", + "xpack.apm.alertTypes.transactionDurationAnomaly.defaultActionMessage": "L'alerte \\{\\{alertName\\}\\} se déclenche en raison des conditions suivantes :\n\n- Nom de service : \\{\\{context.serviceName\\}\\}\n- Type : \\{\\{context.transactionType\\}\\}\n- Environnement : \\{\\{context.environment\\}\\}\n- Seuil de sévérité : \\{\\{context.threshold\\}\\}\n- Valeur de sévérité : \\{\\{context.triggerValue\\}\\}\n", + "xpack.apm.alertTypes.transactionDurationAnomaly.description": "Alerte lorsque la latence d'un service est anormale.", + "xpack.apm.alertTypes.transactionErrorRate.defaultActionMessage": "L'alerte \\{\\{alertName\\}\\} se déclenche en raison des conditions suivantes :\n\n- Nom de service : \\{\\{context.serviceName\\}\\}\n- Type : \\{\\{context.transactionType\\}\\}\n- Environnement : \\{\\{context.environment\\}\\}\n- Seuil : \\{\\{context.threshold\\}\\} %\n- Valeur de déclenchement : \\{\\{context.triggerValue\\}\\} % des erreurs sur la dernière période de \\{\\{context.interval\\}\\}", + "xpack.apm.alertTypes.transactionErrorRate.description": "Alerte lorsque le taux d'erreurs de transaction d'un service dépasse un seuil défini.", + "xpack.apm.analyzeDataButton.label": "Analyser les données", + "xpack.apm.analyzeDataButton.tooltip": "EXPÉRIMENTAL - La fonctionnalité Analyser les données vous permet de sélectionner et de filtrer les données de résultat dans toute dimension et de rechercher la cause ou l'impact des problèmes de performances", + "xpack.apm.anomaly_detection.error.invalid_license": "Pour utiliser la détection des anomalies, vous devez disposer d'une licence Elastic Platinum. Cette licence vous permet de monitorer vos services à l'aide du Machine Learning.", + "xpack.apm.anomaly_detection.error.missing_read_privileges": "Vous devez disposer des privilèges \"read\" (lecture) pour le Machine Learning et l'APM pour consulter les tâches de détection des anomalies", + "xpack.apm.anomaly_detection.error.missing_write_privileges": "Vous devez disposer des privilèges \"write\" (écriture) pour le Machine Learning et l'APM pour créer des tâches de détection des anomalies", + "xpack.apm.anomaly_detection.error.not_available": "Le Machine Learning est indisponible", + "xpack.apm.anomaly_detection.error.not_available_in_space": "Le Machine Learning est indisponible dans l'espace sélectionné", + "xpack.apm.anomalyDetection.createJobs.failed.text": "Une erreur est survenue lors de la création d'une ou de plusieurs tâches de détection des anomalies pour les environnements de service APM [{environments}]. Erreur : \"{errorMessage}\"", + "xpack.apm.anomalyDetection.createJobs.failed.title": "Les tâches de détection des anomalies n'ont pas pu être créées", + "xpack.apm.anomalyDetection.createJobs.succeeded.text": "Tâches de détection des anomalies créées avec succès pour les environnements de service APM [{environments}]. Le démarrage de l'analyse du trafic à la recherche d'anomalies par le Machine Learning va prendre un certain temps.", + "xpack.apm.anomalyDetection.createJobs.succeeded.title": "Tâches de détection des anomalies créées", + "xpack.apm.anomalyDetectionSetup.linkLabel": "Détection des anomalies", + "xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText": "La détection des anomalies n'est pas encore activée pour l'environnement \"{currentEnvironment}\". Cliquez pour continuer la configuration.", + "xpack.apm.anomalyDetectionSetup.notEnabledText": "La détection des anomalies n'est pas encore activée. Cliquez pour continuer la configuration.", + "xpack.apm.api.fleet.cloud_apm_package_policy.requiredRoleOnCloud": "Opération autorisée uniquement pour les utilisateurs Elastic Cloud disposant du rôle de superutilisateur.", + "xpack.apm.api.fleet.fleetSecurityRequired": "Les plug-ins Fleet et Security sont requis", + "xpack.apm.apmDescription": "Collecte automatiquement les indicateurs et les erreurs de performances détaillés depuis vos applications.", + "xpack.apm.apmSchema.index": "Schéma du serveur APM - Index", + "xpack.apm.apmSettings.index": "Paramètres APM - Index", + "xpack.apm.backendDetail.dependenciesTableColumnBackend": "Service", + "xpack.apm.backendDetail.dependenciesTableTitle": "Services en amont", + "xpack.apm.backendDetailFailedTransactionRateChartTitle": "Taux de transactions ayant échoué", + "xpack.apm.backendDetailLatencyChartTitle": "Latence", + "xpack.apm.backendDetailThroughputChartTitle": "Rendement", + "xpack.apm.backendErrorRateChart.chartTitle": "Taux de transactions ayant échoué", + "xpack.apm.backendErrorRateChart.previousPeriodLabel": "Période précédente", + "xpack.apm.backendLatencyChart.chartTitle": "Latence", + "xpack.apm.backendLatencyChart.previousPeriodLabel": "Période précédente", + "xpack.apm.backendThroughputChart.chartTitle": "Rendement", + "xpack.apm.backendThroughputChart.previousPeriodLabel": "Période précédente", + "xpack.apm.chart.annotation.version": "Version", + "xpack.apm.chart.cpuSeries.processAverageLabel": "Moyenne de processus", + "xpack.apm.chart.cpuSeries.processMaxLabel": "Max de processus", + "xpack.apm.chart.cpuSeries.systemAverageLabel": "Moyenne du système", + "xpack.apm.chart.cpuSeries.systemMaxLabel": "Max du système", + "xpack.apm.chart.error": "Une erreur est survenue lors de la tentative de récupération des données. Réessayez plus tard", + "xpack.apm.chart.memorySeries.systemAverageLabel": "Moyenne", + "xpack.apm.chart.memorySeries.systemMaxLabel": "Max", + "xpack.apm.compositeSpanCallsLabel": ", {count} appels, sur une moyenne de {duration}", + "xpack.apm.compositeSpanDurationLabel": "Durée moyenne", + "xpack.apm.correlations.correlationsTable.excludeDescription": "Filtrer la valeur", + "xpack.apm.correlations.correlationsTable.excludeLabel": "Exclure", + "xpack.apm.correlations.correlationsTable.filterDescription": "Filtrer par valeur", + "xpack.apm.correlations.correlationsTable.filterLabel": "Filtre", + "xpack.apm.correlations.correlationsTable.loadingText": "Chargement", + "xpack.apm.correlations.correlationsTable.noDataText": "Aucune donnée", + "xpack.apm.correlations.failedTransactions.correlationsTable.fieldNameLabel": "Nom du champ", + "xpack.apm.correlations.failedTransactions.correlationsTable.fieldValueLabel": "Valeur du champ", + "xpack.apm.correlations.failedTransactions.correlationsTable.impactLabel": "Impact", + "xpack.apm.correlations.failedTransactions.correlationsTable.pValueLabel": "Score", + "xpack.apm.correlations.failedTransactions.errorTitle": "Une erreur est survenue lors de l'exécution de corrélations sur les transactions ayant échoué", + "xpack.apm.correlations.failedTransactions.highImpactText": "Élevé", + "xpack.apm.correlations.failedTransactions.lowImpactText": "Bas", + "xpack.apm.correlations.failedTransactions.mediumImpactText": "Moyen", + "xpack.apm.correlations.failedTransactions.panelTitle": "Transactions ayant échoué", + "xpack.apm.correlations.latencyCorrelations.correlationsTable.actionsLabel": "Filtre", + "xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationColumnDescription": "Score de corrélation [0-1] d'un attribut ; plus le score est élevé, plus un attribut augmente la latence.", + "xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationLabel": "Corrélation", + "xpack.apm.correlations.latencyCorrelations.correlationsTable.excludeDescription": "Filtrer la valeur", + "xpack.apm.correlations.latencyCorrelations.correlationsTable.excludeLabel": "Exclure", + "xpack.apm.correlations.latencyCorrelations.correlationsTable.fieldNameLabel": "Nom du champ", + "xpack.apm.correlations.latencyCorrelations.correlationsTable.fieldValueLabel": "Valeur du champ", + "xpack.apm.correlations.latencyCorrelations.correlationsTable.filterDescription": "Filtrer par valeur", + "xpack.apm.correlations.latencyCorrelations.correlationsTable.filterLabel": "Filtre", + "xpack.apm.correlations.latencyCorrelations.errorTitle": "Une erreur est survenue lors de la récupération des corrélations", + "xpack.apm.correlations.latencyCorrelations.panelTitle": "Distribution de la latence", + "xpack.apm.correlations.latencyCorrelations.tableTitle": "Corrélations", + "xpack.apm.correlations.latencyPopoverBasicExplanation": "Les corrélations vous aident à découvrir quels attributs contribuent à l'augmentation des temps de réponse des transactions ou de la latence.", + "xpack.apm.correlations.latencyPopoverChartExplanation": "Le graphique de distribution de la latence permet de visualiser la latence globale des transactions dans le service. Lorsque vous passez votre souris sur des attributs du tableau, leur distribution de latence est ajoutée au graphique.", + "xpack.apm.correlations.latencyPopoverFilterExplanation": "Vous pouvez également ajouter ou retirer des filtres pour modifier les requêtes dans l'application APM.", + "xpack.apm.correlations.latencyPopoverPerformanceExplanation": "Cette analyse réalise des recherches statistiques sur un grand nombre d'attributs. Pour les plages temporelles étendues et les services ayant un rendement de transactions élevé, cela peut prendre un certain temps. Réduisez la plage temporelle pour améliorer les performances.", + "xpack.apm.correlations.latencyPopoverTableExplanation": "Le tableau est trié par coefficient de corrélation, de 0 à 1. Les attributs ayant des valeurs de corrélation plus élevées sont plus susceptibles de contribuer à des transactions à haute latence.", + "xpack.apm.correlations.latencyPopoverTitle": "Corrélations de latence", + "xpack.apm.customLink.buttom.create": "Créer un lien personnalisé", + "xpack.apm.customLink.buttom.create.title": "Créer", + "xpack.apm.customLink.buttom.manage": "Gérer des liens personnalisés", + "xpack.apm.customLink.empty": "Aucun lien personnalisé trouvé. Configurez vos propres liens personnalisés, par ex. un lien vers un tableau de bord spécifique ou un lien externe.", + "xpack.apm.dependenciesTable.columnErrorRate": "Taux de transactions ayant échoué", + "xpack.apm.dependenciesTable.columnImpact": "Impact", + "xpack.apm.dependenciesTable.columnLatency": "Latence (moy.)", + "xpack.apm.dependenciesTable.columnThroughput": "Rendement", + "xpack.apm.dependenciesTable.serviceMapLinkText": "Afficher la carte des services", + "xpack.apm.emptyMessage.noDataFoundDescription": "Essayez avec une autre plage temporelle ou réinitialisez le filtre de recherche.", + "xpack.apm.emptyMessage.noDataFoundLabel": "Aucune donnée trouvée.", + "xpack.apm.error.prompt.body": "Veuillez consulter la console de développeur de votre navigateur pour plus de détails.", + "xpack.apm.error.prompt.title": "Désolé, une erreur s'est produite :(", + "xpack.apm.errorCountAlert.name": "Seuil de nombre d'erreurs", + "xpack.apm.errorCountAlertTrigger.errors": " erreurs", + "xpack.apm.errorGroupDetails.culpritLabel": "Coupable", + "xpack.apm.errorGroupDetails.errorGroupTitle": "Groupe d'erreurs {errorGroupId}", + "xpack.apm.errorGroupDetails.errorOccurrenceTitle": "Occurrence d'erreur", + "xpack.apm.errorGroupDetails.exceptionMessageLabel": "Message d'exception", + "xpack.apm.errorGroupDetails.logMessageLabel": "Message log", + "xpack.apm.errorGroupDetails.occurrencesChartLabel": "Occurrences", + "xpack.apm.errorGroupDetails.relatedTransactionSample": "Échantillon de transaction associée", + "xpack.apm.errorGroupDetails.unhandledLabel": "Non géré", + "xpack.apm.errorGroupDetails.viewOccurrencesInDiscoverButtonLabel": "Visualisez {occurrencesCount} {occurrencesCount, plural, one {occurrence} other {occurrences}} dans Discover.", + "xpack.apm.errorRate": "Taux de transactions ayant échoué", + "xpack.apm.errorRate.chart.errorRate": "Taux de transactions ayant échoué (moy.)", + "xpack.apm.errorRate.chart.errorRate.previousPeriodLabel": "Période précédente", + "xpack.apm.errorsTable.errorMessageAndCulpritColumnLabel": "Message d'erreur et coupable", + "xpack.apm.errorsTable.groupIdColumnDescription": "Hachage de la trace de pile. Regroupe les erreurs similaires, même lorsque le message d'erreur est différent en raison des paramètres dynamiques.", + "xpack.apm.errorsTable.groupIdColumnLabel": "ID du groupe", + "xpack.apm.errorsTable.noErrorsLabel": "Aucune erreur n'a été trouvée", + "xpack.apm.errorsTable.occurrencesColumnLabel": "Occurrences", + "xpack.apm.errorsTable.typeColumnLabel": "Type", + "xpack.apm.errorsTable.unhandledLabel": "Non géré", + "xpack.apm.failedTransactionsCorrelations.licenseCheckText": "Pour utiliser la fonctionnalité de corrélation des transactions ayant échoué, vous devez disposer d'une licence Elastic Platinum.", + "xpack.apm.featureRegistry.apmFeatureName": "APM et expérience utilisateur", + "xpack.apm.feedbackMenu.appName": "APM", + "xpack.apm.fetcher.error.status": "Erreur", + "xpack.apm.fetcher.error.title": "Erreur lors de la récupération des ressources", + "xpack.apm.fetcher.error.url": "URL", + "xpack.apm.filter.environment.allLabel": "Tous", + "xpack.apm.filter.environment.label": "Environnement", + "xpack.apm.filter.environment.notDefinedLabel": "Non défini", + "xpack.apm.filter.environment.selectEnvironmentLabel": "Sélectionner l'environnement", + "xpack.apm.fleet_integration.settings.advancedOptionsLavel": "Options avancées", + "xpack.apm.fleet_integration.settings.apm.capturePersonalDataDescription": "Capturer des données personnelles, telles que l'IP ou l'agent utilisateur", + "xpack.apm.fleet_integration.settings.apm.capturePersonalDataTitle": "Capture des données personnelles", + "xpack.apm.fleet_integration.settings.apm.defaultServiceEnvironmentDescription": "Environnement de service par défaut pour l'enregistrement des événements n'ayant aucun environnement de service défini.", + "xpack.apm.fleet_integration.settings.apm.defaultServiceEnvironmentLabel": "Environnement de service par défaut", + "xpack.apm.fleet_integration.settings.apm.defaultServiceEnvironmentTitle": "Configuration de service", + "xpack.apm.fleet_integration.settings.apm.expvarEnabledDescription": "Exposé sous /debug/vars", + "xpack.apm.fleet_integration.settings.apm.expvarEnabledTitle": "Activer la prise en charge d'expvar de Golang pour le serveur APM", + "xpack.apm.fleet_integration.settings.apm.hostDescription": "Choisissez un nom et une description pour identifier facilement le type d'utilisation de cette intégration.", + "xpack.apm.fleet_integration.settings.apm.hostLabel": "Hôte", + "xpack.apm.fleet_integration.settings.apm.hostTitle": "Configuration du serveur", + "xpack.apm.fleet_integration.settings.apm.idleTimeoutLabel": "Temps d'inactivité avant la fermeture de la connexion sous-jacente", + "xpack.apm.fleet_integration.settings.apm.maxConnectionsLabel": "Connexions acceptées simultanément", + "xpack.apm.fleet_integration.settings.apm.maxEventBytesLabel": "Taille maximale par événement (octets)", + "xpack.apm.fleet_integration.settings.apm.maxHeaderBytesDescription": "Définissez des limites pour la taille des en-têtes de requêtes et les configurations de temporisation.", + "xpack.apm.fleet_integration.settings.apm.maxHeaderBytesLabel": "Taille maximale de l'en-tête d'une requête (octets)", + "xpack.apm.fleet_integration.settings.apm.maxHeaderBytesTitle": "Limites", + "xpack.apm.fleet_integration.settings.apm.readTimeoutLabel": "Durée maximale pour la lecture d'une requête intégrale", + "xpack.apm.fleet_integration.settings.apm.responseHeadersDescription": "Définissez des limites pour la taille des en-têtes de requêtes et les configurations de temporisation.", + "xpack.apm.fleet_integration.settings.apm.responseHeadersHelpText": "Peut être utilisé pour la conformité à la politique de sécurité.", + "xpack.apm.fleet_integration.settings.apm.responseHeadersLabel": "En-têtes HTTP personnalisés ajoutés aux réponses HTTP", + "xpack.apm.fleet_integration.settings.apm.responseHeadersTitle": "En-têtes personnalisés", + "xpack.apm.fleet_integration.settings.apm.settings.subtitle": "Paramètres de l'intégration APM.", + "xpack.apm.fleet_integration.settings.apm.settings.title": "Général", + "xpack.apm.fleet_integration.settings.apm.shutdownTimeoutLabel": "Durée maximale avant la libération des ressources lors de l'arrêt", + "xpack.apm.fleet_integration.settings.apm.urlLabel": "URL", + "xpack.apm.fleet_integration.settings.apm.writeTimeoutLabel": "Durée maximale pour la rédaction d'une réponse", + "xpack.apm.fleet_integration.settings.apmAgent.description": "Configurez l'instrumentation pour les applications {title}.", + "xpack.apm.fleet_integration.settings.disabledLabel": "Désactivé", + "xpack.apm.fleet_integration.settings.enabledLabel": "Activé", + "xpack.apm.fleet_integration.settings.optionalLabel": "Facultatif", + "xpack.apm.fleet_integration.settings.requiredFieldLabel": "Champ requis", + "xpack.apm.fleet_integration.settings.requiredLabel": "Requis", + "xpack.apm.fleet_integration.settings.rum.enableRumDescription": "Activer le monitoring des utilisateurs réels (RUM)", + "xpack.apm.fleet_integration.settings.rum.enableRumTitle": "Activer RUM", + "xpack.apm.fleet_integration.settings.rum.rumAllowHeaderDescription": "Configurer l'authentification pour l'agent", + "xpack.apm.fleet_integration.settings.rum.rumAllowHeaderHelpText": "En-têtes Origin autorisés pouvant être envoyés par les agents utilisateurs.", + "xpack.apm.fleet_integration.settings.rum.rumAllowHeaderLabel": "En-têtes Origin autorisés", + "xpack.apm.fleet_integration.settings.rum.rumAllowHeaderTitle": "En-têtes personnalisés", + "xpack.apm.fleet_integration.settings.rum.rumAllowOriginsHelpText": "Access-Control-Allow-Headers pris en charge en plus de \"Content-Type\", \"Content-Encoding\" et \"Accept\".", + "xpack.apm.fleet_integration.settings.rum.rumAllowOriginsLabel": "Access-Control-Allow-Headers", + "xpack.apm.fleet_integration.settings.rum.rumLibraryPatternHelpText": "Identifiez les cadres de la bibliothèque en faisant correspondre le file_name et le abs_path du cadre de la trace de pile avec ce regexp.", + "xpack.apm.fleet_integration.settings.rum.rumLibraryPatternLabel": "Modèle du cadre de la bibliothèque", + "xpack.apm.fleet_integration.settings.rum.rumResponseHeadersHelpText": "Ajouté aux réponses RUM, par ex. à des fins de conformité à la politique de sécurité.", + "xpack.apm.fleet_integration.settings.rum.rumResponseHeadersLabel": "En-têtes de réponse HTTP personnalisés", + "xpack.apm.fleet_integration.settings.rum.settings.subtitle": "Gérez la configuration de l'agent RUM JS.", + "xpack.apm.fleet_integration.settings.rum.settings.title": "Real User Monitoring (monitoring des utilisateurs réels)", + "xpack.apm.fleet_integration.settings.selectOrCreateOptions": "Sélectionner ou créer des options", + "xpack.apm.fleet_integration.settings.tls.settings.subtitle": "Paramètres pour la certification TLS.", + "xpack.apm.fleet_integration.settings.tls.settings.title": "Paramètres TLS", + "xpack.apm.fleet_integration.settings.tls.tlsCertificateLabel": "Chemin d'accès au certificat du serveur", + "xpack.apm.fleet_integration.settings.tls.tlsCertificateTitle": "Certificat TLS", + "xpack.apm.fleet_integration.settings.tls.tlsCipherSuitesHelpText": "Ne peut pas être configuré pour TLS 1.3.", + "xpack.apm.fleet_integration.settings.tls.tlsCipherSuitesLabel": "Suites de chiffrement pour les connexions TLS", + "xpack.apm.fleet_integration.settings.tls.tlsCurveTypesLabel": "Types de courbes pour les suites de chiffrement ECDHE", + "xpack.apm.fleet_integration.settings.tls.tlsEnabledTitle": "Activer TLS", + "xpack.apm.fleet_integration.settings.tls.tlsKeyLabel": "Chemin d'accès à la clé de certificat du serveur", + "xpack.apm.fleet_integration.settings.tls.tlsSupportedProtocolsLabel": "Versions de protocoles prises en charge", + "xpack.apm.fleetIntegration.assets.description": "Consulter les traces de l'application et les cartes de service dans APM", + "xpack.apm.fleetIntegration.assets.name": "Services", + "xpack.apm.fleetIntegration.enrollmentFlyout.installApmAgentButtonText": "Installer l'agent APM", + "xpack.apm.fleetIntegration.enrollmentFlyout.installApmAgentDescription": "Une fois l'agent lancé, vous pouvez installer des agents APM sur vos hôtes pour collecter des données depuis vos applications et services.", + "xpack.apm.fleetIntegration.enrollmentFlyout.installApmAgentTitle": "Installer l'agent APM", + "xpack.apm.formatters.hoursTimeUnitLabel": "h", + "xpack.apm.formatters.microsTimeUnitLabel": "μs", + "xpack.apm.formatters.millisTimeUnitLabel": "ms", + "xpack.apm.formatters.minutesTimeUnitLabel": "min", + "xpack.apm.formatters.secondsTimeUnitLabel": "s", + "xpack.apm.header.badge.readOnly.text": "Lecture seule", + "xpack.apm.header.badge.readOnly.tooltip": "Enregistrement impossible", + "xpack.apm.helpMenu.upgradeAssistantLink": "Assistant de mise à niveau", + "xpack.apm.helpPopover.ariaLabel": "Aide", + "xpack.apm.home.alertsMenu.alerts": "Alertes et règles", + "xpack.apm.home.alertsMenu.createAnomalyAlert": "Créer une règle d'anomalie", + "xpack.apm.home.alertsMenu.createThresholdAlert": "Créer une règle de seuil", + "xpack.apm.home.alertsMenu.errorCount": "Nombre d'erreurs", + "xpack.apm.home.alertsMenu.transactionDuration": "Latence", + "xpack.apm.home.alertsMenu.transactionErrorRate": "Taux de transactions ayant échoué", + "xpack.apm.home.alertsMenu.viewActiveAlerts": "Gérer les règles", + "xpack.apm.home.serviceLogsTabLabel": "Logs", + "xpack.apm.home.serviceMapTabLabel": "Carte des services", + "xpack.apm.instancesLatencyDistributionChartLegend": "Instances", + "xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "Période précédente", + "xpack.apm.instancesLatencyDistributionChartTitle": "Distribution de la latence des instances", + "xpack.apm.instancesLatencyDistributionChartTooltipClickToFilterDescription": "Cliquer pour filtrer par instance", + "xpack.apm.instancesLatencyDistributionChartTooltipInstancesTitle": "{instancesCount} {instancesCount, plural, one {instance} other {instances}}", + "xpack.apm.instancesLatencyDistributionChartTooltipLatencyLabel": "Latence", + "xpack.apm.instancesLatencyDistributionChartTooltipThroughputLabel": "Rendement", + "xpack.apm.invalidLicense.licenseManagementLink": "Gérer votre licence", + "xpack.apm.invalidLicense.message": "L'interface utilisateur d'APM n'est pas disponible car votre licence actuelle a expiré ou n'est plus valide.", + "xpack.apm.invalidLicense.title": "Licence non valide", + "xpack.apm.jvmsTable.cpuColumnLabel": "Moy. CPU", + "xpack.apm.jvmsTable.explainServiceNodeNameMissing": "Nous n'avons pas pu déterminer à quelles JVM ces indicateurs correspondent. Cela provient probablement du fait que vous exécutez une version du serveur APM antérieure à 7.5. La mise à niveau du serveur APM vers la version 7.5 ou supérieure devrait résoudre le problème.", + "xpack.apm.jvmsTable.heapMemoryColumnLabel": "Moy. segment de mémoire", + "xpack.apm.jvmsTable.nameColumnLabel": "Nom", + "xpack.apm.jvmsTable.nameExplanation": "Par défaut, le nom de la JVM est l'ID du conteneur (le cas échéant) ou le nom d'hôte. Vous pouvez néanmoins le configurer manuellement via la configuration \"service_node_name\" de l'agent.", + "xpack.apm.jvmsTable.noJvmsLabel": "Aucune JVM n'a été trouvée", + "xpack.apm.jvmsTable.nonHeapMemoryColumnLabel": "Moy. segment de mémoire sans tas", + "xpack.apm.jvmsTable.threadCountColumnLabel": "Nombre de threads max", + "xpack.apm.keyValueFilterList.actionFilterLabel": "Filtrer par valeur", + "xpack.apm.kueryBar.placeholder": "Rechercher {event, select,\n transaction {des transactions}\n metric {des indicateurs}\n error {des erreurs}\n other {des transactions, des erreurs et des indicateurs}\n } (par ex. {queryExample})", + "xpack.apm.latencyCorrelations.licenseCheckText": "Pour utiliser les corrélations de latence, vous devez disposer d'une licence Elastic Platinum. Elle vous permettra de découvrir quels champs sont corrélés à de faibles performances.", + "xpack.apm.license.betaBadge": "Version bêta", + "xpack.apm.license.betaTooltipMessage": "Cette fonctionnalité est actuellement en version bêta. Si vous rencontrez des bugs ou si vous souhaitez apporter des commentaires, ouvrez un ticket de problème ou visitez notre forum de discussion.", + "xpack.apm.license.button": "Commencer l'essai", + "xpack.apm.license.title": "Commencer un essai gratuit de 30 jours", + "xpack.apm.localFilters.titles.browser": "Navigateur", + "xpack.apm.localFilters.titles.device": "Appareil", + "xpack.apm.localFilters.titles.location": "Lieu", + "xpack.apm.localFilters.titles.os": "Système d'exploitation", + "xpack.apm.localFilters.titles.serviceName": "Nom de service", + "xpack.apm.localFilters.titles.transactionUrl": "URL", + "xpack.apm.metrics.transactionChart.machineLearningLabel": "Machine Learning :", + "xpack.apm.metrics.transactionChart.machineLearningTooltip": "Le flux affiche les limites attendues de la latence moyenne. Une annotation verticale rouge signale des anomalies avec un score d'anomalie de 75 ou plus.", + "xpack.apm.metrics.transactionChart.machineLearningTooltip.withKuery": "Les résultats de Machine Learning sont masqués lorsque la barre de recherche est utilisée comme filtre", + "xpack.apm.metrics.transactionChart.viewJob": "Afficher la tâche", + "xpack.apm.navigation.serviceMapTitle": "Carte des services", + "xpack.apm.navigation.servicesTitle": "Services", + "xpack.apm.navigation.tracesTitle": "Traces", + "xpack.apm.notAvailableLabel": "N/A", + "xpack.apm.percentOfParent": "({value} de {parentType, select, transaction { transaction } trace {trace} })", + "xpack.apm.profiling.collapseSimilarFrames": "Réduire les éléments similaires", + "xpack.apm.profiling.highlightFrames": "Rechercher", + "xpack.apm.profiling.table.name": "Nom", + "xpack.apm.profiling.table.value": "Auto", + "xpack.apm.propertiesTable.agentFeature.noDataAvailableLabel": "Pas de données disponibles", + "xpack.apm.propertiesTable.agentFeature.noResultFound": "Pas de résultats pour \"{value}\".", + "xpack.apm.propertiesTable.tabs.exceptionStacktraceLabel": "Trace de pile d'exception", + "xpack.apm.propertiesTable.tabs.logs.serviceName": "Nom de service", + "xpack.apm.propertiesTable.tabs.logsLabel": "Logs", + "xpack.apm.propertiesTable.tabs.logStacktraceLabel": "Trace de pile des logs", + "xpack.apm.propertiesTable.tabs.metadataLabel": "Métadonnées", + "xpack.apm.propertiesTable.tabs.timelineLabel": "Chronologie", + "xpack.apm.searchInput.filter": "Filtrer…", + "xpack.apm.selectPlaceholder": "Sélectionner une option :", + "xpack.apm.serviceDependencies.breakdownChartTitle": "Temps consacré par dépendance", + "xpack.apm.serviceDetails.dependenciesTabLabel": "Dépendances", + "xpack.apm.serviceDetails.errorsTabLabel": "Erreurs", + "xpack.apm.serviceDetails.metrics.cpuUsageChartTitle": "Utilisation CPU", + "xpack.apm.serviceDetails.metrics.errorOccurrencesChart.title": "Occurrences d'erreurs", + "xpack.apm.serviceDetails.metrics.errorsList.title": "Erreurs", + "xpack.apm.serviceDetails.metrics.memoryUsageChartTitle": "Utilisation mémoire système", + "xpack.apm.serviceDetails.metricsTabLabel": "Indicateurs", + "xpack.apm.serviceDetails.nodesTabLabel": "JVM", + "xpack.apm.serviceDetails.overviewTabLabel": "Aperçu", + "xpack.apm.serviceDetails.profilingTabExperimentalDescription": "Le profilage est à un stade hautement expérimental, dédié uniquement à une utilisation interne.", + "xpack.apm.serviceDetails.profilingTabExperimentalLabel": "Expérimental", + "xpack.apm.serviceDetails.profilingTabLabel": "Profilage", + "xpack.apm.serviceDetails.transactionsTabLabel": "Transactions", + "xpack.apm.serviceHealthStatus.critical": "Critique", + "xpack.apm.serviceHealthStatus.healthy": "Intègre", + "xpack.apm.serviceHealthStatus.unknown": "Inconnu", + "xpack.apm.serviceHealthStatus.warning": "Avertissement", + "xpack.apm.serviceIcons.cloud": "Cloud", + "xpack.apm.serviceIcons.container": "Conteneur", + "xpack.apm.serviceIcons.service": "Service", + "xpack.apm.serviceIcons.serviceDetails.cloud.availabilityZoneLabel": "{zones, plural, =0 {Zone de disponibilité} one {Zone de disponibilité} other {Zones de disponibilité}} ", + "xpack.apm.serviceIcons.serviceDetails.cloud.machineTypesLabel": "{machineTypes, plural, =0{Type de machine} one {Type de machine} other {Types de machines}} ", + "xpack.apm.serviceIcons.serviceDetails.cloud.projectIdLabel": "ID projet", + "xpack.apm.serviceIcons.serviceDetails.cloud.providerLabel": "Fournisseur cloud", + "xpack.apm.serviceIcons.serviceDetails.container.containerizedLabel": "Conteneurisé", + "xpack.apm.serviceIcons.serviceDetails.container.noLabel": "Non", + "xpack.apm.serviceIcons.serviceDetails.container.orchestrationLabel": "Orchestration", + "xpack.apm.serviceIcons.serviceDetails.container.osLabel": "Système d'exploitation", + "xpack.apm.serviceIcons.serviceDetails.container.totalNumberInstancesLabel": "Nombre total d'instances", + "xpack.apm.serviceIcons.serviceDetails.container.yesLabel": "Oui", + "xpack.apm.serviceIcons.serviceDetails.service.agentLabel": "Nom et version de l'agent", + "xpack.apm.serviceIcons.serviceDetails.service.frameworkLabel": "Nom du framework", + "xpack.apm.serviceIcons.serviceDetails.service.runtimeLabel": "Nom et version de l'exécution", + "xpack.apm.serviceIcons.serviceDetails.service.versionLabel": "Version du service", + "xpack.apm.serviceLogs.noInfrastructureMessage": "Il n'y a aucun message log à afficher.", + "xpack.apm.serviceMap.anomalyDetectionPopoverDisabled": "Affichez les indicateurs d'intégrité du service en activant la détection des anomalies dans les paramètres APM.", + "xpack.apm.serviceMap.anomalyDetectionPopoverLink": "Afficher les anomalies", + "xpack.apm.serviceMap.anomalyDetectionPopoverNoData": "Nous n'avons pas trouvé de score d'anomalie dans la plage temporelle sélectionnée. Consultez les détails dans l'explorateur d'anomalies.", + "xpack.apm.serviceMap.anomalyDetectionPopoverScoreMetric": "Score (max.)", + "xpack.apm.serviceMap.anomalyDetectionPopoverTitle": "Détection des anomalies", + "xpack.apm.serviceMap.anomalyDetectionPopoverTooltip": "Les indicateurs d'intégrité du service sont soutenus par la fonctionnalité de détection des anomalies dans le Machine Learning", + "xpack.apm.serviceMap.avgCpuUsagePopoverStat": "Utilisation CPU (moy.)", + "xpack.apm.serviceMap.avgMemoryUsagePopoverStat": "Utilisation de la mémoire (moy.)", + "xpack.apm.serviceMap.avgReqPerMinutePopoverMetric": "Rendement (moy.)", + "xpack.apm.serviceMap.avgTransDurationPopoverStat": "Latence (moy.)", + "xpack.apm.serviceMap.center": "Centre", + "xpack.apm.serviceMap.download": "Télécharger", + "xpack.apm.serviceMap.emptyBanner.docsLink": "En savoir plus dans la documentation", + "xpack.apm.serviceMap.emptyBanner.message": "Nous démapperons les services connectés et les requêtes externes si nous parvenons à les détecter. Assurez-vous d'exécuter la dernière version de l'agent APM.", + "xpack.apm.serviceMap.emptyBanner.title": "Il semblerait qu'il n'y ait qu'un seul service.", + "xpack.apm.serviceMap.errorRatePopoverStat": "Taux de transactions ayant échoué (moy.)", + "xpack.apm.serviceMap.focusMapButtonText": "Centrer la carte", + "xpack.apm.serviceMap.invalidLicenseMessage": "Pour accéder aux cartes de service, vous devez disposer d'une licence Elastic Platinum. Elle vous permettra de visualiser l'intégralité de la suite d'applications ainsi que vos données APM.", + "xpack.apm.serviceMap.noServicesPromptDescription": "Nous ne parvenons pas à trouver des services à mapper dans la plage temporelle et l'environnement actuellement sélectionnés. Veuillez essayer une autre plage ou vérifier l'environnement sélectionné. Si vous ne disposez d'aucun service, utilisez nos instructions de configuration pour vous aider à vous lancer.", + "xpack.apm.serviceMap.noServicesPromptTitle": "Aucun service disponible", + "xpack.apm.serviceMap.popover.noDataText": "Aucune donnée pour l'environnement sélectionné. Essayez de passer à un autre environnement.", + "xpack.apm.serviceMap.resourceCountLabel": "{count} ressources", + "xpack.apm.serviceMap.serviceDetailsButtonText": "Détails du service", + "xpack.apm.serviceMap.subtypePopoverStat": "Sous-type", + "xpack.apm.serviceMap.timeoutPrompt.docsLink": "En savoir plus sur les paramètres APM dans la documentation", + "xpack.apm.serviceMap.timeoutPromptDescription": "Délai expiré lors de la récupération des données pour la carte de services. Limitez la portée en sélectionnant une plage temporelle plus restreinte, ou utilisez le paramètre de configuration \"{configName}\" avec une valeur réduite.", + "xpack.apm.serviceMap.timeoutPromptTitle": "Expiration de la carte de services", + "xpack.apm.serviceMap.typePopoverStat": "Type", + "xpack.apm.serviceMap.viewFullMap": "Afficher la carte de services entière", + "xpack.apm.serviceMap.zoomIn": "Zoom avant", + "xpack.apm.serviceMap.zoomOut": "Zoom arrière", + "xpack.apm.serviceNodeMetrics.containerId": "ID conteneur", + "xpack.apm.serviceNodeMetrics.host": "Hôte", + "xpack.apm.serviceNodeMetrics.serviceName": "Nom de service", + "xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningDocumentationLink": "documentation du serveur APM", + "xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningText": "Nous n'avons pas pu déterminer à quelles JVM ces indicateurs correspondent. Cela provient probablement du fait que vous exécutez une version du serveur APM antérieure à 7.5. La mise à niveau du serveur APM vers la version 7.5 ou supérieure devrait résoudre le problème. Pour plus d'informations sur la mise à niveau, consultez {link}. Vous pouvez également utiliser la barre de recherche de Kibana pour filtrer par nom d'hôte, par ID de conteneur ou en fonction d'autres champs.", + "xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningTitle": "Impossible d'identifier les JVM", + "xpack.apm.serviceNodeNameMissing": "(vide)", + "xpack.apm.serviceOverview.dependenciesTableTabLink": "Afficher les dépendances", + "xpack.apm.serviceOverview.dependenciesTableTitle": "Services en aval et back-ends", + "xpack.apm.serviceOverview.errorsTableColumnLastSeen": "Vu en dernier", + "xpack.apm.serviceOverview.errorsTableColumnName": "Nom", + "xpack.apm.serviceOverview.errorsTableColumnOccurrences": "Occurrences", + "xpack.apm.serviceOverview.errorsTableLinkText": "Afficher les erreurs", + "xpack.apm.serviceOverview.errorsTableTitle": "Erreurs", + "xpack.apm.serviceOverview.instancesTable.actionMenus.container.subtitle": "Affichez les logs et les indicateurs de ce conteneur pour plus de détails.", + "xpack.apm.serviceOverview.instancesTable.actionMenus.container.title": "Détails du conteneur", + "xpack.apm.serviceOverview.instancesTable.actionMenus.containerLogs": "Logs du conteneur", + "xpack.apm.serviceOverview.instancesTable.actionMenus.containerMetrics": "Indicateurs du conteneur", + "xpack.apm.serviceOverview.instancesTable.actionMenus.filterByInstance": "Filtrer l'aperçu par instance", + "xpack.apm.serviceOverview.instancesTable.actionMenus.metrics": "Indicateurs", + "xpack.apm.serviceOverview.instancesTable.actionMenus.pod.subtitle": "Affichez les logs et indicateurs de ce pod pour plus de détails.", + "xpack.apm.serviceOverview.instancesTable.actionMenus.pod.title": "Détails du pod", + "xpack.apm.serviceOverview.instancesTable.actionMenus.podLogs": "Logs du pod", + "xpack.apm.serviceOverview.instancesTable.actionMenus.podMetrics": "Indicateurs du pod", + "xpack.apm.serviceOverview.instancesTableColumnCpuUsage": "Utilisation CPU (moy.)", + "xpack.apm.serviceOverview.instancesTableColumnErrorRate": "Taux de transactions ayant échoué", + "xpack.apm.serviceOverview.instancesTableColumnMemoryUsage": "Utilisation de la mémoire (moy.)", + "xpack.apm.serviceOverview.instancesTableColumnNodeName": "Nom du nœud", + "xpack.apm.serviceOverview.instancesTableColumnThroughput": "Rendement", + "xpack.apm.serviceOverview.instancesTableTitle": "Instances", + "xpack.apm.serviceOverview.instanceTable.details.cloudTitle": "Cloud", + "xpack.apm.serviceOverview.instanceTable.details.containerTitle": "Conteneur", + "xpack.apm.serviceOverview.instanceTable.details.serviceTitle": "Service", + "xpack.apm.serviceOverview.latencyChartTitle": "Latence", + "xpack.apm.serviceOverview.latencyChartTitle.prepend": "Indicateur", + "xpack.apm.serviceOverview.latencyChartTitle.previousPeriodLabel": "Période précédente", + "xpack.apm.serviceOverview.latencyColumnAvgLabel": "Latence (moy.)", + "xpack.apm.serviceOverview.latencyColumnDefaultLabel": "Latence", + "xpack.apm.serviceOverview.latencyColumnP95Label": "Latence (95e)", + "xpack.apm.serviceOverview.latencyColumnP99Label": "Latence (99e)", + "xpack.apm.serviceOverview.throughtputChart.previousPeriodLabel": "Période précédente", + "xpack.apm.serviceOverview.throughtputChartTitle": "Rendement", + "xpack.apm.serviceOverview.tpmHelp": "Le rendement est mesuré en transactions par minute (tpm)", + "xpack.apm.serviceOverview.transactionsTableColumnErrorRate": "Taux de transactions ayant échoué", + "xpack.apm.serviceOverview.transactionsTableColumnImpact": "Impact", + "xpack.apm.serviceOverview.transactionsTableColumnName": "Nom", + "xpack.apm.serviceOverview.transactionsTableColumnThroughput": "Rendement", + "xpack.apm.serviceProfiling.valueTypeLabel.allocObjects": "Objets alloués", + "xpack.apm.serviceProfiling.valueTypeLabel.allocSpace": "Espace alloué", + "xpack.apm.serviceProfiling.valueTypeLabel.cpuTime": "Sur CPU", + "xpack.apm.serviceProfiling.valueTypeLabel.inuseObjects": "Objets utilisés", + "xpack.apm.serviceProfiling.valueTypeLabel.inuseSpace": "Espace utilisé", + "xpack.apm.serviceProfiling.valueTypeLabel.samples": "Échantillons", + "xpack.apm.serviceProfiling.valueTypeLabel.unknown": "Autre", + "xpack.apm.serviceProfiling.valueTypeLabel.wallTime": "Mur", + "xpack.apm.servicesTable.environmentColumnLabel": "Environnement", + "xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, one {1 environnement} other {# environnements}}", + "xpack.apm.servicesTable.healthColumnLabel": "Intégrité", + "xpack.apm.servicesTable.latencyAvgColumnLabel": "Latence (moy.)", + "xpack.apm.servicesTable.metricsExplanationLabel": "Que sont ces indicateurs ?", + "xpack.apm.servicesTable.nameColumnLabel": "Nom", + "xpack.apm.servicesTable.notFoundLabel": "Aucun service trouvé", + "xpack.apm.servicesTable.throughputColumnLabel": "Rendement", + "xpack.apm.servicesTable.tooltip.metricsExplanation": "Les indicateurs des services sont agrégés selon le type de transaction \"request\", \"page-load\", ou en fonction du type de transaction de niveau supérieur disponible.", + "xpack.apm.servicesTable.transactionColumnLabel": "Type de transaction", + "xpack.apm.servicesTable.transactionErrorRate": "Taux de transactions ayant échoué", + "xpack.apm.settings.agentConfig": "Configuration de l'agent", + "xpack.apm.settings.agentConfig.createConfigButton.tooltip": "Vous ne disposez pas d'autorisations pour créer des configurations d'agent", + "xpack.apm.settings.agentConfig.descriptionText": "Affinez votre configuration d'agent depuis l'application APM. Les modifications sont automatiquement propagées à vos agents APM, ce qui vous évite d'effectuer un redéploiement.", + "xpack.apm.settings.anomaly_detection.legacy_jobs.button": "Consulter les tâches", + "xpack.apm.settings.anomalyDetection": "Détection des anomalies", + "xpack.apm.settings.anomalyDetection.addEnvironments.cancelButtonText": "Annuler", + "xpack.apm.settings.anomalyDetection.addEnvironments.createJobsButtonText": "Créer des tâches", + "xpack.apm.settings.anomalyDetection.addEnvironments.descriptionText": "Sélectionnez les environnements de services dans lesquels vous souhaitez activer la détection des anomalies. Les anomalies seront mises en évidence pour tous les services et types de transactions dans les environnements sélectionnés.", + "xpack.apm.settings.anomalyDetection.addEnvironments.selectorLabel": "Environnements", + "xpack.apm.settings.anomalyDetection.addEnvironments.selectorPlaceholder": "Sélectionner ou ajouter des environnements", + "xpack.apm.settings.anomalyDetection.addEnvironments.titleText": "Sélectionner des environnements", + "xpack.apm.settings.anomalyDetection.jobList.actionColumnLabel": "Action", + "xpack.apm.settings.anomalyDetection.jobList.addEnvironments": "Créer une tâche de ML", + "xpack.apm.settings.anomalyDetection.jobList.emptyListText": "Aucune tâche de détection des anomalies.", + "xpack.apm.settings.anomalyDetection.jobList.environmentColumnLabel": "Environnement", + "xpack.apm.settings.anomalyDetection.jobList.environments": "Environnements", + "xpack.apm.settings.anomalyDetection.jobList.failedFetchText": "Impossible de récupérer les tâches de détection des anomalies.", + "xpack.apm.settings.anomalyDetection.jobList.mlDescriptionText": "Pour ajouter la détection des anomalies à un nouvel environnement, créez une tâche de Machine Learning. Vous pouvez gérer les tâches de Machine Learning existantes dans {mlJobsLink}.", + "xpack.apm.settings.anomalyDetection.jobList.mlDescriptionText.mlJobsLinkText": "Machine Learning", + "xpack.apm.settings.anomalyDetection.jobList.mlJobLinkText": "Afficher une tâche dans ML", + "xpack.apm.settings.apmIndices.applyButton": "Appliquer les modifications", + "xpack.apm.settings.apmIndices.applyChanges.failed.text": "Un problème est survenu lors de l'application des index. Erreur : {errorMessage}", + "xpack.apm.settings.apmIndices.applyChanges.failed.title": "Impossible d'appliquer les index.", + "xpack.apm.settings.apmIndices.applyChanges.succeeded.text": "Les modifications apportées aux index ont été correctement appliquées. Ces modifications sont immédiatement appliquées dans l'interface utilisateur APM", + "xpack.apm.settings.apmIndices.applyChanges.succeeded.title": "Index appliqués", + "xpack.apm.settings.apmIndices.cancelButton": "Annuler", + "xpack.apm.settings.apmIndices.description": "L'interface utilisateur APM utilise des modèles d'indexation pour interroger vos index APM. Si vous avez personnalisé les noms des index sur lesquels le serveur APM écrit les événements, vous devrez peut-être mettre à jour ces modèles pour que l'interface utilisateur APM fonctionne. Dans ce cas précis, les paramètres prévalent sur ceux définis dans kibana.yml.", + "xpack.apm.settings.apmIndices.errorIndicesLabel": "Index des erreurs", + "xpack.apm.settings.apmIndices.helpText": "Remplace {configurationName} : {defaultValue}", + "xpack.apm.settings.apmIndices.metricsIndicesLabel": "Index des indicateurs", + "xpack.apm.settings.apmIndices.noPermissionTooltipLabel": "Votre rôle d'utilisateur ne dispose pas d'autorisations pour changer les index APM", + "xpack.apm.settings.apmIndices.onboardingIndicesLabel": "Intégration des index", + "xpack.apm.settings.apmIndices.sourcemapIndicesLabel": "Index des source maps", + "xpack.apm.settings.apmIndices.spanIndicesLabel": "Index des intervalles", + "xpack.apm.settings.apmIndices.title": "Index", + "xpack.apm.settings.apmIndices.transactionIndicesLabel": "Index des transactions", + "xpack.apm.settings.createApmPackagePolicy.errorToast.title": "Impossible de créer une politique de package APM sur la politique d'agent cloud", + "xpack.apm.settings.customizeApp": "Personnaliser l'application", + "xpack.apm.settings.indices": "Index", + "xpack.apm.settings.schema": "Schéma", + "xpack.apm.settings.schema.confirm.apmServerSettingsCloudLinkText": "Accéder aux paramètres du serveur APM dans Elastic Cloud", + "xpack.apm.settings.schema.confirm.cancelText": "Annuler", + "xpack.apm.settings.schema.confirm.checkboxLabel": "Je confirme vouloir basculer vers les flux de données", + "xpack.apm.settings.schema.confirm.irreversibleWarning.message": "Il est possible que cela affecte temporairement votre collecte de données APM pendant la progression de la migration. Le processus de migration ne devrait prendre que quelques minutes.", + "xpack.apm.settings.schema.confirm.irreversibleWarning.title": "Le basculement vers les flux de données est une action irréversible", + "xpack.apm.settings.schema.confirm.switchButtonText": "Basculer vers les flux de données", + "xpack.apm.settings.schema.confirm.title": "Veuillez confirmer votre choix", + "xpack.apm.settings.schema.confirm.unsupportedConfigs.descriptionText": "Les paramètres utilisateur apm-server.yml personnalisés compatibles seront déplacés vers le serveur Fleet à votre place. Nous vous informerons des paramètres incompatibles avant de les supprimer.", + "xpack.apm.settings.schema.confirm.unsupportedConfigs.title": "Les paramètres utilisateur apm-server.yml suivants sont incompatibles et seront supprimés", + "xpack.apm.settings.schema.descriptionText.irreversibleEmphasisText": "irréversible", + "xpack.apm.settings.schema.descriptionText.superuserEmphasisText": "superutilisateur", + "xpack.apm.settings.schema.disabledReason": "L'option Basculer vers les flux de données est indisponible : {reasons}", + "xpack.apm.settings.schema.disabledReason.cloudApmMigrationEnabled": "La migration vers le cloud n'est pas activée", + "xpack.apm.settings.schema.disabledReason.hasCloudAgentPolicy": "La politique d'agent cloud n'existe pas", + "xpack.apm.settings.schema.disabledReason.hasRequiredRole": "L'utilisateur ne dispose pas du rôle de superutilisateur", + "xpack.apm.settings.schema.migrate.classicIndices.currentSetup": "Configuration actuelle", + "xpack.apm.settings.schema.migrate.classicIndices.description": "Vous utilisez actuellement des index APM classiques pour vos données. Ce schéma de données est sur le point de disparaître et sera remplacé par des flux de données dans la version 8.0 d'Elastic Stack.", + "xpack.apm.settings.schema.migrate.classicIndices.title": "Index APM classiques", + "xpack.apm.settings.schema.migrate.dataStreams.buttonText": "Basculer vers les flux de données", + "xpack.apm.settings.schema.migrate.dataStreams.description": "À partir de maintenant, toutes les nouvelles données ingérées seront stockées dans les flux de données. Les données précédemment ingérées restent dans les index APM classiques. Les applications APM et UX continueront à prendre en charge les deux types d'index.", + "xpack.apm.settings.schema.migrate.dataStreams.title": "Flux de données", + "xpack.apm.settings.schema.migrationInProgressPanelDescription": "Nous créons actuellement une instance de serveur Fleet pour contenir le nouveau serveur APM pendant la fermeture de l'ancienne instance du serveur APM. Dans quelques minutes, vous devriez voir vos données réintégrer l'application.", + "xpack.apm.settings.schema.migrationInProgressPanelTitle": "Basculement vers les flux de données…", + "xpack.apm.settings.schema.success.description": "Votre intégration APM est à présent configurée et prête à recevoir des données de vos agents actuellement instrumentés. N'hésitez pas à consulter les politiques appliquées à votre intégration.", + "xpack.apm.settings.schema.success.returnText": "ou revenez simplement à l'{serviceInventoryLink}.", + "xpack.apm.settings.schema.success.returnText.serviceInventoryLink": "Inventaire de service", + "xpack.apm.settings.schema.success.title": "Les flux de données ont été configurés avec succès !", + "xpack.apm.settings.schema.success.viewIntegrationInFleet.buttonText": "Afficher l'intégration APM dans Fleet", + "xpack.apm.settings.title": "Paramètres", + "xpack.apm.settings.unsupportedConfigs.errorToast.title": "Impossible de récupérer les paramètres du serveur APM", + "xpack.apm.settingsLinkLabel": "Paramètres", + "xpack.apm.setupInstructionsButtonLabel": "Instructions de configuration", + "xpack.apm.stacktraceTab.causedByFramesToogleButtonLabel": "Provoqué par", + "xpack.apm.stacktraceTab.libraryFramesToogleButtonLabel": "{count, plural, one {# cadre de bibliothèque} other {# cadres de bibliothèque}}", + "xpack.apm.stacktraceTab.localVariablesToogleButtonLabel": "Variables locales", + "xpack.apm.stacktraceTab.noStacktraceAvailableLabel": "Aucune trace de pile disponible.", + "xpack.apm.timeComparison.label": "Comparaison", + "xpack.apm.timeComparison.select.dayBefore": "Jour précédent", + "xpack.apm.timeComparison.select.weekBefore": "Semaine précédente", + "xpack.apm.toggleHeight.showLessButtonLabel": "Afficher moins de lignes", + "xpack.apm.toggleHeight.showMoreButtonLabel": "Afficher plus de lignes", + "xpack.apm.tracesTable.avgResponseTimeColumnLabel": "Latence (moy.)", + "xpack.apm.tracesTable.impactColumnDescription": "Points de terminaison les plus utilisés et les plus lents de votre service. C'est le résultat de la multiplication de la latence et du rendement", + "xpack.apm.tracesTable.impactColumnLabel": "Impact", + "xpack.apm.tracesTable.nameColumnLabel": "Nom", + "xpack.apm.tracesTable.notFoundLabel": "Aucune trace trouvée pour cette recherche", + "xpack.apm.tracesTable.originatingServiceColumnLabel": "Service d'origine", + "xpack.apm.tracesTable.tracesPerMinuteColumnLabel": "Traces par minute", + "xpack.apm.transactionActionMenu.actionsButtonLabel": "Investiguer", + "xpack.apm.transactionActionMenu.container.subtitle": "Affichez les logs et les indicateurs de ce conteneur pour plus de détails.", + "xpack.apm.transactionActionMenu.container.title": "Détails du conteneur", + "xpack.apm.transactionActionMenu.customLink.section": "Liens personnalisés", + "xpack.apm.transactionActionMenu.customLink.showAll": "Afficher tout", + "xpack.apm.transactionActionMenu.customLink.showFewer": "Afficher moins", + "xpack.apm.transactionActionMenu.customLink.subtitle": "Les liens s'ouvriront dans une nouvelle fenêtre.", + "xpack.apm.transactionActionMenu.host.subtitle": "Affichez les logs et les indicateurs de l'hôte pour plus de détails.", + "xpack.apm.transactionActionMenu.host.title": "Détails de l'hôte", + "xpack.apm.transactionActionMenu.pod.subtitle": "Affichez les logs et indicateurs de ce pod pour plus de détails.", + "xpack.apm.transactionActionMenu.pod.title": "Détails du pod", + "xpack.apm.transactionActionMenu.showContainerLogsLinkLabel": "Logs du conteneur", + "xpack.apm.transactionActionMenu.showContainerMetricsLinkLabel": "Indicateurs du conteneur", + "xpack.apm.transactionActionMenu.showHostLogsLinkLabel": "Logs de l'hôte", + "xpack.apm.transactionActionMenu.showHostMetricsLinkLabel": "Indicateurs de l'hôte", + "xpack.apm.transactionActionMenu.showPodLogsLinkLabel": "Logs du pod", + "xpack.apm.transactionActionMenu.showPodMetricsLinkLabel": "Indicateurs du pod", + "xpack.apm.transactionActionMenu.showTraceLogsLinkLabel": "Logs de trace", + "xpack.apm.transactionActionMenu.status.subtitle": "Affichez le statut pour plus de détails.", + "xpack.apm.transactionActionMenu.status.title": "Détails de statut", + "xpack.apm.transactionActionMenu.trace.subtitle": "Afficher les logs de trace pour plus de détails.", + "xpack.apm.transactionActionMenu.trace.title": "Détails de la trace", + "xpack.apm.transactionActionMenu.viewInUptime": "Statut", + "xpack.apm.transactionActionMenu.viewSampleDocumentLinkLabel": "Afficher l'exemple de document", + "xpack.apm.transactionBreakdown.chartTitle": "Temps consacré par type d'intervalle", + "xpack.apm.transactionDetails.clearSelectionAriaLabel": "Effacer la sélection", + "xpack.apm.transactionDetails.distribution.panelTitle": "Distribution de la latence", + "xpack.apm.transactionDetails.emptySelectionText": "Glisser et déposer pour sélectionner une plage", + "xpack.apm.transactionDetails.errorCount": "{errorCount, number} {errorCount, plural, one {erreur} other {erreurs}}", + "xpack.apm.transactionDetails.noTraceParentButtonTooltip": "Le parent de la trace n'a pas pu être trouvé", + "xpack.apm.transactionDetails.percentOfTraceLabelExplanation": "Le % de {parentType, select, transaction {transaction} trace {trace} } dépasse 100 %, car {childType, select, span {cet intervalle} transaction {cette transaction} } prend plus de temps que la transaction racine.", + "xpack.apm.transactionDetails.requestMethodLabel": "Méthode de requête", + "xpack.apm.transactionDetails.resultLabel": "Résultat", + "xpack.apm.transactionDetails.serviceLabel": "Service", + "xpack.apm.transactionDetails.servicesTitle": "Services", + "xpack.apm.transactionDetails.spanFlyout.compositeExampleWarning": "Il s'agit d'un exemple de document pour un groupe d'intervalles similaires consécutifs", + "xpack.apm.transactionDetails.spanFlyout.databaseStatementTitle": "Déclaration de la base de données", + "xpack.apm.transactionDetails.spanFlyout.nameLabel": "Nom", + "xpack.apm.transactionDetails.spanFlyout.spanAction": "Action", + "xpack.apm.transactionDetails.spanFlyout.spanDetailsTitle": "Détails de l'intervalle", + "xpack.apm.transactionDetails.spanFlyout.spanSubtype": "Sous-type", + "xpack.apm.transactionDetails.spanFlyout.spanType": "Type", + "xpack.apm.transactionDetails.spanFlyout.spanType.navigationTimingLabel": "Temporisation de la navigation", + "xpack.apm.transactionDetails.spanFlyout.stackTraceTabLabel": "Trace de pile", + "xpack.apm.transactionDetails.spanFlyout.viewSpanInDiscoverButtonLabel": "Afficher l'intervalle dans Discover", + "xpack.apm.transactionDetails.spanTypeLegendTitle": "Type", + "xpack.apm.transactionDetails.statusCode": "Code du statut", + "xpack.apm.transactionDetails.syncBadgeAsync": "async", + "xpack.apm.transactionDetails.syncBadgeBlocking": "blocage", + "xpack.apm.transactionDetails.tabs.failedTransactionsCorrelationsLabel": "Corrélations des transactions ayant échoué", + "xpack.apm.transactionDetails.tabs.latencyLabel": "Corrélations de latence", + "xpack.apm.transactionDetails.tabs.traceSamplesLabel": "Échantillons de traces", + "xpack.apm.transactionDetails.traceNotFound": "La trace sélectionnée n'a pas pu être trouvée", + "xpack.apm.transactionDetails.traceSampleTitle": "Échantillon de trace", + "xpack.apm.transactionDetails.transactionLabel": "Transaction", + "xpack.apm.transactionDetails.transFlyout.callout.agentDroppedSpansMessage": "L'agent APM qui a signalé cette transaction a abandonné {dropped} intervalles ou plus, d'après sa configuration.", + "xpack.apm.transactionDetails.transFlyout.callout.learnMoreAboutDroppedSpansLinkText": "Découvrir plus d'informations sur les intervalles abandonnés.", + "xpack.apm.transactionDetails.transFlyout.transactionDetailsTitle": "Détails de la transaction", + "xpack.apm.transactionDetails.userAgentAndVersionLabel": "Agent utilisateur et version", + "xpack.apm.transactionDetails.viewFullTraceButtonLabel": "Afficher la trace complète", + "xpack.apm.transactionDetails.viewingFullTraceButtonTooltip": "Affichage actuel de la trace complète", + "xpack.apm.transactionDistribution.chart.allTransactionsLabel": "Toutes les transactions", + "xpack.apm.transactionDistribution.chart.currentTransactionMarkerLabel": "Échantillon actuel", + "xpack.apm.transactionDistribution.chart.numberOfTransactionsLabel": "Nb de transactions", + "xpack.apm.transactionDistribution.chart.percentileMarkerLabel": "{markerPercentile}e centile", + "xpack.apm.transactionDurationAlert.aggregationType.95th": "95e centile", + "xpack.apm.transactionDurationAlert.aggregationType.99th": "99e centile", + "xpack.apm.transactionDurationAlert.aggregationType.avg": "Moyenne", + "xpack.apm.transactionDurationAlert.name": "Seuil de latence", + "xpack.apm.transactionDurationAlertTrigger.ms": "ms", + "xpack.apm.transactionDurationAlertTrigger.when": "Quand", + "xpack.apm.transactionDurationAnomalyAlert.name": "Anomalie de latence", + "xpack.apm.transactionDurationAnomalyAlertTrigger.anomalySeverity": "Comporte une anomalie avec sévérité", + "xpack.apm.transactionDurationLabel": "Durée", + "xpack.apm.transactionErrorRateAlert.name": "Seuil du taux de transactions ayant échoué", + "xpack.apm.transactionErrorRateAlertTrigger.isAbove": "est supérieur à", + "xpack.apm.transactionRateLabel": "{displayedValue} tpm", + "xpack.apm.transactions.latency.chart.95thPercentileLabel": "95e centile", + "xpack.apm.transactions.latency.chart.99thPercentileLabel": "99e centile", + "xpack.apm.transactions.latency.chart.averageLabel": "Moyenne", + "xpack.apm.tutorial.agent_config.choosePolicy.helper": "Ajoute la configuration de la politique sélectionnée à l'extrait ci-dessous.", + "xpack.apm.tutorial.agent_config.choosePolicyLabel": "Choix de la politique", + "xpack.apm.tutorial.agent_config.defaultStandaloneConfig": "Configuration autonome par défaut", + "xpack.apm.tutorial.agent_config.fleetPoliciesLabel": "Politiques Fleet", + "xpack.apm.tutorial.agent_config.getStartedWithFleet": "Démarrer avec Fleet", + "xpack.apm.tutorial.agent_config.manageFleetPolicies": "Gérer les politiques Fleet", + "xpack.apm.tutorial.apmAgents.statusCheck.btnLabel": "Vérifier le statut de l'agent", + "xpack.apm.tutorial.apmAgents.statusCheck.errorMessage": "Aucune donnée n'a encore été reçue des agents", + "xpack.apm.tutorial.apmAgents.statusCheck.successMessage": "Les données ont été correctement reçues d'un ou de plusieurs agents", + "xpack.apm.tutorial.apmAgents.statusCheck.text": "Vérifiez que votre application est en cours d'exécution et que les agents envoient les données.", + "xpack.apm.tutorial.apmAgents.statusCheck.title": "Statut de l'agent", + "xpack.apm.tutorial.apmAgents.title": "Agents APM", + "xpack.apm.tutorial.apmServer.callOut.message": "Assurez-vous de mettre à jour votre serveur APM vers la version 7.0 ou supérieure. Vous pouvez également migrer vos données 6.x à l'aide de l'assistant de migration disponible dans la section de gestion de Kibana.", + "xpack.apm.tutorial.apmServer.callOut.title": "Important : mise à niveau vers la version 7.0 ou supérieure", + "xpack.apm.tutorial.apmServer.fleet.apmIntegration.button": "Intégration APM", + "xpack.apm.tutorial.apmServer.fleet.manageApmIntegration.button": "Gérer l'intégration APM dans Fleet", + "xpack.apm.tutorial.apmServer.fleet.message": "L'intégration d'APM installe les modèles Elasticsearch et les pipelines de nœuds d'ingestion pour les données APM.", + "xpack.apm.tutorial.apmServer.fleet.title": "Elastic APM (version bêta) est maintenant disponible dans Fleet !", + "xpack.apm.tutorial.apmServer.statusCheck.btnLabel": "Vérifier le statut du serveur APM", + "xpack.apm.tutorial.apmServer.statusCheck.errorMessage": "Aucun serveur APM détecté. Vérifiez qu'il est en cours d'exécution et que vous avez effectué la mise à jour vers la version 7.0 ou supérieure.", + "xpack.apm.tutorial.apmServer.statusCheck.successMessage": "Vous avez correctement configuré le serveur APM", + "xpack.apm.tutorial.apmServer.statusCheck.text": "Vérifiez que le serveur APM est en cours d'exécution avant de commencer à mettre en œuvre les agents APM.", + "xpack.apm.tutorial.apmServer.statusCheck.title": "Statut du serveur APM", + "xpack.apm.tutorial.apmServer.title": "Serveur APM", + "xpack.apm.tutorial.djangoClient.configure.commands.addAgentComment": "Ajouter l'agent aux applications installées", + "xpack.apm.tutorial.djangoClient.configure.commands.addTracingMiddlewareComment": "Pour envoyer les indicateurs de performance, ajoutez notre intergiciel de traçage :", + "xpack.apm.tutorial.djangoClient.configure.commands.allowedCharactersComment": "a-z, A-Z, 0-9, -, _ et espace", + "xpack.apm.tutorial.djangoClient.configure.commands.setCustomApmServerUrlComment": "Définir l'URL personnalisée du serveur APM (par défaut : {defaultApmServerUrl})", + "xpack.apm.tutorial.djangoClient.configure.commands.setRequiredServiceNameComment": "Définissez le nom de service obligatoire. Caractères autorisés :", + "xpack.apm.tutorial.djangoClient.configure.commands.setServiceEnvironmentComment": "Définir l'environnement de service", + "xpack.apm.tutorial.djangoClient.configure.commands.useIfApmServerRequiresTokenComment": "À utiliser si le serveur APM requiert un token secret", + "xpack.apm.tutorial.djangoClient.configure.textPost": "Consultez la [documentation]({documentationLink}) pour une utilisation avancée.", + "xpack.apm.tutorial.djangoClient.configure.textPre": "Les agents sont des bibliothèques exécutées dans les processus de votre application. Les services APM sont créés par programmation à partir du \"SERVICE_NAME\".", + "xpack.apm.tutorial.djangoClient.configure.title": "Configurer l'agent", + "xpack.apm.tutorial.djangoClient.install.textPre": "Installez l'agent APM pour Python en tant que dépendance.", + "xpack.apm.tutorial.djangoClient.install.title": "Installer l'agent APM", + "xpack.apm.tutorial.dotNetClient.configureAgent.textPost": "Si vous ne transférez pas une instance \"IConfiguration\" à l'agent (par ex., pour les applications non ASP.NET Core) vous pouvez également configurer l'agent par le biais de variables d'environnement. \n Consultez [the documentation]({documentationLink}) pour une utilisation avancée.", + "xpack.apm.tutorial.dotNetClient.configureAgent.title": "Exemple de fichier appsettings.json :", + "xpack.apm.tutorial.dotNetClient.configureApplication.textPost": "La transmission d'une instance \"IConfiguration\" est facultative mais si cette opération est effectuée, l'agent lira les paramètres de configuration depuis cette instance \"IConfiguration\" (par ex. à partir du fichier \"appsettings.json\").", + "xpack.apm.tutorial.dotNetClient.configureApplication.textPre": "Si vous utilisez ASP.NET Core avec le package \"Elastic.Apm.NetCoreAll\", appelez la méthode \"UseAllElasticApm\" dans la méthode \"Configure\" dans le fichier \"Startup.cs\".", + "xpack.apm.tutorial.dotNetClient.configureApplication.title": "Ajouter l'agent à l'application", + "xpack.apm.tutorial.dotNetClient.download.textPre": "Ajoutez le(s) package(s) d'agent depuis [NuGet]({allNuGetPackagesLink}) à votre application .NET. Plusieurs packages NuGet sont disponibles pour différents cas d'utilisation. \n\nPour une application ASP.NET Core avec Entity Framework Core, téléchargez le package [Elastic.Apm.NetCoreAll]({netCoreAllApmPackageLink}). Ce package ajoutera automatiquement chaque composant d'agent à votre application. \n\n Si vous souhaitez minimiser les dépendances, vous pouvez utiliser le package [Elastic.Apm.AspNetCore]({aspNetCorePackageLink}) uniquement pour le monitoring d'ASP.NET Core ou le package [Elastic.Apm.EfCore]({efCorePackageLink}) uniquement pour le monitoring d'Entity Framework Core. \n\n Si vous souhaitez seulement utiliser l'API d'agent publique pour l'instrumentation manuelle, utilisez le package [Elastic.Apm]({elasticApmPackageLink}).", + "xpack.apm.tutorial.dotNetClient.download.title": "Télécharger l'agent APM", + "xpack.apm.tutorial.downloadServer.title": "Télécharger et décompresser le serveur APM", + "xpack.apm.tutorial.downloadServerRpm": "Vous cherchez les packages 32 bits ? Consultez la [Download page]({downloadPageLink}).", + "xpack.apm.tutorial.downloadServerTitle": "Vous cherchez les packages 32 bits ? Consultez la [Download page]({downloadPageLink}).", + "xpack.apm.tutorial.editConfig.textPre": "Si vous utilisez une version sécurisée X-Pack d'Elastic Stack, vous devez spécifier les informations d'identification dans le fichier de configuration \"apm-server.yml\".", + "xpack.apm.tutorial.editConfig.title": "Modifier la configuration", + "xpack.apm.tutorial.elasticCloud.textPre": "Pour activer le serveur APM, accédez à [the Elastic Cloud console](https://cloud.elastic.co/deployments/{deploymentId}/edit) et activez APM dans les paramètres de déploiement. Une fois activé, actualisez la page.", + "xpack.apm.tutorial.elasticCloudInstructions.title": "Agents APM", + "xpack.apm.tutorial.flaskClient.configure.commands.allowedCharactersComment": "a-z, A-Z, 0-9, -, _ et espace", + "xpack.apm.tutorial.flaskClient.configure.commands.configureElasticApmComment": "ou configurer l'utilisation d'ELASTIC_APM dans les paramètres de votre application", + "xpack.apm.tutorial.flaskClient.configure.commands.initializeUsingEnvironmentVariablesComment": "initialiser à l'aide des variables d'environnement", + "xpack.apm.tutorial.flaskClient.configure.commands.setCustomApmServerUrlComment": "Définir l'URL personnalisée du serveur APM (par défaut : {defaultApmServerUrl})", + "xpack.apm.tutorial.flaskClient.configure.commands.setRequiredServiceNameComment": "Définissez le nom de service obligatoire. Caractères autorisés :", + "xpack.apm.tutorial.flaskClient.configure.commands.setServiceEnvironmentComment": "Définir l'environnement de service", + "xpack.apm.tutorial.flaskClient.configure.commands.useIfApmServerRequiresTokenComment": "À utiliser si le serveur APM requiert un token secret", + "xpack.apm.tutorial.flaskClient.configure.textPost": "Consultez la [documentation]({documentationLink}) pour une utilisation avancée.", + "xpack.apm.tutorial.flaskClient.configure.textPre": "Les agents sont des bibliothèques exécutées dans les processus de votre application. Les services APM sont créés par programmation à partir du \"SERVICE_NAME\".", + "xpack.apm.tutorial.flaskClient.configure.title": "Configurer l'agent", + "xpack.apm.tutorial.flaskClient.install.textPre": "Installez l'agent APM pour Python en tant que dépendance.", + "xpack.apm.tutorial.flaskClient.install.title": "Installer l'agent APM", + "xpack.apm.tutorial.goClient.configure.commands.initializeUsingEnvironmentVariablesComment": "Initialisez à l'aide des variables d'environnement :", + "xpack.apm.tutorial.goClient.configure.commands.setCustomApmServerUrlComment": "Définir l'URL de serveur APM personnalisée (par défaut : {defaultApmServerUrl})", + "xpack.apm.tutorial.goClient.configure.commands.setServiceEnvironment": "Définir l'environnement de service", + "xpack.apm.tutorial.goClient.configure.commands.setServiceNameComment": "Configurez le nom de service. Caractères autorisés : # a-z, A-Z, 0-9, -, _ et espace.", + "xpack.apm.tutorial.goClient.configure.commands.usedExecutableNameComment": "Si ELASTIC_APM_SERVICE_NAME n'est pas spécifié, le nom de l'exécutable sera utilisé.", + "xpack.apm.tutorial.goClient.configure.commands.useIfApmRequiresTokenComment": "À utiliser si le serveur APM requiert un token secret", + "xpack.apm.tutorial.goClient.configure.textPost": "Consultez la [documentation]({documentationLink}) pour une configuration avancée.", + "xpack.apm.tutorial.goClient.configure.textPre": "Les agents sont des bibliothèques exécutées dans les processus de votre application. Les services APM sont créés par programmation à partir du nom du fichier exécutable, ou de la variable d'environnement \"ELASTIC_APM_SERVICE_NAME\".", + "xpack.apm.tutorial.goClient.configure.title": "Configurer l'agent", + "xpack.apm.tutorial.goClient.install.textPre": "Installez les packages d'agent APM pour Go.", + "xpack.apm.tutorial.goClient.install.title": "Installer l'agent APM", + "xpack.apm.tutorial.goClient.instrument.textPost": "Consultez la [documentation]({documentationLink}) pour obtenir un guide détaillé pour l'instrumentation du code source Go.", + "xpack.apm.tutorial.goClient.instrument.textPre": "Pour instrumenter votre application Go, utilisez l'un des modules d'instrumentation proposés ou directement l'API de traçage.", + "xpack.apm.tutorial.goClient.instrument.title": "Instrumenter votre application", + "xpack.apm.tutorial.introduction": "Collectez les indicateurs et les erreurs de performances approfondies depuis vos applications.", + "xpack.apm.tutorial.javaClient.download.textPre": "Téléchargez le fichier jar de l'agent depuis [Maven Central]({mavenCentralLink}). N'ajoutez **pas** l'agent comme dépendance de votre application.", + "xpack.apm.tutorial.javaClient.download.title": "Télécharger l'agent APM", + "xpack.apm.tutorial.javaClient.startApplication.textPost": "Consultez la [documentation]({documentationLink}) pour découvrir les options de configuration et l'utilisation avancée.", + "xpack.apm.tutorial.javaClient.startApplication.textPre": "Ajoutez l'indicateur \"-javaagent\" et configurez l'agent avec les propriétés du système.\n\n * Définir le nom de service requis (caractères autorisés : a-z, A-Z, 0-9, -, _ et espace)\n * Définir l'URL personnalisée du serveur APM (par défaut : {customApmServerUrl})\n * Définir le token secret du serveur APM\n * Définir l'environnement de service\n * Définir le package de base de votre application", + "xpack.apm.tutorial.javaClient.startApplication.title": "Lancer votre application avec l'indicateur javaagent", + "xpack.apm.tutorial.jsClient.enableRealUserMonitoring.textPre": "Le serveur APM désactive la prise en charge du RUM par défaut. Consultez la [documentation]({documentationLink}) pour obtenir des détails sur l'activation de la prise en charge du RUM.", + "xpack.apm.tutorial.jsClient.enableRealUserMonitoring.title": "Activer la prise en charge du Real User Monitoring (monitoring des utilisateurs réels) dans le serveur APM", + "xpack.apm.tutorial.jsClient.installDependency.commands.setCustomApmServerUrlComment": "Définir l'URL de serveur APM personnalisée (par défaut : {defaultApmServerUrl})", + "xpack.apm.tutorial.jsClient.installDependency.commands.setRequiredServiceNameComment": "Définir le nom de service requis (caractères autorisés : a-z, A-Z, 0-9, -, _ et espace)", + "xpack.apm.tutorial.jsClient.installDependency.commands.setServiceEnvironmentComment": "Définir l'environnement de service", + "xpack.apm.tutorial.jsClient.installDependency.commands.setServiceVersionComment": "Définir la version de service (requis pour la fonctionnalité source map)", + "xpack.apm.tutorial.jsClient.installDependency.textPost": "Les intégrations de framework, tel que React ou Angular, ont des dépendances personnalisées. Consultez la [integration documentation]({docLink}) pour plus d'informations.", + "xpack.apm.tutorial.jsClient.installDependency.textPre": "Vous pouvez installer l'Agent comme dépendance de votre application avec \"npm install @elastic/apm-rum --save\".\n\nVous pouvez ensuite initialiser l'agent et le configurer dans votre application de cette façon :", + "xpack.apm.tutorial.jsClient.installDependency.title": "Configurer l'agent comme dépendance", + "xpack.apm.tutorial.jsClient.scriptTags.textPre": "Vous pouvez également utiliser les balises Script pour configurer l'agent. Ajoutez un indicateur \"